Compare commits

..

1 Commits

Author SHA1 Message Date
Maurice a3f52ebd7b trim mobile labels in journey picker + guard JourneyMap flyTo
- mobile-shorten 'Alle Fotos' → 'Alle' in MemoriesPanel picker and the
  Journey ProviderPicker filter tabs (four tabs no longer wrap)
- mobile-shorten 'Datum wählen' → 'Datum' in the entry-editor DatePicker
  placeholder
- guard JourneyMap.tsx flyTo: getZoom() throws "Set map center and zoom
  first" when activeMarkerId arrives before fitBounds has set a view —
  wrap in try/catch and fall back to setView.
2026-04-18 19:28:19 +02:00
107 changed files with 787 additions and 6219 deletions
+2
View File
@@ -1,5 +1,6 @@
# Normalize line endings to LF on commit
* text=auto eol=lf
# Explicitly enforce LF for source files
*.ts text eol=lf
*.tsx text eol=lf
@@ -13,6 +14,7 @@
*.yaml text eol=lf
*.py text eol=lf
*.sh text eol=lf
# Binary files — no line ending conversion
*.png binary
*.jpg binary
+24 -103
View File
@@ -16,7 +16,6 @@ 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)
@@ -53,11 +52,10 @@ management required — just provide the server URL:
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
**What happens automatically:**
1. The client fetches `/.well-known/oauth-protected-resource` (RFC 9728) to discover the authorization server and bind the `/mcp` endpoint.
2. The client fetches `/.well-known/oauth-authorization-server` for the full AS metadata.
3. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
4. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
5. The client receives a short-lived access token audience-bound to `/mcp` (RFC 8707) and a rotating refresh token — no re-authorization needed.
1. The client fetches `/.well-known/oauth-authorization-server` to discover the TREK authorization server.
2. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
3. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
4. The client receives a short-lived access token and a rotating refresh token — no re-authorization needed.
> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
> discovery to work correctly.
@@ -142,17 +140,13 @@ 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, Collab, Vacay, Journey) require both the relevant scope **and** the addon to be enabled.
- Addon-gated tools (Atlas Extended, Collab, Vacay) require both the relevant scope **and** the addon to be enabled.
---
@@ -173,7 +167,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, Journey) is enabled by an admin. |
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay) is enabled by an admin. |
---
@@ -200,6 +194,7 @@ 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 |
@@ -219,10 +214,6 @@ 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 |
---
@@ -235,23 +226,7 @@ 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, 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).
---
| `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. |
### Trips
@@ -272,18 +247,14 @@ Compound tools collapse common multi-step workflows into a single atomic call. E
### 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. |
| `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`. |
| `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`. |
### Day Planning
@@ -302,40 +273,24 @@ Compound tools collapse common multi-step workflows into a single atomic call. E
### 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
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. |
| 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. |
### 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. |
@@ -415,14 +370,7 @@ For flights, trains, cars, and cruises, use the **Transport** tools above. Reser
| `get_weather` | Get weather forecast for a location and date. |
| `get_detailed_weather`| Get hourly/detailed weather forecast for a location and date. |
### 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)_
### Collab Notes
| Tool | Description |
|----------------------|-------------------------------------------------------------------------------------------------|
@@ -444,14 +392,14 @@ For flights, trains, cars, and cruises, use the **Transport** tools above. Reser
| `delete_collab_message`| Delete a chat message (own messages only). |
| `react_collab_message`| Toggle a reaction emoji on a chat message. |
### Bucket List _(Atlas addon required)_
### Bucket List
| 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 addon required)_
### Atlas
| Tool | Description |
|--------------------------|---------------------------------------------------------------------------------|
@@ -496,33 +444,6 @@ For flights, trains, cars, and cruises, use the **Transport** tools above. Reser
| `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
+217 -273
View File
@@ -1,160 +1,121 @@
<div align="center">
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="client/public/logo-light.svg" />
<source media="(prefers-color-scheme: light)" srcset="client/public/logo-dark.svg" />
<img src="client/public/logo-light.svg" alt="TREK" height="60" />
</picture>
<br />
<em>Your Trips. Your Plan.</em>
</p>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.gif" />
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" />
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
</picture>
<p align="center">
<a href="https://discord.gg/NhZBDSd4qW"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
<a href="https://github.com/mauriceboe/TREK/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/TREK" alt="Last Commit" /></a>
</p>
### Your trips. Your plan. Your server.
<p align="center">
A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more.
<br />
<strong><a href="https://demo-nomad.pakulat.org">Live Demo</a></strong> — Try TREK without installing. Resets hourly.
</p>
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
<br />
<a href="https://demo-nomad.pakulat.org"><img alt="Live Demo" src="https://img.shields.io/badge/Live_Demo-try_it_now-111827?style=for-the-badge" /></a>
&nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge&logo=docker&logoColor=white" /></a>
&nbsp;
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-community-5865F2?style=for-the-badge&logo=discord&logoColor=white" /></a>
&nbsp;
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-board-0EA5E9?style=for-the-badge&logo=trello&logoColor=white" /></a>
<br />
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
<a href="https://github.com/mauriceboe/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
</div>
---
<div align="center">
<img src="https://github.com/mauriceboe/test/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
</div>
<br />
<div align="center">
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
</div>
---
## What you get
<picture>
<source media="(max-width: 700px)" srcset="docs/tiles/grid-mobile.svg" />
<img src="docs/tiles/grid-desktop.svg" alt="TREK feature tiles" width="100%" />
</picture>
![TREK Screenshot](docs/screenshot.png)
![TREK Screenshot 2](docs/screenshot-2.png)
<details>
<summary><b>See all features</b></summary>
<summary>More Screenshots</summary>
<table>
<tr>
<td width="50%" valign="top">
#### 🧭 Trip planning
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
- **Route optimisation** — auto-sort places and export to Google Maps
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
- **Category filter** — show only matching pins on the map
</td>
<td width="50%" valign="top">
#### 🧳 Travel management
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
- **Packing lists** — categories, templates, user assignment, progress tracking
- **Bag tracking** — optional weight tracking with iOS-style distribution
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
- **PDF export** — full trip plan as PDF with cover page, images, notes
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 👥 Collaboration
- **Real-time sync** — WebSocket. Changes appear instantly across all connected users
- **Multi-user trips** — invite members with role-based access
- **Invite links** — one-time or reusable links with expiry
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
- **2FA** — TOTP + backup codes
- **Collab suite** — group chat, shared notes, polls, day check-ins
</td>
<td width="50%" valign="top">
#### 📱 Mobile & PWA
- **Installable** — iOS and Android, straight from the browser, no App Store needed
- **Offline support** — Service Worker caches tiles, API, uploads via Workbox
- **Native feel** — fullscreen standalone, themed status bar, splash screen
- **Touch optimised** — mobile-specific layouts with safe-area handling
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 🧩 Addons (admin-toggleable)
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Collab** — chat, notes, polls, day-by-day attendance
- **Journey** — magazine-style travel journal with entries, photos, maps, moods
- **Dashboard widgets** — currency converter and timezone clocks
</td>
<td width="50%" valign="top">
#### 🤖 AI / MCP
- **Built-in MCP server** — OAuth 2.1 authenticated. 80+ tools, 27 resources
- **Granular scopes** — 24 OAuth scopes across 13 permission groups
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
</td>
</tr>
<tr>
<td colspan="2" valign="top">
#### ⚙️ Admin & customisation
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
- **14 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
</td>
</tr>
</table>
| | |
|---|---|
| ![Plan Detail](docs/screenshot-plan-detail.png) | ![Bookings](docs/screenshot-bookings.png) |
| ![Budget](docs/screenshot-budget.png) | ![Packing List](docs/screenshot-packing.png) |
| ![Collab](docs/screenshot-collab.png) | |
</details>
<br />
## Features
## Get started in 30 seconds
### Trip Planning
- **Drag & Drop Planner** — Organize places into day plans with reordering and cross-day moves
- **Interactive Map** — Leaflet map with photo markers, clustering, route visualization, and customizable tile sources
- **Place Search** — Search via Google Places (with photos, ratings, opening hours) or OpenStreetMap (free, no API key needed)
- **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering
- **Route Optimization** — Auto-optimize place order and export to Google Maps
- **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback
- **Map Category Filter** — Filter places by category and see only matching pins on the map
### Travel Management
- **Reservations & Bookings** — Track flights, accommodations, restaurants with status, confirmation numbers, and file attachments
- **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support
- **Packing Lists** — Category-based checklists with user assignment, packing templates, and progress tracking
- **Packing Templates** — Create reusable packing templates in the admin panel with categories and items, apply to any trip
- **Bag Tracking** — Optional weight tracking and bag assignment for packing items with iOS-style weight distribution (admin-toggleable)
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
- **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and TREK branding
### Mobile & PWA
- **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed
- **Offline Support** — Service Worker caches map tiles, API data, uploads, and static assets via Workbox
- **Native App Feel** — Fullscreen standalone mode, custom app icon, themed status bar, and splash screen
- **Touch Optimized** — Responsive design with mobile-specific layouts, touch-friendly controls, and safe area handling
### Collaboration
- **Real-Time Sync** — Plan together via WebSocket — changes appear instantly across all connected users
- **Multi-User** — Invite members to collaborate on shared trips with role-based access
- **Invite Links** — Create one-time registration links with configurable max uses and expiry for easy onboarding
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
- **Two-Factor Authentication (MFA)** — TOTP-based 2FA with QR code setup, works with Google Authenticator, Authy, etc.
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
### Addons (modular, admin-toggleable)
- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with live sync, and carry-over tracking
- **Atlas** — Interactive world map with visited countries, bucket list with planned travel dates, travel stats, continent breakdown, streak tracking, and liquid glass UI effects
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
### AI / MCP Integration
- **MCP Server** — Built-in [Model Context Protocol](MCP.md) server with OAuth 2.1 authentication exposes 80+ tools and 27 resources so AI assistants (Claude, Cursor, etc.) can read and modify your trips
- **Granular Scopes** — 24 OAuth scopes across 13 permission groups let you control exactly what data your AI client can access
- **Full Trip Automation** — AI can create trips, plan itineraries, build packing lists, manage budgets, send collab messages, mark countries visited, and more in a single conversation
- **Prompts** — Pre-built `trip-summary`, `packing-list`, and `budget-overview` prompts give AI clients instant structured context
- **Addon-Aware** — Atlas, Collab, and Vacay features are exposed automatically when those addons are enabled
### Customization & Admin
- **Dashboard Views** — Toggle between card grid and compact list view on the My Trips page
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
- **Multilingual** — English, German, Spanish, French, Russian, Chinese (Simplified), Dutch, Indonesian, Arabic (with RTL support)
- **Admin Panel** — User management, invite links, packing templates, global categories, addon management, API keys, backups, and GitHub release history
- **Auto-Backups** — Scheduled backups with configurable interval and retention
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
## Tech Stack
- **Backend**: Node.js 22 + Express + SQLite (`better-sqlite3`)
- **Frontend**: React 18 + Vite + Tailwind CSS
- **PWA**: vite-plugin-pwa + Workbox
- **Real-Time**: WebSocket (`ws`)
- **State**: Zustand
- **Auth**: JWT + OAuth 2.1 + OIDC + TOTP (MFA)
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
- **Weather**: Open-Meteo API (free, no key required)
- **Icons**: lucide-react
## Helm (Kubernetes)
A hosted Helm repository is available:
```sh
helm repo add trek https://mauriceboe.github.io/TREK
helm repo update
helm install trek trek/trek
```
See [`charts/README.md`](charts/README.md) for configuration options.
## Quick Start
```bash
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
@@ -162,40 +123,19 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
```
Open `http://localhost:3000`. The first user to register becomes admin.
The app runs on port `3000`. The first user to register becomes the admin.
<div align="center">
### Install as App (PWA)
&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#docker-compose-production">Docker Compose</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#helm-kubernetes">Helm / Kubernetes</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#install-as-app-pwa">Install as PWA</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#reverse-proxy">Reverse Proxy</a>&nbsp;&nbsp;·&nbsp;&nbsp;
TREK works as a Progressive Web App — no App Store needed:
</div>
<br />
## Tech stack
<div align="center">
![Node.js](https://img.shields.io/badge/Node.js_22-339933?style=flat-square&logo=node.js&logoColor=white)
![Express](https://img.shields.io/badge/Express-000000?style=flat-square&logo=express&logoColor=white)
![SQLite](https://img.shields.io/badge/SQLite-003B57?style=flat-square&logo=sqlite&logoColor=white)
![React](https://img.shields.io/badge/React_18-61DAFB?style=flat-square&logo=react&logoColor=black)
![Vite](https://img.shields.io/badge/Vite-646CFF?style=flat-square&logo=vite&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white)
![Tailwind](https://img.shields.io/badge/Tailwind-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white)
![Leaflet](https://img.shields.io/badge/Leaflet-199900?style=flat-square&logo=leaflet&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-2496ED?style=flat-square&logo=docker&logoColor=white)
</div>
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
<br />
<h2 id="docker-compose-production">Docker Compose (production)</h2>
1. Open your TREK instance in the browser (HTTPS required)
2. **iOS**: Share button → "Add to Home Screen"
3. **Android**: Menu → "Install app" or "Add to Home Screen"
4. TREK launches fullscreen with its own icon, just like a native app
<details>
<summary>Full compose example with secure defaults</summary>
<summary>Docker Compose (recommended for production)</summary>
```yaml
services:
@@ -218,19 +158,30 @@ services:
environment:
- NODE_ENV=production
- PORT=3000
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # generate with: openssl rand -hex 32
- TZ=${TZ:-UTC}
- LOG_LEVEL=${LOG_LEVEL:-info}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
- APP_URL=${APP_URL:-} # required for OIDC + email links
# - FORCE_HTTPS=true # behind a TLS-terminating proxy
# - TRUST_PROXY=1
# - OIDC_ISSUER=https://auth.example.com
# - OIDC_CLIENT_ID=trek
# - OIDC_CLIENT_SECRET=supersecret
# - OIDC_DISPLAY_NAME=SSO
# - OIDC_ADMIN_CLAIM=groups
# - OIDC_ADMIN_VALUE=app-trek-admins
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
- APP_URL=${APP_URL:-} # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP; Also used as the base URL for email notifications and other external links
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
# - OIDC_ONLY=false # Set to true to force SSO-only login (disables password login and registration). Equivalent to toggling those off in Admin > Settings, but takes priority over any DB setting and cannot be changed at runtime.
# - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
# - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role
# - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM)
# - OIDC_DISCOVERY_URL= # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik)
# - DEMO_MODE=false # Enable demo mode (resets data hourly)
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
# - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
# - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
@@ -243,49 +194,29 @@ services:
start_period: 15s
```
Then:
This example is aimed at reverse-proxy deployments where nginx, Caddy, Traefik, or a similar proxy terminates TLS in front of TREK. The three HTTPS-related variables work together:
- **`FORCE_HTTPS`** is 100% optional. When set to `true` it does four things: adds an HTTP-to-HTTPS 301 redirect, sends an HSTS header (`max-age=31536000`), adds the CSP `upgrade-insecure-requests` directive, and forces the session cookie `secure` flag on. It only makes sense behind a TLS-terminating proxy.
- **`TRUST_PROXY`** tells Express how many proxies sit in front of TREK so it can read the real client IP from `X-Forwarded-For` and the protocol from `X-Forwarded-Proto`. Without it, `FORCE_HTTPS` redirects will loop because Express never sees the request as secure. In production (`NODE_ENV=production`) this defaults to `1` automatically; in development it is off unless explicitly set.
- **`COOKIE_SECURE`** is normally auto-derived — the session cookie is marked `secure` whenever `NODE_ENV=production` or `FORCE_HTTPS=true`. Setting `COOKIE_SECURE=false` is an escape hatch that disables the `secure` flag even in production (e.g. testing over plain HTTP on a LAN). Do not disable it in real deployments.
If you access TREK directly on `http://<host>:3000` with no reverse proxy, leave `FORCE_HTTPS` unset (or remove it) and remove `TRUST_PROXY` to avoid redirect loops to a non-existent HTTPS endpoint.
```bash
docker compose up -d
```
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
</details>
<br />
### Updating
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
```bash
helm repo add trek https://mauriceboe.github.io/TREK
helm repo update
helm install trek trek/trek
```
See [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for values.
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
TREK works as a Progressive Web App — no App Store needed.
1. Open TREK in the browser (HTTPS required)
2. **iOS**: Share ▸ *Add to Home Screen*
3. **Android**: Menu ▸ *Install app* (or *Add to Home Screen*)
TREK then launches fullscreen with its own icon, just like a native app.
<br />
## Updating
**Docker Compose:**
**Docker Compose** (recommended):
```bash
docker compose pull && docker compose up -d
```
**Docker run**reuse the original volume paths:
**Docker Run** — use the same volume paths from your original `docker run` command:
```bash
docker pull mauriceboe/trek
@@ -293,23 +224,27 @@ docker rm -f trek
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
```
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
> **Tip:** Not sure which paths you used? Run `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
<h3>Rotating the Encryption Key</h3>
### Rotating the Encryption Key
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
If you need to rotate `ENCRYPTION_KEY` (e.g. you are upgrading from a version that derived encryption from `JWT_SECRET`), use the migration script to re-encrypt all stored secrets under the new key without starting the app:
```bash
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
```
The script creates a timestamped DB backup before making changes and prompts for old + new keys (input is not echoed).
The script will prompt for your old and new keys interactively (input is not echoed). It creates a timestamped database backup before making any changes and exits with a non-zero code if anything fails.
<h2 id="reverse-proxy">Reverse Proxy</h2>
**Upgrading from a previous version?** Your old JWT secret is in `./data/.jwt_secret`. Use its contents as the "old key" and your new `ENCRYPTION_KEY` value as the "new key".
For production, put TREK behind a TLS-terminating reverse proxy. TREK uses WebSockets for real-time sync, so the proxy **must** support WebSocket upgrades on `/ws`.
### Reverse Proxy (recommended)
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
> **Important:** TREK uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path.
<details>
<summary>Nginx</summary>
@@ -325,19 +260,8 @@ server {
listen 443 ssl http2;
server_name trek.yourdomain.com;
ssl_certificate /etc/ssl/fullchain.pem;
ssl_certificate_key /etc/ssl/privkey.pem;
client_max_body_size 50m;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
location /ws {
proxy_pass http://localhost:3000;
@@ -345,6 +269,21 @@ server {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
# File uploads are capped at 50 MB; backup restore ZIPs can include the full
# uploads directory and may exceed that — raise this value if restores fail.
client_max_body_size 500m;
}
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
@@ -354,24 +293,17 @@ server {
<details>
<summary>Caddy</summary>
```caddy
Caddy handles WebSocket upgrades automatically:
```
trek.yourdomain.com {
reverse_proxy localhost:3000
}
```
Caddy handles TLS and WebSockets automatically.
</details>
<br />
## Environment variables
<details>
<summary><b>Full reference</b></summary>
<br />
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
@@ -381,46 +313,58 @@ Caddy handles TLS and WebSockets automatically.
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
| `DEFAULT_LANGUAGE` | Default language shown on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback when no match is found. Supported values: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Only useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY` to be set so Express can detect the forwarded protocol. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: secure is on when `NODE_ENV=production` **or** `FORCE_HTTPS=true`. Set to `false` as an escape hatch to allow session cookies over plain HTTP (e.g. LAN testing without TLS). **Not recommended to disable in production.** | auto (`true` in production) |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Activates automatically in production (defaults to `1`); off in development unless explicitly set. Must be set for `FORCE_HTTPS` redirects to work correctly. | `1` (when active) |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — |
| **OIDC / SSO** | | |
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
| `OIDC_CLIENT_ID` | OIDC client ID | — |
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
| `OIDC_ONLY` | Force SSO-only mode: disables password login + registration, regardless of Admin > Settings. The first SSO login becomes admin. | `false` |
| `OIDC_ONLY` | Force SSO-only mode: disables password login and password registration, regardless of the granular toggles in Admin > Settings. The first SSO login becomes admin. Use when you want this enforced at the infrastructure level and not overridable via the UI. | `false` |
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
| `OIDC_SCOPE` | Space-separated OIDC scopes. **Fully replaces** the default — always include `openid email profile`. | `openid email profile` |
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — |
| **Initial setup** | | |
| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` |
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
| `OIDC_SCOPE` | Space-separated OIDC scopes to request. **Fully replaces** the default — always include `openid email profile` plus any extra scopes you need (e.g. add `groups` when using `OIDC_ADMIN_CLAIM`) | `openid email profile` |
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint. Useful for providers that expose it at a non-standard path (e.g. Authentik: `https://auth.example.com/application/o/trek/.well-known/openid-configuration`) | — |
| **Initial Setup** | | |
| `ADMIN_EMAIL` | Email for the first admin account created on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is generated and printed to the server log. Has no effect once any user exists. | `admin@trek.local` |
| `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random |
| **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
</details>
## Optional API Keys
<br />
API keys are configured in the **Admin Panel** after login. Keys set by the admin are automatically shared with all users — no per-user configuration needed.
### Google Maps (Place Search & Photos)
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a project and enable the **Places API (New)**
3. Create an API key under Credentials
4. In TREK: Admin Panel → Settings → Google Maps
## Building from Source
```bash
git clone https://github.com/mauriceboe/TREK.git
cd TREK
docker build -t trek .
```
## Data & Backups
- **Database** SQLite, stored in `./data/travel.db`
- **Uploads** — stored in `./uploads/`
- **Logs** `./data/logs/trek.log` (auto-rotated)
- **Backups** — create and restore via Admin Panel
- **Auto-Backups** — configurable schedule and retention in Admin Panel
<br />
- **Database**: SQLite, stored in `./data/travel.db`
- **Uploads**: Stored in `./uploads/`
- **Logs**: `./data/logs/trek.log` (auto-rotated)
- **Backups**: Create and restore via Admin Panel
- **Auto-Backups**: Configurable schedule and retention in Admin Panel
## License
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
[AGPL-3.0](LICENSE)
+1 -223
View File
@@ -13,7 +13,6 @@
"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",
@@ -2868,41 +2867,6 @@
"@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",
@@ -3878,17 +3842,9 @@
"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",
@@ -3923,12 +3879,6 @@
"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",
@@ -3979,15 +3929,6 @@
"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",
@@ -4808,12 +4749,6 @@
"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",
@@ -5131,12 +5066,6 @@
"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",
@@ -5406,12 +5335,6 @@
"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",
@@ -6059,12 +5982,6 @@
"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",
@@ -6137,12 +6054,6 @@
"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",
@@ -6257,12 +6168,6 @@
"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",
@@ -7288,12 +7193,6 @@
"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",
@@ -7489,44 +7388,6 @@
"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",
@@ -7549,17 +7410,6 @@
"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",
@@ -8561,12 +8411,6 @@
}
}
},
"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",
@@ -8844,18 +8688,6 @@
"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",
@@ -9068,12 +8900,6 @@
"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",
@@ -9130,12 +8956,6 @@
"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",
@@ -9185,12 +9005,6 @@
],
"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",
@@ -9643,15 +9457,6 @@
"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",
@@ -9676,12 +9481,6 @@
"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",
@@ -10197,12 +9996,6 @@
"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",
@@ -10554,15 +10347,6 @@
"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",
@@ -10837,12 +10621,6 @@
"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",
-1
View File
@@ -20,7 +20,6 @@
"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",
+1 -8
View File
@@ -4,8 +4,6 @@ import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { useAddonStore } from './store/addonStore'
import LoginPage from './pages/LoginPage'
import ForgotPasswordPage from './pages/ForgotPasswordPage'
import ResetPasswordPage from './pages/ResetPasswordPage'
import DashboardPage from './pages/DashboardPage'
import TripPlannerPage from './pages/TripPlannerPage'
import FilesPage from './pages/FilesPage'
@@ -199,10 +197,7 @@ export default function App() {
applyDark(mode === true || mode === 'dark')
}, [settings.dark_mode, isSharedPage])
const isAuthPage = location.pathname.startsWith('/login')
|| location.pathname.startsWith('/register')
|| location.pathname.startsWith('/forgot-password')
|| location.pathname.startsWith('/reset-password')
const isAuthPage = location.pathname.startsWith('/login') || location.pathname.startsWith('/register')
return (
<TranslationProvider>
@@ -215,8 +210,6 @@ export default function App() {
<Route path="/shared/:token" element={<SharedTripPage />} />
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
<Route path="/register" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
<Route
-3
View File
@@ -114,8 +114,6 @@ export const authApi = {
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
forgotPassword: (data: { email: string }) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
resetPassword: (data: { token: string; new_password: string; mfa_code?: string }) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }),
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
mcpTokens: {
@@ -345,7 +343,6 @@ 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),
+2 -2
View File
@@ -32,8 +32,8 @@ describe('SCOPE_GROUPS', () => {
})
describe('ALL_SCOPES', () => {
it('FE-OAUTH-SCOPES-003: contains exactly 27 scopes', () => {
expect(ALL_SCOPES).toHaveLength(27)
it('FE-OAUTH-SCOPES-003: contains exactly 24 scopes', () => {
expect(ALL_SCOPES).toHaveLength(24)
})
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
-3
View File
@@ -38,9 +38,6 @@ 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)
+1 -7
View File
@@ -183,12 +183,6 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
maxZoom: 18,
attribution: '&copy; <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)
@@ -250,7 +244,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: 16 })
map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 14 })
} else {
map.setView([30, 0], 2)
}
@@ -1,55 +0,0 @@
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
@@ -1,464 +0,0 @@
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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
@@ -1,210 +0,0 @@
import React, { useState, useEffect, useRef } from 'react'
import { Menu, X, type LucideIcon } from 'lucide-react'
export interface PageSidebarTab {
id: string
label: string
icon: LucideIcon
}
interface PageSidebarProps {
/** Uppercase label shown above the tab list, e.g. "SETTINGS". */
sidebarLabel: string
tabs: PageSidebarTab[]
activeTab: string
onTabChange: (id: string) => void
children: React.ReactNode
/** Small text at the very bottom of the sidebar (e.g. "v3.0 · self-hosted"). */
footer?: React.ReactNode
}
/**
* Left-sidebar + right-panel layout used by the Settings and Admin pages.
*
* Desktop (>=1024px): sidebar is always visible at 260px; panel fills rest.
* Mobile: sidebar collapses behind a hamburger at the top of the panel; tap
* the hamburger to slide the sidebar in as an overlay, tap a tab to close.
*/
export default function PageSidebar({
sidebarLabel,
tabs,
activeTab,
onTabChange,
children,
footer,
}: PageSidebarProps): React.ReactElement {
const [mobileOpen, setMobileOpen] = useState(false)
const activeLabel = tabs.find(t => t.id === activeTab)?.label ?? ''
// Close the mobile drawer on Escape or on outside click.
const drawerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!mobileOpen) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMobileOpen(false) }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [mobileOpen])
return (
<div
className="rounded-2xl overflow-hidden flex flex-col lg:flex-row relative"
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-primary)',
minHeight: 'min(820px, calc(100vh - var(--nav-h) - 120px))',
}}
>
{/* Mobile top bar with hamburger */}
<div
className="lg:hidden flex items-center justify-between px-4 py-3 border-b"
style={{ borderColor: 'var(--border-primary)' }}
>
<button
onClick={() => setMobileOpen(true)}
className="w-9 h-9 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)]"
aria-label="Open navigation"
style={{ color: 'var(--text-primary)' }}
>
<Menu size={18} />
</button>
<div className="flex items-center gap-2 text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{activeLabel}
</div>
<div className="w-9" />
</div>
{/* Desktop sidebar (always visible on lg) */}
<aside
className="hidden lg:flex flex-col shrink-0 relative"
style={{
width: 260,
background: 'var(--bg-secondary)',
borderRight: '1px solid var(--border-primary)',
padding: '24px 14px',
}}
>
<SidebarInner
sidebarLabel={sidebarLabel}
tabs={tabs}
activeTab={activeTab}
onTabChange={onTabChange}
footer={footer}
/>
</aside>
{/* Mobile drawer */}
{mobileOpen && (
<>
<div
className="lg:hidden fixed inset-0 z-40"
style={{ background: 'rgba(0,0,0,0.35)' }}
onClick={() => setMobileOpen(false)}
/>
<aside
ref={drawerRef}
className="lg:hidden fixed top-0 left-0 bottom-0 z-50 flex flex-col shadow-2xl"
style={{
width: 280,
background: 'var(--bg-secondary)',
padding: '18px 14px',
}}
>
<div className="flex items-center justify-between mb-3 px-2">
<span
className="text-[11px] font-bold tracking-widest uppercase"
style={{ color: 'var(--text-muted)' }}
>
{sidebarLabel}
</span>
<button
onClick={() => setMobileOpen(false)}
className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)]"
aria-label="Close navigation"
style={{ color: 'var(--text-primary)' }}
>
<X size={16} />
</button>
</div>
<SidebarInner
sidebarLabel={null}
tabs={tabs}
activeTab={activeTab}
onTabChange={(id) => {
onTabChange(id)
setMobileOpen(false)
}}
footer={footer}
/>
</aside>
</>
)}
{/* Panel */}
<div className="flex-1 min-w-0" style={{ padding: '26px 28px' }}>
{children}
</div>
</div>
)
}
function SidebarInner({
sidebarLabel,
tabs,
activeTab,
onTabChange,
footer,
}: {
sidebarLabel: string | null
tabs: PageSidebarTab[]
activeTab: string
onTabChange: (id: string) => void
footer?: React.ReactNode
}): React.ReactElement {
return (
<>
{sidebarLabel && (
<div
className="text-[11px] font-bold tracking-widest uppercase mb-3 px-3"
style={{ color: 'var(--text-muted)' }}
>
{sidebarLabel}
</div>
)}
<nav className="flex flex-col gap-1 flex-1">
{tabs.map((tab) => {
const Icon = tab.icon
const active = tab.id === activeTab
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors"
style={{
background: active ? 'var(--bg-hover)' : 'transparent',
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: active ? 600 : 500,
}}
onMouseEnter={(e) => {
if (!active) e.currentTarget.style.background = 'var(--bg-hover)'
}}
onMouseLeave={(e) => {
if (!active) e.currentTarget.style.background = 'transparent'
}}
>
<Icon size={16} className="shrink-0" />
<span className="truncate">{tab.label}</span>
</button>
)
})}
</nav>
{footer && (
<div
className="mt-4 pt-3 px-3 text-[10px] tracking-wide"
style={{ color: 'var(--text-faint)', borderTop: '1px solid var(--border-primary)' }}
>
{footer}
</div>
)}
</>
)
}
@@ -1,56 +0,0 @@
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>
)
}
+78 -75
View File
@@ -278,76 +278,93 @@ 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 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 }) {
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
function LocationTracker() {
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)
// 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.
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)
useEffect(() => {
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])
if (position && !centered.current) {
map.setView(position, 15)
centered.current = true
}
}, [position, map])
// 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>`,
})
// Cleanup on unmount
useEffect(() => () => { if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) }, [])
return (
<>
{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}
/>
{/* 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 }} />
</>
)}
{headingIcon && (
<Marker
position={[position.lat, position.lng]}
icon={headingIcon}
interactive={false}
zIndexOffset={900}
/>
{/* Pulse animation CSS */}
{position && (
<style>{`
@keyframes location-pulse {
0% { transform: scale(1); opacity: 0.6; }
100% { transform: scale(2.5); opacity: 0; }
}
`}</style>
)}
<CircleMarker
center={[position.lat, position.lng]}
radius={8}
pathOptions={{ color: 'white', fillColor: '#3b82f6', fillOpacity: 1, weight: 3 }}
interactive={false}
/>
</>
)
}
@@ -544,15 +561,8 @@ 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}
@@ -576,7 +586,7 @@ export const MapView = memo(function MapView({
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
<LocationTracker />
<MarkerClusterGroup
chunkedLoading
@@ -621,13 +631,6 @@ 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={{
-16
View File
@@ -1,16 +0,0 @@
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} />
}
-558
View File
@@ -1,558 +0,0 @@
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>
)
}
@@ -1,172 +0,0 @@
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
}
-101
View File
@@ -1,101 +0,0 @@
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,17 +270,6 @@ 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'
@@ -1142,7 +1131,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-selected)' : 'transparent'),
background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-tertiary)' : 'transparent'),
transition: 'background 0.12s',
userSelect: 'none',
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
@@ -1450,21 +1439,6 @@ 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, [
@@ -1495,7 +1469,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
cursor: 'pointer',
background: lockedIds.has(assignment.id)
? 'rgba(220,38,38,0.08)'
: isPlaceSelected ? 'var(--bg-selected)' : 'transparent',
: isPlaceSelected ? 'var(--bg-hover)' : '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 ?? '', end_day_id: selectedDayId ?? '' })
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '' })
setFromPick({})
setToPick({})
}
@@ -123,12 +123,12 @@ describe('MapSettingsTab', () => {
});
render(<MapSettingsTab />);
await user.click(screen.getByText('Save Map'));
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({
expect(updateSettings).toHaveBeenCalledWith({
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 () => {
+40 -308
View File
@@ -1,13 +1,11 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Map, Save, Layers, Box, ChevronDown, Check } from 'lucide-react'
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { Map, Save } 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 {
@@ -23,137 +21,18 @@ 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 { t } = useTranslation()
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 : t('settings.mapStylePlaceholder')}
</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)
@@ -188,12 +67,7 @@ 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)),
@@ -206,155 +80,28 @@ 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-2">{t('settings.mapProvider')}</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="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapLeafletSubtitle')}</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">
{t('settings.mapExperimental')}
</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="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div>
</div>
</button>
</div>
<p className="text-xs text-slate-400 mt-2">
{t('settings.mapProviderHint')}
</p>
<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>
{/* 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">{t('settings.mapMapboxToken')}</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">
{t('settings.mapMapboxTokenHint')}{' '}
<a href="https://account.mapbox.com/access-tokens/" target="_blank" rel="noreferrer" className="underline">
{t('settings.mapMapboxTokenLink')}
</a>
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapStyle')}</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">
{t('settings.mapStyleHint')}
</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">{t('settings.map3dBuildings')}</div>
<div className="text-xs text-slate-500 mt-0.5">
{t('settings.map3dHint')}
</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 flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
<span className="order-2 sm:order-1">{t('settings.mapHighQuality')}</span>
<span className="order-1 sm:order-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
{t('settings.mapExperimental')}
</span>
</div>
<div className="text-xs text-slate-500 mt-0.5">
{t('settings.mapHighQualityHint')}{' '}
<span className="text-amber-600 dark:text-amber-400">{t('settings.mapHighQualityWarning')}</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">{t('settings.mapTipLabel')}</strong> {t('settings.mapTip')}
</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>
@@ -362,7 +109,7 @@ export default function MapSettingsTab(): React.ReactElement {
type="number"
step="any"
value={defaultLat}
onChange={(e) => setDefaultLat(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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>
@@ -372,7 +119,7 @@ export default function MapSettingsTab(): React.ReactElement {
type="number"
step="any"
value={defaultLng}
onChange={(e) => setDefaultLng(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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>
@@ -380,40 +127,25 @@ export default function MapSettingsTab(): React.ReactElement {
<div>
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
{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,
})
)}
{/* 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>
@@ -1,77 +0,0 @@
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' }} />
}
+3 -4
View File
@@ -1,16 +1,15 @@
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(() => isTestEnv || target <= 0 ? target : 0)
const [value, setValue] = useState(0)
const startRef = useRef<number | null>(null)
const frameRef = useRef<number | null>(null)
useEffect(() => {
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false
if (reduced || isTestEnv || target <= 0) { setValue(target); return }
const isJsdom = typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent ?? '')
if (reduced || isJsdom || target <= 0) { setValue(target); return }
startRef.current = null
const step = (now: number) => {
-171
View File
@@ -1,171 +0,0 @@
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 }
}
-47
View File
@@ -161,24 +161,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.mapDefaultHint': 'اتركه فارغًا لاستخدام OpenStreetMap افتراضيًا',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'قالب URL لبلاطات الخريطة',
'settings.mapProvider': 'مزود الخريطة',
'settings.mapProviderHint': 'يؤثر على خرائط Trip Planner و Journey. يستخدم Atlas دائمًا Leaflet.',
'settings.mapLeafletSubtitle': '2D كلاسيكي، أي بلاطات نقطية',
'settings.mapMapboxSubtitle': 'بلاطات متجهية ومبانٍ ثلاثية الأبعاد وتضاريس',
'settings.mapExperimental': 'تجريبي',
'settings.mapMapboxToken': 'رمز وصول Mapbox',
'settings.mapMapboxTokenHint': 'الرمز العام (pk.*) من',
'settings.mapMapboxTokenLink': 'mapbox.com ← رموز الوصول',
'settings.mapStyle': 'نمط الخريطة',
'settings.mapStylePlaceholder': 'اختر نمط Mapbox',
'settings.mapStyleHint': 'إعداد مسبق أو عنوان URL mapbox://styles/USER/ID خاص بك',
'settings.map3dBuildings': 'مبانٍ ثلاثية الأبعاد وتضاريس',
'settings.map3dHint': 'إمالة + مبانٍ ثلاثية الأبعاد حقيقية — يعمل مع كل نمط بما في ذلك الأقمار الصناعية.',
'settings.mapHighQuality': 'وضع الجودة العالية',
'settings.mapHighQualityHint': 'تحسين الحواف + إسقاط كروي لحواف أكثر حدة وعرض واقعي للعالم.',
'settings.mapHighQualityWarning': 'قد يؤثر على الأداء في الأجهزة الأقل قدرة.',
'settings.mapTipLabel': 'نصيحة:',
'settings.mapTip': 'انقر بزر الماوس الأيمن واسحب لتدوير/إمالة الخريطة. النقر الأوسط لإضافة مكان (النقر الأيمن مخصص للتدوير).',
'settings.latitude': 'خط العرض',
'settings.longitude': 'خط الطول',
'settings.saveMap': 'حفظ الخريطة',
@@ -463,28 +445,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC',
'login.usernameRequired': 'اسم المستخدم مطلوب',
'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل',
'login.forgotPassword': 'نسيت كلمة المرور؟',
'login.forgotPasswordTitle': 'إعادة تعيين كلمة المرور',
'login.forgotPasswordBody': 'أدخل عنوان البريد الإلكتروني المسجَّل. إذا كان الحساب موجودًا، سنرسل رابط إعادة التعيين.',
'login.forgotPasswordSubmit': 'إرسال الرابط',
'login.forgotPasswordSentTitle': 'تحقق من بريدك',
'login.forgotPasswordSentBody': 'إذا كان هناك حساب مرتبط بهذا البريد، فإن الرابط في الطريق. تنتهي صلاحيته خلال 60 دقيقة.',
'login.forgotPasswordSmtpHintOff': 'ملاحظة: لم يقم المسؤول بتكوين SMTP، لذا سيتم كتابة رابط إعادة التعيين في وحدة تحكم الخادم بدلاً من إرساله عبر البريد الإلكتروني.',
'login.backToLogin': 'العودة إلى تسجيل الدخول',
'login.newPassword': 'كلمة المرور الجديدة',
'login.confirmPassword': 'تأكيد كلمة المرور الجديدة',
'login.passwordsDontMatch': 'كلمتا المرور غير متطابقتين',
'login.mfaCode': 'رمز 2FA',
'login.resetPasswordTitle': 'ضبط كلمة مرور جديدة',
'login.resetPasswordBody': 'اختر كلمة مرور قوية لم تستخدمها هنا من قبل. 8 أحرف على الأقل.',
'login.resetPasswordMfaBody': 'أدخل رمز 2FA أو رمز النسخ الاحتياطي لإتمام إعادة التعيين.',
'login.resetPasswordSubmit': 'إعادة تعيين كلمة المرور',
'login.resetPasswordVerify': 'تحقق وأعد التعيين',
'login.resetPasswordSuccessTitle': 'تم تحديث كلمة المرور',
'login.resetPasswordSuccessBody': 'يمكنك الآن تسجيل الدخول بكلمة المرور الجديدة.',
'login.resetPasswordInvalidLink': 'رابط إعادة تعيين غير صالح',
'login.resetPasswordInvalidLinkBody': 'هذا الرابط مفقود أو تالف. اطلب رابطًا جديدًا للمتابعة.',
'login.resetPasswordFailed': 'فشلت إعادة التعيين. ربما انتهت صلاحية الرابط.',
// Register
'register.passwordMismatch': 'كلمتا المرور غير متطابقتين',
@@ -2032,7 +1992,6 @@ 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': 'عرض الرحلات وخطط السفر',
@@ -2083,12 +2042,6 @@ 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',
-47
View File
@@ -156,24 +156,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.mapDefaultHint': 'Deixe vazio para OpenStreetMap (padrão)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'URL do modelo de blocos do mapa',
'settings.mapProvider': 'Provedor de mapa',
'settings.mapProviderHint': 'Afeta os mapas do Planejador de Viagem e Diário. Atlas sempre usa Leaflet.',
'settings.mapLeafletSubtitle': 'Clássico 2D, quaisquer blocos raster',
'settings.mapMapboxSubtitle': 'Blocos vetoriais, prédios 3D & terreno',
'settings.mapExperimental': 'Experimental',
'settings.mapMapboxToken': 'Token de acesso Mapbox',
'settings.mapMapboxTokenHint': 'Token público (pk.*) de',
'settings.mapMapboxTokenLink': 'mapbox.com → Tokens de acesso',
'settings.mapStyle': 'Estilo do mapa',
'settings.mapStylePlaceholder': 'Selecionar um estilo Mapbox',
'settings.mapStyleHint': 'Preset ou sua própria URL mapbox://styles/USER/ID',
'settings.map3dBuildings': 'Prédios 3D & terreno',
'settings.map3dHint': 'Inclinação + extrusões 3D reais de prédios — funciona em todo estilo, incluindo satélite.',
'settings.mapHighQuality': 'Modo alta qualidade',
'settings.mapHighQualityHint': 'Antialiasing + projeção global para bordas mais nítidas e uma visão realista do mundo.',
'settings.mapHighQualityWarning': 'Pode afetar o desempenho em dispositivos menos potentes.',
'settings.mapTipLabel': 'Dica:',
'settings.mapTip': 'Clique direito e arraste para girar/inclinar o mapa. Clique do meio para adicionar um local (o clique direito é reservado para rotação).',
'settings.latitude': 'Latitude',
'settings.longitude': 'Longitude',
'settings.saveMap': 'Salvar mapa',
@@ -458,28 +440,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'Falha no login OIDC',
'login.usernameRequired': 'Nome de usuário é obrigatório',
'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres',
'login.forgotPassword': 'Esqueceu a senha?',
'login.forgotPasswordTitle': 'Redefinir sua senha',
'login.forgotPasswordBody': 'Digite o e-mail cadastrado. Se houver uma conta, enviaremos um link de redefinição.',
'login.forgotPasswordSubmit': 'Enviar link',
'login.forgotPasswordSentTitle': 'Verifique seu e-mail',
'login.forgotPasswordSentBody': 'Se houver uma conta para esse e-mail, o link está a caminho. Ele expira em 60 minutos.',
'login.forgotPasswordSmtpHintOff': 'Observação: seu administrador não configurou SMTP, então o link de redefinição será gravado no console do servidor em vez de ser enviado por e-mail.',
'login.backToLogin': 'Voltar ao login',
'login.newPassword': 'Nova senha',
'login.confirmPassword': 'Confirmar nova senha',
'login.passwordsDontMatch': 'As senhas não coincidem',
'login.mfaCode': 'Código 2FA',
'login.resetPasswordTitle': 'Definir uma nova senha',
'login.resetPasswordBody': 'Escolha uma senha forte que você ainda não tenha usado aqui. Mínimo de 8 caracteres.',
'login.resetPasswordMfaBody': 'Digite seu código 2FA ou um código de backup para concluir a redefinição.',
'login.resetPasswordSubmit': 'Redefinir senha',
'login.resetPasswordVerify': 'Verificar e redefinir',
'login.resetPasswordSuccessTitle': 'Senha atualizada',
'login.resetPasswordSuccessBody': 'Agora você pode entrar com sua nova senha.',
'login.resetPasswordInvalidLink': 'Link de redefinição inválido',
'login.resetPasswordInvalidLinkBody': 'Este link está ausente ou corrompido. Solicite um novo para continuar.',
'login.resetPasswordFailed': 'Falha na redefinição. O link pode ter expirado.',
// Register
'register.passwordMismatch': 'As senhas não coincidem',
@@ -2235,7 +2195,6 @@ 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',
@@ -2286,12 +2245,6 @@ 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',
-47
View File
@@ -157,24 +157,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.mapDefaultHint': 'Ponechte prázdné pro OpenStreetMap (výchozí)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'URL šablony pro mapové dlaždice',
'settings.mapProvider': 'Poskytovatel mapy',
'settings.mapProviderHint': 'Ovlivňuje mapy v Trip Planneru a Journey. Atlas vždy používá Leaflet.',
'settings.mapLeafletSubtitle': 'Klasické 2D, libovolné rastrové dlaždice',
'settings.mapMapboxSubtitle': 'Vektorové dlaždice, 3D budovy a terén',
'settings.mapExperimental': 'Experimentální',
'settings.mapMapboxToken': 'Mapbox přístupový token',
'settings.mapMapboxTokenHint': 'Veřejný token (pk.*) z',
'settings.mapMapboxTokenLink': 'mapbox.com → Přístupové tokeny',
'settings.mapStyle': 'Styl mapy',
'settings.mapStylePlaceholder': 'Vyberte styl Mapbox',
'settings.mapStyleHint': 'Preset nebo vaše vlastní URL mapbox://styles/USER/ID',
'settings.map3dBuildings': '3D budovy a terén',
'settings.map3dHint': 'Náklon + skutečné 3D vyvýšení budov — funguje s každým stylem, včetně satelitu.',
'settings.mapHighQuality': 'Režim vysoké kvality',
'settings.mapHighQualityHint': 'Antialiasing + zobrazení glóbu pro ostřejší hrany a realistický pohled na svět.',
'settings.mapHighQualityWarning': 'Může ovlivnit výkon na slabších zařízeních.',
'settings.mapTipLabel': 'Tip:',
'settings.mapTip': 'Pravé tlačítko myši a táhněte pro rotaci/náklon mapy. Prostřední tlačítko pro přidání místa (pravé tlačítko je vyhrazeno pro rotaci).',
'settings.latitude': 'Zeměpisná šířka',
'settings.longitude': 'Zeměpisná délka',
'settings.saveMap': 'Uložit nastavení mapy',
@@ -458,28 +440,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'Přihlášení přes OIDC se nezdařilo',
'login.usernameRequired': 'Uživatelské jméno je povinné',
'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků',
'login.forgotPassword': 'Zapomenuté heslo?',
'login.forgotPasswordTitle': 'Obnovení hesla',
'login.forgotPasswordBody': 'Zadej e-mail použitý při registraci. Pokud účet existuje, pošleme odkaz pro obnovení.',
'login.forgotPasswordSubmit': 'Odeslat odkaz',
'login.forgotPasswordSentTitle': 'Zkontroluj e-mail',
'login.forgotPasswordSentBody': 'Pokud k tomuto e-mailu existuje účet, odkaz je na cestě. Platnost vyprší za 60 minut.',
'login.forgotPasswordSmtpHintOff': 'Upozornění: správce nemá nakonfigurovaný SMTP, takže se odkaz pro obnovení zapíše do konzole serveru místo odeslání e-mailem.',
'login.backToLogin': 'Zpět na přihlášení',
'login.newPassword': 'Nové heslo',
'login.confirmPassword': 'Potvrď nové heslo',
'login.passwordsDontMatch': 'Hesla se neshodují',
'login.mfaCode': 'Kód 2FA',
'login.resetPasswordTitle': 'Nastavit nové heslo',
'login.resetPasswordBody': 'Vyber silné heslo, které jsi tu ještě nepoužil. Minimálně 8 znaků.',
'login.resetPasswordMfaBody': 'Zadej 2FA kód nebo záložní kód pro dokončení obnovení.',
'login.resetPasswordSubmit': 'Obnovit heslo',
'login.resetPasswordVerify': 'Ověřit a obnovit',
'login.resetPasswordSuccessTitle': 'Heslo aktualizováno',
'login.resetPasswordSuccessBody': 'Nyní se můžeš přihlásit novým heslem.',
'login.resetPasswordInvalidLink': 'Neplatný odkaz',
'login.resetPasswordInvalidLinkBody': 'Odkaz chybí nebo je poškozený. Pro pokračování si vyžádej nový.',
'login.resetPasswordFailed': 'Obnovení se nezdařilo. Odkaz mohl vypršet.',
// Registrace (Register)
'register.passwordMismatch': 'Hesla se neshodují',
@@ -2239,7 +2199,6 @@ 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',
@@ -2290,12 +2249,6 @@ 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',
-47
View File
@@ -159,24 +159,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.mapDefaultHint': 'Leer lassen für OpenStreetMap (Standard)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'URL-Template für die Kartenkacheln',
'settings.mapProvider': 'Kartenanbieter',
'settings.mapProviderHint': 'Gilt für Trip Planner und Journey. Atlas nutzt immer Leaflet.',
'settings.mapLeafletSubtitle': 'Klassisch 2D, beliebige Raster-Kacheln',
'settings.mapMapboxSubtitle': 'Vektor-Kacheln, 3D-Gebäude & Terrain',
'settings.mapExperimental': 'Experimentell',
'settings.mapMapboxToken': 'Mapbox Access Token',
'settings.mapMapboxTokenHint': 'Öffentliches Token (pk.*) von',
'settings.mapMapboxTokenLink': 'mapbox.com → Access Tokens',
'settings.mapStyle': 'Kartenstil',
'settings.mapStylePlaceholder': 'Mapbox-Stil wählen',
'settings.mapStyleHint': 'Preset oder eigene mapbox://styles/USER/ID URL',
'settings.map3dBuildings': '3D-Gebäude & Terrain',
'settings.map3dHint': 'Neigung + echte 3D-Gebäude-Extrusionen — funktioniert mit jedem Stil, auch Satellit.',
'settings.mapHighQuality': 'Hochqualitäts-Modus',
'settings.mapHighQualityHint': 'Antialiasing + Globus-Projektion für schärfere Kanten und eine realistische Weltsicht.',
'settings.mapHighQualityWarning': 'Kann die Performance auf schwächeren Geräten beeinträchtigen.',
'settings.mapTipLabel': 'Tipp:',
'settings.mapTip': 'Rechtsklick und ziehen, um die Karte zu drehen/neigen. Mittelklick, um einen Ort hinzuzufügen (Rechtsklick ist für die Rotation reserviert).',
'settings.latitude': 'Breitengrad',
'settings.longitude': 'Längengrad',
'settings.saveMap': 'Karte speichern',
@@ -463,28 +445,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'OIDC-Anmeldung fehlgeschlagen',
'login.usernameRequired': 'Benutzername ist erforderlich',
'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein',
'login.forgotPassword': 'Passwort vergessen?',
'login.forgotPasswordTitle': 'Passwort zurücksetzen',
'login.forgotPasswordBody': 'Gib die E-Mail-Adresse deines Kontos ein. Falls ein Konto existiert, schicken wir dir einen Reset-Link.',
'login.forgotPasswordSubmit': 'Reset-Link senden',
'login.forgotPasswordSentTitle': 'Prüfe deine E-Mails',
'login.forgotPasswordSentBody': 'Falls ein Konto mit dieser Adresse existiert, ist ein Reset-Link unterwegs. Er läuft in 60 Minuten ab.',
'login.forgotPasswordSmtpHintOff': 'Hinweis: Der Administrator hat SMTP nicht konfiguriert. Der Reset-Link wird statt per E-Mail in die Server-Konsole geschrieben.',
'login.backToLogin': 'Zurück zur Anmeldung',
'login.newPassword': 'Neues Passwort',
'login.confirmPassword': 'Neues Passwort bestätigen',
'login.passwordsDontMatch': 'Passwörter stimmen nicht überein',
'login.mfaCode': '2FA-Code',
'login.resetPasswordTitle': 'Neues Passwort festlegen',
'login.resetPasswordBody': 'Wähle ein starkes Passwort, das du hier noch nicht verwendet hast. Mindestens 8 Zeichen.',
'login.resetPasswordMfaBody': 'Gib deinen 2FA-Code oder einen Backup-Code ein, um den Reset abzuschließen.',
'login.resetPasswordSubmit': 'Passwort zurücksetzen',
'login.resetPasswordVerify': 'Prüfen & zurücksetzen',
'login.resetPasswordSuccessTitle': 'Passwort aktualisiert',
'login.resetPasswordSuccessBody': 'Du kannst dich jetzt mit deinem neuen Passwort anmelden.',
'login.resetPasswordInvalidLink': 'Ungültiger Reset-Link',
'login.resetPasswordInvalidLinkBody': 'Dieser Link fehlt oder ist beschädigt. Fordere einen neuen an, um fortzufahren.',
'login.resetPasswordFailed': 'Zurücksetzen fehlgeschlagen. Der Link ist möglicherweise abgelaufen.',
// Register
'register.passwordMismatch': 'Passwörter stimmen nicht überein',
@@ -2245,7 +2205,6 @@ 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',
@@ -2296,12 +2255,6 @@ 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',
-47
View File
@@ -159,24 +159,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.mapDefaultHint': 'Leave empty for OpenStreetMap (default)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'URL template for map tiles',
'settings.mapProvider': 'Map Provider',
'settings.mapProviderHint': 'Affects Trip Planner and Journey maps. Atlas always uses Leaflet.',
'settings.mapLeafletSubtitle': 'Classic 2D, any raster tiles',
'settings.mapMapboxSubtitle': 'Vector tiles, 3D buildings & terrain',
'settings.mapExperimental': 'Experimental',
'settings.mapMapboxToken': 'Mapbox Access Token',
'settings.mapMapboxTokenHint': 'Public token (pk.*) from',
'settings.mapMapboxTokenLink': 'mapbox.com → Access tokens',
'settings.mapStyle': 'Map Style',
'settings.mapStylePlaceholder': 'Select a Mapbox style',
'settings.mapStyleHint': 'Preset or your own mapbox://styles/USER/ID URL',
'settings.map3dBuildings': '3D Buildings & Terrain',
'settings.map3dHint': 'Pitch + real 3D building extrusions — works on every style, including satellite.',
'settings.mapHighQuality': 'High Quality Mode',
'settings.mapHighQualityHint': 'Antialiasing + globe projection for sharper edges and a realistic world view.',
'settings.mapHighQualityWarning': 'May impact performance on lower-end devices.',
'settings.mapTipLabel': 'Tip:',
'settings.mapTip': 'right-click and drag to rotate/pitch the map. Middle-click to add a place (right-click is reserved for rotation).',
'settings.latitude': 'Latitude',
'settings.longitude': 'Longitude',
'settings.saveMap': 'Save Map',
@@ -522,28 +504,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'OIDC login failed',
'login.usernameRequired': 'Username is required',
'login.passwordMinLength': 'Password must be at least 8 characters',
'login.forgotPassword': 'Forgot password?',
'login.forgotPasswordTitle': 'Reset your password',
'login.forgotPasswordBody': 'Enter the email address you signed up with. If an account exists, we\'ll send a reset link.',
'login.forgotPasswordSubmit': 'Send reset link',
'login.forgotPasswordSentTitle': 'Check your email',
'login.forgotPasswordSentBody': 'If an account exists for that email, a reset link is on its way. It expires in 60 minutes.',
'login.forgotPasswordSmtpHintOff': 'Heads up: your administrator hasn\'t configured SMTP, so the reset link will be written to the server console instead of being emailed.',
'login.backToLogin': 'Back to sign in',
'login.newPassword': 'New password',
'login.confirmPassword': 'Confirm new password',
'login.passwordsDontMatch': 'Passwords don\'t match',
'login.mfaCode': '2FA code',
'login.resetPasswordTitle': 'Set a new password',
'login.resetPasswordBody': 'Pick a strong password you havent used here before. Minimum 8 characters.',
'login.resetPasswordMfaBody': 'Enter your 2FA code or a backup code to complete the reset.',
'login.resetPasswordSubmit': 'Reset password',
'login.resetPasswordVerify': 'Verify & reset',
'login.resetPasswordSuccessTitle': 'Password updated',
'login.resetPasswordSuccessBody': 'You can now sign in with your new password.',
'login.resetPasswordInvalidLink': 'Invalid reset link',
'login.resetPasswordInvalidLinkBody': 'This link is missing or broken. Request a new one to continue.',
'login.resetPasswordFailed': 'Reset failed. The link may have expired.',
// Register
'register.passwordMismatch': 'Passwords do not match',
@@ -2282,7 +2242,6 @@ 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',
@@ -2333,12 +2292,6 @@ 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',
-47
View File
@@ -157,24 +157,6 @@ const es: Record<string, string> = {
'settings.mapDefaultHint': 'Déjalo vacío para OpenStreetMap (por defecto)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'Plantilla de URL para los mosaicos del mapa',
'settings.mapProvider': 'Proveedor de mapa',
'settings.mapProviderHint': 'Afecta a los mapas de Trip Planner y Journey. Atlas siempre usa Leaflet.',
'settings.mapLeafletSubtitle': 'Clásico 2D, cualquier mosaico raster',
'settings.mapMapboxSubtitle': 'Mosaicos vectoriales, edificios 3D y terreno',
'settings.mapExperimental': 'Experimental',
'settings.mapMapboxToken': 'Token de acceso de Mapbox',
'settings.mapMapboxTokenHint': 'Token público (pk.*) de',
'settings.mapMapboxTokenLink': 'mapbox.com → Tokens de acceso',
'settings.mapStyle': 'Estilo de mapa',
'settings.mapStylePlaceholder': 'Seleccionar un estilo de Mapbox',
'settings.mapStyleHint': 'Preset o tu propia URL mapbox://styles/USER/ID',
'settings.map3dBuildings': 'Edificios 3D y terreno',
'settings.map3dHint': 'Inclinación + extrusiones 3D reales de edificios — funciona con todos los estilos, incluyendo satélite.',
'settings.mapHighQuality': 'Modo de alta calidad',
'settings.mapHighQualityHint': 'Antialiasing + proyección global para bordes más nítidos y una vista realista del mundo.',
'settings.mapHighQualityWarning': 'Puede afectar el rendimiento en dispositivos menos potentes.',
'settings.mapTipLabel': 'Consejo:',
'settings.mapTip': 'Clic derecho y arrastrar para rotar/inclinar el mapa. Clic central para añadir un lugar (el clic derecho está reservado para la rotación).',
'settings.latitude': 'Latitud',
'settings.longitude': 'Longitud',
'settings.saveMap': 'Guardar mapa',
@@ -450,28 +432,6 @@ const es: Record<string, string> = {
'login.oidcFailed': 'Error de inicio de sesión OIDC',
'login.usernameRequired': 'El nombre de usuario es obligatorio',
'login.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres',
'login.forgotPassword': '¿Olvidaste tu contraseña?',
'login.forgotPasswordTitle': 'Restablecer tu contraseña',
'login.forgotPasswordBody': 'Introduce la dirección de correo con la que te registraste. Si existe una cuenta, enviaremos un enlace.',
'login.forgotPasswordSubmit': 'Enviar enlace',
'login.forgotPasswordSentTitle': 'Revisa tu correo',
'login.forgotPasswordSentBody': 'Si existe una cuenta con ese correo, el enlace de restablecimiento está en camino. Caduca en 60 minutos.',
'login.forgotPasswordSmtpHintOff': 'Nota: tu administrador no ha configurado SMTP, así que el enlace de restablecimiento se escribirá en la consola del servidor en lugar de enviarse por correo.',
'login.backToLogin': 'Volver al inicio de sesión',
'login.newPassword': 'Nueva contraseña',
'login.confirmPassword': 'Confirmar nueva contraseña',
'login.passwordsDontMatch': 'Las contraseñas no coinciden',
'login.mfaCode': 'Código 2FA',
'login.resetPasswordTitle': 'Establecer una nueva contraseña',
'login.resetPasswordBody': 'Elige una contraseña segura que no hayas usado aquí antes. Mínimo 8 caracteres.',
'login.resetPasswordMfaBody': 'Introduce tu código 2FA o un código de respaldo para completar el restablecimiento.',
'login.resetPasswordSubmit': 'Restablecer contraseña',
'login.resetPasswordVerify': 'Verificar y restablecer',
'login.resetPasswordSuccessTitle': 'Contraseña actualizada',
'login.resetPasswordSuccessBody': 'Ya puedes iniciar sesión con tu nueva contraseña.',
'login.resetPasswordInvalidLink': 'Enlace de restablecimiento no válido',
'login.resetPasswordInvalidLinkBody': 'Este enlace falta o está roto. Solicita uno nuevo para continuar.',
'login.resetPasswordFailed': 'Restablecimiento fallido. El enlace puede haber caducado.',
'login.oidc.tokenFailed': 'La autenticación falló.',
'login.oidc.invalidState': 'Sesión no válida. Inténtalo de nuevo.',
'login.demoFailed': 'Falló el acceso a la demo',
@@ -2241,7 +2201,6 @@ 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',
@@ -2292,12 +2251,6 @@ 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',
-47
View File
@@ -156,24 +156,6 @@ const fr: Record<string, string> = {
'settings.mapDefaultHint': 'Laissez vide pour OpenStreetMap (par défaut)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'Modèle d\'URL pour les tuiles de carte',
'settings.mapProvider': 'Fournisseur de carte',
'settings.mapProviderHint': 'Affecte les cartes Trip Planner et Journey. Atlas utilise toujours Leaflet.',
'settings.mapLeafletSubtitle': 'Classique 2D, toutes tuiles raster',
'settings.mapMapboxSubtitle': 'Tuiles vectorielles, bâtiments 3D & terrain',
'settings.mapExperimental': 'Expérimental',
'settings.mapMapboxToken': 'Jeton d\'accès Mapbox',
'settings.mapMapboxTokenHint': 'Jeton public (pk.*) depuis',
'settings.mapMapboxTokenLink': 'mapbox.com → Jetons d\'accès',
'settings.mapStyle': 'Style de carte',
'settings.mapStylePlaceholder': 'Sélectionner un style Mapbox',
'settings.mapStyleHint': 'Preset ou votre propre URL mapbox://styles/USER/ID',
'settings.map3dBuildings': 'Bâtiments 3D & terrain',
'settings.map3dHint': 'Inclinaison + extrusions 3D réelles des bâtiments — fonctionne avec tous les styles, y compris satellite.',
'settings.mapHighQuality': 'Mode haute qualité',
'settings.mapHighQualityHint': 'Anticrénelage + projection globe pour des bords plus nets et une vue réaliste du monde.',
'settings.mapHighQualityWarning': 'Peut affecter les performances sur les appareils moins puissants.',
'settings.mapTipLabel': 'Astuce :',
'settings.mapTip': 'Clic droit et glisser pour pivoter/incliner la carte. Clic milieu pour ajouter un lieu (le clic droit est réservé à la rotation).',
'settings.latitude': 'Latitude',
'settings.longitude': 'Longitude',
'settings.saveMap': 'Enregistrer la carte',
@@ -451,28 +433,6 @@ const fr: Record<string, string> = {
'login.oidcFailed': 'Échec de connexion OIDC',
'login.usernameRequired': 'Le nom d\'utilisateur est obligatoire',
'login.passwordMinLength': 'Le mot de passe doit comporter au moins 8 caractères',
'login.forgotPassword': 'Mot de passe oublié ?',
'login.forgotPasswordTitle': 'Réinitialiser votre mot de passe',
'login.forgotPasswordBody': 'Entrez l\'adresse e-mail associée à votre compte. Si un compte existe, nous enverrons un lien de réinitialisation.',
'login.forgotPasswordSubmit': 'Envoyer le lien',
'login.forgotPasswordSentTitle': 'Vérifiez vos e-mails',
'login.forgotPasswordSentBody': 'Si un compte existe pour cette adresse, un lien de réinitialisation est en route. Il expire dans 60 minutes.',
'login.forgotPasswordSmtpHintOff': 'Remarque : votre administrateur n\'a pas configuré SMTP. Le lien de réinitialisation sera écrit dans la console du serveur au lieu d\'être envoyé par e-mail.',
'login.backToLogin': 'Retour à la connexion',
'login.newPassword': 'Nouveau mot de passe',
'login.confirmPassword': 'Confirmer le nouveau mot de passe',
'login.passwordsDontMatch': 'Les mots de passe ne correspondent pas',
'login.mfaCode': 'Code 2FA',
'login.resetPasswordTitle': 'Définir un nouveau mot de passe',
'login.resetPasswordBody': 'Choisissez un mot de passe fort que vous n\'avez pas encore utilisé ici. 8 caractères minimum.',
'login.resetPasswordMfaBody': 'Entrez votre code 2FA ou un code de secours pour finaliser la réinitialisation.',
'login.resetPasswordSubmit': 'Réinitialiser',
'login.resetPasswordVerify': 'Vérifier et réinitialiser',
'login.resetPasswordSuccessTitle': 'Mot de passe mis à jour',
'login.resetPasswordSuccessBody': 'Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.',
'login.resetPasswordInvalidLink': 'Lien de réinitialisation invalide',
'login.resetPasswordInvalidLinkBody': 'Ce lien est manquant ou invalide. Demandez-en un nouveau pour continuer.',
'login.resetPasswordFailed': 'Échec de la réinitialisation. Le lien a peut-être expiré.',
'login.oidc.tokenFailed': 'L\'authentification a échoué.',
'login.oidc.invalidState': 'Session invalide. Veuillez réessayer.',
'login.demoFailed': 'Échec de la connexion démo',
@@ -2235,7 +2195,6 @@ 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',
@@ -2286,12 +2245,6 @@ 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',
-47
View File
@@ -156,24 +156,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'settings.mapDefaultHint': 'Hagyd üresen az OpenStreetMap használatához (alapértelmezett)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'URL sablon a térképcsempékhez',
'settings.mapProvider': 'Térkép szolgáltató',
'settings.mapProviderHint': 'A Trip Planner és Journey térképekre érvényes. Az Atlas mindig Leafletet használ.',
'settings.mapLeafletSubtitle': 'Klasszikus 2D, bármilyen raszter csempe',
'settings.mapMapboxSubtitle': 'Vektoros csempék, 3D épületek és terep',
'settings.mapExperimental': 'Kísérleti',
'settings.mapMapboxToken': 'Mapbox hozzáférési token',
'settings.mapMapboxTokenHint': 'Publikus token (pk.*) innen:',
'settings.mapMapboxTokenLink': 'mapbox.com → Hozzáférési tokenek',
'settings.mapStyle': 'Térkép stílus',
'settings.mapStylePlaceholder': 'Válassz Mapbox stílust',
'settings.mapStyleHint': 'Preset vagy saját mapbox://styles/USER/ID URL',
'settings.map3dBuildings': '3D épületek és terep',
'settings.map3dHint': 'Dőlés + valódi 3D épület-kiemelés — minden stílussal működik, beleértve a műholdast.',
'settings.mapHighQuality': 'Magas minőség mód',
'settings.mapHighQualityHint': 'Antialiasing + földgömb-vetítés az élesebb kontúrokért és egy valósághű világnézethez.',
'settings.mapHighQualityWarning': 'Gyengébb eszközökön befolyásolhatja a teljesítményt.',
'settings.mapTipLabel': 'Tipp:',
'settings.mapTip': 'Jobb klikk és húzás a térkép forgatásához/döntéséhez. Középső kattintás hely hozzáadásához (a jobb klikk a forgatáshoz van fenntartva).',
'settings.latitude': 'Szélességi fok',
'settings.longitude': 'Hosszúsági fok',
'settings.saveMap': 'Térkép mentése',
@@ -458,28 +440,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'OIDC bejelentkezés sikertelen',
'login.usernameRequired': 'A felhasználónév kötelező',
'login.passwordMinLength': 'A jelszónak legalább 8 karakter hosszúnak kell lennie',
'login.forgotPassword': 'Elfelejtetted a jelszavad?',
'login.forgotPasswordTitle': 'Jelszó visszaállítása',
'login.forgotPasswordBody': 'Írd be a regisztrációnál használt e-mail-címet. Ha létezik fiók, küldünk egy visszaállítási linket.',
'login.forgotPasswordSubmit': 'Link küldése',
'login.forgotPasswordSentTitle': 'Nézd meg az e-mailjeidet',
'login.forgotPasswordSentBody': 'Ha létezik fiók ehhez az e-mailhez, a visszaállítási link úton van. 60 perc után lejár.',
'login.forgotPasswordSmtpHintOff': 'Megjegyzés: a rendszergazda nem konfigurálta az SMTP-t, ezért a visszaállítási link e-mail helyett a szerverkonzolba kerül.',
'login.backToLogin': 'Vissza a bejelentkezéshez',
'login.newPassword': 'Új jelszó',
'login.confirmPassword': 'Új jelszó megerősítése',
'login.passwordsDontMatch': 'A jelszavak nem egyeznek',
'login.mfaCode': '2FA-kód',
'login.resetPasswordTitle': 'Új jelszó beállítása',
'login.resetPasswordBody': 'Válassz erős jelszót, amit itt még nem használtál. Minimum 8 karakter.',
'login.resetPasswordMfaBody': 'Add meg a 2FA-kódodat vagy egy tartalék kódot a visszaállítás befejezéséhez.',
'login.resetPasswordSubmit': 'Jelszó visszaállítása',
'login.resetPasswordVerify': 'Ellenőrzés és visszaállítás',
'login.resetPasswordSuccessTitle': 'Jelszó frissítve',
'login.resetPasswordSuccessBody': 'Mostantól bejelentkezhetsz az új jelszavaddal.',
'login.resetPasswordInvalidLink': 'Érvénytelen visszaállítási link',
'login.resetPasswordInvalidLinkBody': 'A link hiányzik vagy sérült. A folytatáshoz kérj egy újat.',
'login.resetPasswordFailed': 'A visszaállítás nem sikerült. A link lehet, hogy lejárt.',
// Regisztráció
'register.passwordMismatch': 'A jelszavak nem egyeznek',
@@ -2236,7 +2196,6 @@ 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',
@@ -2287,12 +2246,6 @@ 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',
-47
View File
@@ -159,24 +159,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'settings.mapDefaultHint': 'Kosongkan untuk OpenStreetMap (default)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'Template URL untuk tile peta',
'settings.mapProvider': 'Penyedia peta',
'settings.mapProviderHint': 'Berlaku untuk peta Trip Planner dan Journey. Atlas selalu menggunakan Leaflet.',
'settings.mapLeafletSubtitle': 'Klasik 2D, tile raster apa pun',
'settings.mapMapboxSubtitle': 'Tile vektor, bangunan 3D & medan',
'settings.mapExperimental': 'Eksperimental',
'settings.mapMapboxToken': 'Token akses Mapbox',
'settings.mapMapboxTokenHint': 'Token publik (pk.*) dari',
'settings.mapMapboxTokenLink': 'mapbox.com → Token akses',
'settings.mapStyle': 'Gaya peta',
'settings.mapStylePlaceholder': 'Pilih gaya Mapbox',
'settings.mapStyleHint': 'Preset atau URL mapbox://styles/USER/ID milikmu',
'settings.map3dBuildings': 'Bangunan 3D & medan',
'settings.map3dHint': 'Kemiringan + ekstrusi bangunan 3D nyata — bekerja di semua gaya, termasuk satelit.',
'settings.mapHighQuality': 'Mode kualitas tinggi',
'settings.mapHighQualityHint': 'Antialiasing + proyeksi globe untuk tepi yang lebih tajam dan tampilan dunia realistis.',
'settings.mapHighQualityWarning': 'Dapat memengaruhi performa pada perangkat kelas bawah.',
'settings.mapTipLabel': 'Tip:',
'settings.mapTip': 'Klik kanan dan seret untuk memutar/memiringkan peta. Klik tengah untuk menambah tempat (klik kanan untuk rotasi).',
'settings.latitude': 'Lintang',
'settings.longitude': 'Bujur',
'settings.saveMap': 'Simpan Peta',
@@ -520,28 +502,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'Login OIDC gagal',
'login.usernameRequired': 'Nama pengguna wajib diisi',
'login.passwordMinLength': 'Kata sandi minimal 8 karakter',
'login.forgotPassword': 'Lupa kata sandi?',
'login.forgotPasswordTitle': 'Setel ulang kata sandi',
'login.forgotPasswordBody': 'Masukkan alamat email akunmu. Jika akun ada, kami akan mengirim tautan reset.',
'login.forgotPasswordSubmit': 'Kirim tautan',
'login.forgotPasswordSentTitle': 'Periksa email kamu',
'login.forgotPasswordSentBody': 'Jika ada akun dengan email tersebut, tautannya sedang dikirim. Berlaku 60 menit.',
'login.forgotPasswordSmtpHintOff': 'Catatan: administrator belum mengonfigurasi SMTP, jadi tautan reset akan ditulis ke konsol server alih-alih dikirim lewat email.',
'login.backToLogin': 'Kembali ke login',
'login.newPassword': 'Kata sandi baru',
'login.confirmPassword': 'Konfirmasi kata sandi baru',
'login.passwordsDontMatch': 'Kata sandi tidak cocok',
'login.mfaCode': 'Kode 2FA',
'login.resetPasswordTitle': 'Tetapkan kata sandi baru',
'login.resetPasswordBody': 'Pilih kata sandi kuat yang belum pernah kamu pakai di sini. Minimal 8 karakter.',
'login.resetPasswordMfaBody': 'Masukkan kode 2FA atau kode cadangan untuk menyelesaikan reset.',
'login.resetPasswordSubmit': 'Setel ulang kata sandi',
'login.resetPasswordVerify': 'Verifikasi & setel ulang',
'login.resetPasswordSuccessTitle': 'Kata sandi diperbarui',
'login.resetPasswordSuccessBody': 'Sekarang kamu bisa login dengan kata sandi baru.',
'login.resetPasswordInvalidLink': 'Tautan tidak valid',
'login.resetPasswordInvalidLinkBody': 'Tautan hilang atau rusak. Minta tautan baru untuk melanjutkan.',
'login.resetPasswordFailed': 'Reset gagal. Tautan mungkin sudah kedaluwarsa.',
// Register
'register.passwordMismatch': 'Kata sandi tidak cocok',
@@ -2275,7 +2235,6 @@ 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',
@@ -2326,12 +2285,6 @@ 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',
-47
View File
@@ -156,24 +156,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.mapDefaultHint': 'Lascia vuoto per OpenStreetMap (predefinito)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'Modello URL per i tile della mappa',
'settings.mapProvider': 'Provider mappa',
'settings.mapProviderHint': 'Influisce sulle mappe Trip Planner e Journey. Atlas usa sempre Leaflet.',
'settings.mapLeafletSubtitle': 'Classica 2D, qualsiasi tile raster',
'settings.mapMapboxSubtitle': 'Tile vettoriali, edifici 3D e terreno',
'settings.mapExperimental': 'Sperimentale',
'settings.mapMapboxToken': 'Token di accesso Mapbox',
'settings.mapMapboxTokenHint': 'Token pubblico (pk.*) da',
'settings.mapMapboxTokenLink': 'mapbox.com → Token di accesso',
'settings.mapStyle': 'Stile mappa',
'settings.mapStylePlaceholder': 'Seleziona uno stile Mapbox',
'settings.mapStyleHint': 'Preset o il tuo URL mapbox://styles/USER/ID',
'settings.map3dBuildings': 'Edifici 3D e terreno',
'settings.map3dHint': 'Inclinazione + estrusioni 3D reali degli edifici — funziona con ogni stile, incluso satellite.',
'settings.mapHighQuality': 'Modalità alta qualità',
'settings.mapHighQualityHint': 'Antialiasing + proiezione globo per bordi più nitidi e una vista realistica del mondo.',
'settings.mapHighQualityWarning': 'Può influire sulle prestazioni su dispositivi meno potenti.',
'settings.mapTipLabel': 'Suggerimento:',
'settings.mapTip': 'Click destro e trascina per ruotare/inclinare la mappa. Click centrale per aggiungere un luogo (il click destro è riservato alla rotazione).',
'settings.latitude': 'Latitudine',
'settings.longitude': 'Longitudine',
'settings.saveMap': 'Salva Mappa',
@@ -458,28 +440,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'Accesso OIDC non riuscito',
'login.usernameRequired': 'Il nome utente è obbligatorio',
'login.passwordMinLength': 'La password deve contenere almeno 8 caratteri',
'login.forgotPassword': 'Password dimenticata?',
'login.forgotPasswordTitle': 'Reimposta la password',
'login.forgotPasswordBody': 'Inserisci lindirizzo email del tuo account. Se esiste un account, invieremo un link per reimpostarla.',
'login.forgotPasswordSubmit': 'Invia link',
'login.forgotPasswordSentTitle': 'Controlla la tua email',
'login.forgotPasswordSentBody': 'Se esiste un account con questa email, il link è in arrivo. Scade tra 60 minuti.',
'login.forgotPasswordSmtpHintOff': 'Nota: il tuo amministratore non ha configurato SMTP, quindi il link di reset verrà scritto nella console del server invece di essere inviato via email.',
'login.backToLogin': 'Torna allaccesso',
'login.newPassword': 'Nuova password',
'login.confirmPassword': 'Conferma nuova password',
'login.passwordsDontMatch': 'Le password non corrispondono',
'login.mfaCode': 'Codice 2FA',
'login.resetPasswordTitle': 'Imposta una nuova password',
'login.resetPasswordBody': 'Scegli una password robusta che non hai già usato qui. Minimo 8 caratteri.',
'login.resetPasswordMfaBody': 'Inserisci il codice 2FA o un codice di backup per completare il reset.',
'login.resetPasswordSubmit': 'Reimposta password',
'login.resetPasswordVerify': 'Verifica e reimposta',
'login.resetPasswordSuccessTitle': 'Password aggiornata',
'login.resetPasswordSuccessBody': 'Ora puoi accedere con la nuova password.',
'login.resetPasswordInvalidLink': 'Link di reset non valido',
'login.resetPasswordInvalidLinkBody': 'Il link è mancante o danneggiato. Richiedine uno nuovo per continuare.',
'login.resetPasswordFailed': 'Reset non riuscito. Il link potrebbe essere scaduto.',
// Register
'register.passwordMismatch': 'Le password non corrispondono',
@@ -2236,7 +2196,6 @@ 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',
@@ -2287,12 +2246,6 @@ 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',
-47
View File
@@ -156,24 +156,6 @@ const nl: Record<string, string> = {
'settings.mapDefaultHint': 'Laat leeg voor OpenStreetMap (standaard)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'URL-sjabloon voor kaarttegels',
'settings.mapProvider': 'Kaartprovider',
'settings.mapProviderHint': 'Geldt voor Trip Planner en Journey kaarten. Atlas gebruikt altijd Leaflet.',
'settings.mapLeafletSubtitle': 'Klassiek 2D, elke raster-tile',
'settings.mapMapboxSubtitle': 'Vector tiles, 3D-gebouwen & terrein',
'settings.mapExperimental': 'Experimenteel',
'settings.mapMapboxToken': 'Mapbox Access Token',
'settings.mapMapboxTokenHint': 'Openbaar token (pk.*) van',
'settings.mapMapboxTokenLink': 'mapbox.com → Access tokens',
'settings.mapStyle': 'Kaartstijl',
'settings.mapStylePlaceholder': 'Kies een Mapbox-stijl',
'settings.mapStyleHint': 'Preset of eigen mapbox://styles/USER/ID URL',
'settings.map3dBuildings': '3D-gebouwen & terrein',
'settings.map3dHint': 'Kanteling + echte 3D-gebouwenextrusies — werkt op elke stijl, inclusief satelliet.',
'settings.mapHighQuality': 'Hoge kwaliteit modus',
'settings.mapHighQualityHint': 'Antialiasing + globeprojectie voor scherpere randen en een realistische wereldweergave.',
'settings.mapHighQualityWarning': 'Kan de prestaties op minder krachtige apparaten beïnvloeden.',
'settings.mapTipLabel': 'Tip:',
'settings.mapTip': 'Rechts-klik en sleep om de kaart te roteren/kantelen. Middenklik om een locatie toe te voegen (rechts-klik is voor rotatie).',
'settings.latitude': 'Breedtegraad',
'settings.longitude': 'Lengtegraad',
'settings.saveMap': 'Kaart opslaan',
@@ -451,28 +433,6 @@ const nl: Record<string, string> = {
'login.oidcFailed': 'OIDC-aanmelding mislukt',
'login.usernameRequired': 'Gebruikersnaam is vereist',
'login.passwordMinLength': 'Wachtwoord moet minimaal 8 tekens bevatten',
'login.forgotPassword': 'Wachtwoord vergeten?',
'login.forgotPasswordTitle': 'Wachtwoord resetten',
'login.forgotPasswordBody': 'Voer het e-mailadres van je account in. Als er een account bestaat, sturen we een resetlink.',
'login.forgotPasswordSubmit': 'Resetlink verzenden',
'login.forgotPasswordSentTitle': 'Controleer je e-mail',
'login.forgotPasswordSentBody': 'Als er een account bestaat met dit adres, is de resetlink onderweg. Hij verloopt over 60 minuten.',
'login.forgotPasswordSmtpHintOff': 'Let op: de beheerder heeft SMTP niet ingesteld. De resetlink wordt naar de serverconsole geschreven in plaats van via e-mail verzonden.',
'login.backToLogin': 'Terug naar inloggen',
'login.newPassword': 'Nieuw wachtwoord',
'login.confirmPassword': 'Nieuw wachtwoord bevestigen',
'login.passwordsDontMatch': 'Wachtwoorden komen niet overeen',
'login.mfaCode': '2FA-code',
'login.resetPasswordTitle': 'Nieuw wachtwoord instellen',
'login.resetPasswordBody': 'Kies een sterk wachtwoord dat je hier nog niet hebt gebruikt. Minimaal 8 tekens.',
'login.resetPasswordMfaBody': 'Voer je 2FA-code of een back-upcode in om de reset te voltooien.',
'login.resetPasswordSubmit': 'Wachtwoord resetten',
'login.resetPasswordVerify': 'Verifiëren en resetten',
'login.resetPasswordSuccessTitle': 'Wachtwoord bijgewerkt',
'login.resetPasswordSuccessBody': 'Je kunt nu inloggen met je nieuwe wachtwoord.',
'login.resetPasswordInvalidLink': 'Ongeldige resetlink',
'login.resetPasswordInvalidLinkBody': 'Deze link ontbreekt of is ongeldig. Vraag een nieuwe aan om door te gaan.',
'login.resetPasswordFailed': 'Resetten mislukt. De link is mogelijk verlopen.',
'login.oidc.tokenFailed': 'Authenticatie mislukt.',
'login.oidc.invalidState': 'Ongeldige sessie. Probeer het opnieuw.',
'login.demoFailed': 'Demo-login mislukt',
@@ -2235,7 +2195,6 @@ 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',
@@ -2286,12 +2245,6 @@ 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',
-47
View File
@@ -139,24 +139,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'settings.mapDefaultHint': 'Pozostaw puste dla OpenStreetMap (domyślnie)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'Szablon URL dla kafelków mapy',
'settings.mapProvider': 'Dostawca mapy',
'settings.mapProviderHint': 'Dotyczy map Trip Planner i Journey. Atlas zawsze używa Leaflet.',
'settings.mapLeafletSubtitle': 'Klasyczne 2D, dowolne kafelki rastrowe',
'settings.mapMapboxSubtitle': 'Kafelki wektorowe, budynki 3D i teren',
'settings.mapExperimental': 'Eksperymentalne',
'settings.mapMapboxToken': 'Token dostępu Mapbox',
'settings.mapMapboxTokenHint': 'Token publiczny (pk.*) z',
'settings.mapMapboxTokenLink': 'mapbox.com → Tokeny dostępu',
'settings.mapStyle': 'Styl mapy',
'settings.mapStylePlaceholder': 'Wybierz styl Mapbox',
'settings.mapStyleHint': 'Preset lub własny URL mapbox://styles/USER/ID',
'settings.map3dBuildings': 'Budynki 3D i teren',
'settings.map3dHint': 'Nachylenie + prawdziwe wytłaczanie budynków 3D — działa w każdym stylu, także satelitarnym.',
'settings.mapHighQuality': 'Tryb wysokiej jakości',
'settings.mapHighQualityHint': 'Antialiasing + projekcja globusa dla ostrzejszych krawędzi i realistycznego widoku świata.',
'settings.mapHighQualityWarning': 'Może wpływać na wydajność na słabszych urządzeniach.',
'settings.mapTipLabel': 'Wskazówka:',
'settings.mapTip': 'Kliknij prawym przyciskiem i przeciągnij, aby obrócić/pochylić mapę. Środkowy przycisk dodaje miejsce (prawy jest zarezerwowany dla obrotu).',
'settings.latitude': 'Szerokość',
'settings.longitude': 'Długość',
'settings.saveMap': 'Zapisz mapę',
@@ -425,28 +407,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'Logowanie OIDC nie powiodło się',
'login.usernameRequired': 'Nazwa użytkownika jest wymagana',
'login.passwordMinLength': 'Hasło musi mieć co najmniej 8 znaków',
'login.forgotPassword': 'Nie pamiętasz hasła?',
'login.forgotPasswordTitle': 'Zresetuj hasło',
'login.forgotPasswordBody': 'Wpisz adres e-mail użyty przy rejestracji. Jeśli konto istnieje, wyślemy link do resetu.',
'login.forgotPasswordSubmit': 'Wyślij link',
'login.forgotPasswordSentTitle': 'Sprawdź swoją pocztę',
'login.forgotPasswordSentBody': 'Jeśli istnieje konto dla tego adresu, link jest już w drodze. Wygaśnie za 60 minut.',
'login.forgotPasswordSmtpHintOff': 'Uwaga: administrator nie skonfigurował SMTP, więc link resetujący zostanie zapisany w konsoli serwera zamiast wysłania e-mailem.',
'login.backToLogin': 'Wróć do logowania',
'login.newPassword': 'Nowe hasło',
'login.confirmPassword': 'Potwierdź nowe hasło',
'login.passwordsDontMatch': 'Hasła nie są zgodne',
'login.mfaCode': 'Kod 2FA',
'login.resetPasswordTitle': 'Ustaw nowe hasło',
'login.resetPasswordBody': 'Wybierz silne hasło, którego tu jeszcze nie używałeś. Minimum 8 znaków.',
'login.resetPasswordMfaBody': 'Wpisz kod 2FA lub kod zapasowy, aby zakończyć reset.',
'login.resetPasswordSubmit': 'Zresetuj hasło',
'login.resetPasswordVerify': 'Zweryfikuj i zresetuj',
'login.resetPasswordSuccessTitle': 'Hasło zaktualizowane',
'login.resetPasswordSuccessBody': 'Możesz się teraz zalogować nowym hasłem.',
'login.resetPasswordInvalidLink': 'Nieprawidłowy link',
'login.resetPasswordInvalidLinkBody': 'Brakuje linku lub jest uszkodzony. Poproś o nowy, aby kontynuować.',
'login.resetPasswordFailed': 'Reset nie powiódł się. Link mógł wygasnąć.',
// Register
'register.passwordMismatch': 'Hasła nie są identyczne',
@@ -2228,7 +2188,6 @@ 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',
@@ -2279,12 +2238,6 @@ 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',
-47
View File
@@ -156,24 +156,6 @@ const ru: Record<string, string> = {
'settings.mapDefaultHint': 'Оставьте пустым для OpenStreetMap (по умолчанию)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'URL-шаблон для тайлов карты',
'settings.mapProvider': 'Провайдер карты',
'settings.mapProviderHint': 'Применяется к Trip Planner и Journey. Atlas всегда использует Leaflet.',
'settings.mapLeafletSubtitle': 'Классические 2D, любые растровые тайлы',
'settings.mapMapboxSubtitle': 'Векторные тайлы, 3D-здания и рельеф',
'settings.mapExperimental': 'Экспериментально',
'settings.mapMapboxToken': 'Токен доступа Mapbox',
'settings.mapMapboxTokenHint': 'Публичный токен (pk.*) с',
'settings.mapMapboxTokenLink': 'mapbox.com → Токены доступа',
'settings.mapStyle': 'Стиль карты',
'settings.mapStylePlaceholder': 'Выберите стиль Mapbox',
'settings.mapStyleHint': 'Preset или собственный URL mapbox://styles/USER/ID',
'settings.map3dBuildings': '3D-здания и рельеф',
'settings.map3dHint': 'Наклон + настоящие 3D-здания — работает со всеми стилями, включая спутник.',
'settings.mapHighQuality': 'Режим высокого качества',
'settings.mapHighQualityHint': 'Сглаживание + проекция глобуса для более чётких краёв и реалистичного вида мира.',
'settings.mapHighQualityWarning': 'Может повлиять на производительность на слабых устройствах.',
'settings.mapTipLabel': 'Совет:',
'settings.mapTip': 'Зажмите правую кнопку мыши и перетащите, чтобы повернуть/наклонить карту. Клик средней кнопкой — добавить место (правая кнопка зарезервирована для вращения).',
'settings.latitude': 'Широта',
'settings.longitude': 'Долгота',
'settings.saveMap': 'Сохранить карту',
@@ -451,28 +433,6 @@ const ru: Record<string, string> = {
'login.oidcFailed': 'Ошибка входа через OIDC',
'login.usernameRequired': 'Имя пользователя обязательно',
'login.passwordMinLength': 'Пароль должен содержать не менее 8 символов',
'login.forgotPassword': 'Забыли пароль?',
'login.forgotPasswordTitle': 'Сброс пароля',
'login.forgotPasswordBody': 'Введите e-mail, с которым вы регистрировались. Если аккаунт найдём — отправим ссылку для сброса.',
'login.forgotPasswordSubmit': 'Отправить ссылку',
'login.forgotPasswordSentTitle': 'Проверьте почту',
'login.forgotPasswordSentBody': 'Если аккаунт существует, ссылка для сброса уже летит к вам. Она действительна 60 минут.',
'login.forgotPasswordSmtpHintOff': 'Обратите внимание: администратор не настроил SMTP, поэтому ссылка для сброса будет записана в консоль сервера, а не отправлена по почте.',
'login.backToLogin': 'Вернуться ко входу',
'login.newPassword': 'Новый пароль',
'login.confirmPassword': 'Подтвердите новый пароль',
'login.passwordsDontMatch': 'Пароли не совпадают',
'login.mfaCode': 'Код 2FA',
'login.resetPasswordTitle': 'Задайте новый пароль',
'login.resetPasswordBody': 'Выберите надёжный пароль, который вы здесь ещё не использовали. Минимум 8 символов.',
'login.resetPasswordMfaBody': 'Введите код 2FA или резервный код, чтобы завершить сброс.',
'login.resetPasswordSubmit': 'Сбросить пароль',
'login.resetPasswordVerify': 'Проверить и сбросить',
'login.resetPasswordSuccessTitle': 'Пароль обновлён',
'login.resetPasswordSuccessBody': 'Теперь вы можете войти с новым паролем.',
'login.resetPasswordInvalidLink': 'Неверная ссылка сброса',
'login.resetPasswordInvalidLinkBody': 'Ссылка отсутствует или повреждена. Запросите новую, чтобы продолжить.',
'login.resetPasswordFailed': 'Сброс не удался. Возможно, срок действия ссылки истёк.',
'login.oidc.tokenFailed': 'Аутентификация не удалась.',
'login.oidc.invalidState': 'Недействительная сессия. Попробуйте снова.',
'login.demoFailed': 'Ошибка демо-входа',
@@ -2235,7 +2195,6 @@ 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': 'Просмотр поездок и маршрутов',
@@ -2286,12 +2245,6 @@ 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',
-47
View File
@@ -156,24 +156,6 @@ const zh: Record<string, string> = {
'settings.mapDefaultHint': '留空则使用 OpenStreetMap(默认)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': '地图瓦片 URL 模板',
'settings.mapProvider': '地图提供商',
'settings.mapProviderHint': '影响行程规划和旅程地图。Atlas 始终使用 Leaflet。',
'settings.mapLeafletSubtitle': '经典 2D,任何栅格瓦片',
'settings.mapMapboxSubtitle': '矢量瓦片、3D 建筑和地形',
'settings.mapExperimental': '实验性',
'settings.mapMapboxToken': 'Mapbox 访问令牌',
'settings.mapMapboxTokenHint': '公共令牌 (pk.*) 来自',
'settings.mapMapboxTokenLink': 'mapbox.com → 访问令牌',
'settings.mapStyle': '地图样式',
'settings.mapStylePlaceholder': '选择 Mapbox 样式',
'settings.mapStyleHint': '预设或您自己的 mapbox://styles/USER/ID URL',
'settings.map3dBuildings': '3D 建筑和地形',
'settings.map3dHint': '倾斜 + 真实 3D 建筑拉伸 — 适用于所有样式,包括卫星。',
'settings.mapHighQuality': '高画质模式',
'settings.mapHighQualityHint': '抗锯齿 + 地球投影,带来更清晰的边缘和更真实的世界视图。',
'settings.mapHighQualityWarning': '可能影响低端设备的性能。',
'settings.mapTipLabel': '提示:',
'settings.mapTip': '右键点击并拖动以旋转/倾斜地图。中键点击添加地点(右键用于旋转)。',
'settings.latitude': '纬度',
'settings.longitude': '经度',
'settings.saveMap': '保存地图',
@@ -451,28 +433,6 @@ const zh: Record<string, string> = {
'login.oidcFailed': 'OIDC 登录失败',
'login.usernameRequired': '用户名为必填项',
'login.passwordMinLength': '密码至少需要8个字符',
'login.forgotPassword': '忘记密码?',
'login.forgotPasswordTitle': '重置密码',
'login.forgotPasswordBody': '输入您注册时使用的邮箱地址。若账户存在,我们将发送重置链接。',
'login.forgotPasswordSubmit': '发送重置链接',
'login.forgotPasswordSentTitle': '请查看邮箱',
'login.forgotPasswordSentBody': '若该邮箱存在账户,重置链接正在发送中。链接将在 60 分钟后失效。',
'login.forgotPasswordSmtpHintOff': '提示:管理员未配置 SMTP,重置链接将被写入服务器控制台,而不是通过电子邮件发送。',
'login.backToLogin': '返回登录',
'login.newPassword': '新密码',
'login.confirmPassword': '确认新密码',
'login.passwordsDontMatch': '两次输入的密码不一致',
'login.mfaCode': '二步验证码',
'login.resetPasswordTitle': '设置新密码',
'login.resetPasswordBody': '请选择您在此处未使用过的强密码。至少 8 位。',
'login.resetPasswordMfaBody': '输入您的二步验证码或备用代码以完成重置。',
'login.resetPasswordSubmit': '重置密码',
'login.resetPasswordVerify': '验证并重置',
'login.resetPasswordSuccessTitle': '密码已更新',
'login.resetPasswordSuccessBody': '您现在可以使用新密码登录了。',
'login.resetPasswordInvalidLink': '无效的重置链接',
'login.resetPasswordInvalidLinkBody': '此链接已丢失或损坏。请重新申请以继续。',
'login.resetPasswordFailed': '重置失败。链接可能已过期。',
'login.oidc.tokenFailed': '认证失败。',
'login.oidc.invalidState': '会话无效,请重试。',
'login.demoFailed': '演示登录失败',
@@ -2235,7 +2195,6 @@ 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': '查看行程和行程计划',
@@ -2286,12 +2245,6 @@ 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',
-47
View File
@@ -156,24 +156,6 @@ const zhTw: Record<string, string> = {
'settings.mapDefaultHint': '留空則使用 OpenStreetMap(預設)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': '地圖瓦片 URL 模板',
'settings.mapProvider': '地圖提供商',
'settings.mapProviderHint': '影響行程規劃和旅程地圖。Atlas 始終使用 Leaflet。',
'settings.mapLeafletSubtitle': '經典 2D,任何柵格瓦片',
'settings.mapMapboxSubtitle': '向量瓦片、3D 建築和地形',
'settings.mapExperimental': '實驗性',
'settings.mapMapboxToken': 'Mapbox 存取權杖',
'settings.mapMapboxTokenHint': '公開權杖 (pk.*) 來自',
'settings.mapMapboxTokenLink': 'mapbox.com → 存取權杖',
'settings.mapStyle': '地圖樣式',
'settings.mapStylePlaceholder': '選擇 Mapbox 樣式',
'settings.mapStyleHint': '預設或您自己的 mapbox://styles/USER/ID URL',
'settings.map3dBuildings': '3D 建築和地形',
'settings.map3dHint': '傾斜 + 真實 3D 建築拉伸 — 適用於所有樣式,包括衛星。',
'settings.mapHighQuality': '高畫質模式',
'settings.mapHighQualityHint': '抗鋸齒 + 地球投影,帶來更清晰的邊緣和更真實的世界視圖。',
'settings.mapHighQualityWarning': '可能影響低階裝置的效能。',
'settings.mapTipLabel': '提示:',
'settings.mapTip': '右鍵點擊並拖曳以旋轉/傾斜地圖。中鍵點擊新增地點(右鍵用於旋轉)。',
'settings.latitude': '緯度',
'settings.longitude': '經度',
'settings.saveMap': '儲存地圖',
@@ -510,28 +492,6 @@ const zhTw: Record<string, string> = {
'login.oidcFailed': 'OIDC 登入失敗',
'login.usernameRequired': '使用者名稱為必填',
'login.passwordMinLength': '密碼至少需要8個字元',
'login.forgotPassword': '忘記密碼?',
'login.forgotPasswordTitle': '重設密碼',
'login.forgotPasswordBody': '請輸入您註冊時使用的電子郵件。若帳號存在,我們將傳送重設連結。',
'login.forgotPasswordSubmit': '傳送重設連結',
'login.forgotPasswordSentTitle': '請查看您的電子郵件',
'login.forgotPasswordSentBody': '若此電子郵件存在帳號,重設連結正在傳送中。連結將於 60 分鐘後失效。',
'login.forgotPasswordSmtpHintOff': '提醒:管理員尚未設定 SMTP,重設連結將寫入伺服器控制台,而非透過電子郵件寄送。',
'login.backToLogin': '返回登入',
'login.newPassword': '新密碼',
'login.confirmPassword': '確認新密碼',
'login.passwordsDontMatch': '兩次輸入的密碼不一致',
'login.mfaCode': '2FA 驗證碼',
'login.resetPasswordTitle': '設定新密碼',
'login.resetPasswordBody': '請選擇您在此處尚未使用過的強密碼。至少 8 個字元。',
'login.resetPasswordMfaBody': '請輸入您的 2FA 驗證碼或備用代碼以完成重設。',
'login.resetPasswordSubmit': '重設密碼',
'login.resetPasswordVerify': '驗證並重設',
'login.resetPasswordSuccessTitle': '密碼已更新',
'login.resetPasswordSuccessBody': '您現在可以使用新密碼登入。',
'login.resetPasswordInvalidLink': '無效的重設連結',
'login.resetPasswordInvalidLinkBody': '此連結遺失或已損壞。請重新申請以繼續。',
'login.resetPasswordFailed': '重設失敗。連結可能已過期。',
'login.oidc.tokenFailed': '認證失敗。',
'login.oidc.invalidState': '會話無效,請重試。',
'login.demoFailed': '演示登入失敗',
@@ -2236,7 +2196,6 @@ 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': '檢視行程與旅遊計畫',
@@ -2287,12 +2246,6 @@ 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',
-7
View File
@@ -6,11 +6,6 @@ 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);
@@ -452,7 +447,6 @@ 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;
@@ -500,7 +494,6 @@ 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;
+32 -25
View File
@@ -20,9 +20,8 @@ import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
import AuditLogPanel from '../components/Admin/AuditLogPanel'
import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
import PermissionsPanel from '../components/Admin/PermissionsPanel'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle, SlidersHorizontal, UserCog, Puzzle, Settings as SettingsIcon, Bell, Database, ScrollText, KeyRound, GitBranch, Bug } from 'lucide-react'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle } from 'lucide-react'
import CustomSelect from '../components/shared/CustomSelect'
import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar'
interface AdminUser {
id: number
@@ -184,18 +183,18 @@ export default function AdminPage(): React.ReactElement {
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
const mcpEnabled = useAddonStore(s => s.isEnabled('mcp'))
const devMode = useAuthStore(s => s.devMode)
const TABS: PageSidebarTab[] = [
{ id: 'users', label: t('admin.tabs.users'), icon: Users },
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal },
{ id: 'defaults', label: t('admin.tabs.defaults'), icon: UserCog },
{ id: 'addons', label: t('admin.tabs.addons'), icon: Puzzle },
{ id: 'settings', label: t('admin.tabs.settings'), icon: SettingsIcon },
{ id: 'notifications', label: t('admin.tabs.notifications'), icon: Bell },
{ id: 'backup', label: t('admin.tabs.backup'), icon: Database },
{ id: 'audit', label: t('admin.tabs.audit'), icon: ScrollText },
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens'), icon: KeyRound }] : []),
{ id: 'github', label: t('admin.tabs.github'), icon: GitBranch },
...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications', icon: Bug }] : []),
const TABS = [
{ id: 'users', label: t('admin.tabs.users') },
{ id: 'config', label: t('admin.tabs.config') },
{ id: 'defaults', label: t('admin.tabs.defaults') },
{ id: 'addons', label: t('admin.tabs.addons') },
{ id: 'settings', label: t('admin.tabs.settings') },
{ id: 'notifications', label: t('admin.tabs.notifications') },
{ id: 'backup', label: t('admin.tabs.backup') },
{ id: 'audit', label: t('admin.tabs.audit') },
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') }] : []),
{ id: 'github', label: t('admin.tabs.github') },
...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications' }] : []),
]
const [activeTab, setActiveTab] = useState<string>('users')
@@ -501,7 +500,7 @@ export default function AdminPage(): React.ReactElement {
<Navbar />
<div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="w-full px-4 sm:px-6 lg:px-8 py-8">
<div className="max-w-6xl mx-auto px-4 py-8">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center">
@@ -587,15 +586,24 @@ export default function AdminPage(): React.ReactElement {
</div>
)}
{/* Sidebar layout — nav on the left, active panel on the right */}
<PageSidebar
sidebarLabel={t('admin.title').toUpperCase()}
tabs={TABS}
activeTab={activeTab}
onTabChange={setActiveTab}
footer="admin · self-hosted"
>
{/* Tab content */}
{/* Tabs */}
<div className="grid grid-cols-3 sm:flex gap-1 mb-6 rounded-xl p-1" style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)' }}>
{TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium rounded-lg transition-colors ${
activeTab === tab.id
? 'bg-slate-900 text-white'
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab content */}
{activeTab === 'users' && (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
@@ -1610,7 +1618,6 @@ export default function AdminPage(): React.ReactElement {
{activeTab === 'defaults' && <DefaultUserSettingsTab />}
{activeTab === 'dev-notifications' && <DevNotificationsPanel />}
</PageSidebar>
</div>
</div>
-151
View File
@@ -1,151 +0,0 @@
import React, { useState, useEffect, FormEvent, ChangeEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { Mail, ArrowLeft, CheckCircle2, Terminal } from 'lucide-react'
import { useTranslation } from '../i18n'
import { authApi } from '../api/client'
const inputBase: React.CSSProperties = {
width: '100%', padding: '11px 12px 11px 38px', borderRadius: 12,
border: '1px solid #e5e7eb', fontSize: 14, fontFamily: 'inherit',
outline: 'none', transition: 'border-color 120ms',
background: 'white', color: '#111827',
}
const ForgotPasswordPage: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [submitted, setSubmitted] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [smtpConfigured, setSmtpConfigured] = useState<boolean | null>(null)
useEffect(() => {
// Probe whether SMTP is configured so we can warn the user up-front
// that the link will land in the server console instead of their
// inbox. Null while pending — hint is hidden until we know.
authApi.getAppConfig?.()
.then((cfg: any) => {
const hasEmail = !!cfg?.available_channels?.email
setSmtpConfigured(hasEmail)
})
.catch(() => setSmtpConfigured(null))
}, [])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (isLoading) return
setIsLoading(true)
try {
await authApi.forgotPassword({ email: email.trim() })
} catch {
// Enumeration-safe: success UX regardless of server outcome.
}
setSubmitted(true)
setIsLoading(false)
}
return (
<div style={{
minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'linear-gradient(180deg, #f9fafb, #ffffff)', padding: 24, fontFamily: 'inherit',
}}>
<div style={{
width: '100%', maxWidth: 420, background: 'white', borderRadius: 20,
boxShadow: '0 12px 40px rgba(0,0,0,0.06), 0 2px 8px rgba(0,0,0,0.04)',
padding: '32px 28px',
}}>
<button type="button" onClick={() => navigate('/login')} style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
color: '#6b7280', fontSize: 13, fontFamily: 'inherit', marginBottom: 22,
}}>
<ArrowLeft size={14} />{t('login.backToLogin')}
</button>
{submitted ? (
<div style={{ textAlign: 'center', padding: '12px 0' }}>
<div style={{
width: 56, height: 56, borderRadius: '50%', background: '#ecfdf5',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: '#059669', marginBottom: 16,
}}>
<CheckCircle2 size={28} />
</div>
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 10px 0' }}>
{t('login.forgotPasswordSentTitle')}
</h1>
<p style={{ fontSize: 14, color: '#4b5563', lineHeight: 1.55, margin: 0 }}>
{t('login.forgotPasswordSentBody')}
</p>
{smtpConfigured === false && (
<div style={{
marginTop: 18, padding: '12px 14px',
background: '#fffbeb', border: '1px solid #fde68a',
borderRadius: 10, textAlign: 'left',
display: 'flex', alignItems: 'flex-start', gap: 10,
}}>
<Terminal size={16} style={{ color: '#92400e', marginTop: 1, flexShrink: 0 }} />
<p style={{ fontSize: 12.5, color: '#92400e', lineHeight: 1.55, margin: 0 }}>
{t('login.forgotPasswordSmtpHintOff')}
</p>
</div>
)}
<button type="button" onClick={() => navigate('/login')} style={{
marginTop: 24, padding: '11px 22px', background: '#111827', color: 'white',
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
cursor: 'pointer', fontFamily: 'inherit',
}}>{t('login.backToLogin')}</button>
</div>
) : (
<>
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 8px 0' }}>
{t('login.forgotPasswordTitle')}
</h1>
<p style={{ fontSize: 13.5, color: '#6b7280', lineHeight: 1.55, margin: '0 0 16px 0' }}>
{t('login.forgotPasswordBody')}
</p>
{smtpConfigured === false && (
<div style={{
padding: '10px 12px', marginBottom: 18,
background: '#fffbeb', border: '1px solid #fde68a',
borderRadius: 10, display: 'flex', alignItems: 'flex-start', gap: 10,
}}>
<Terminal size={15} style={{ color: '#92400e', marginTop: 1, flexShrink: 0 }} />
<p style={{ fontSize: 12.5, color: '#92400e', lineHeight: 1.5, margin: 0 }}>
{t('login.forgotPasswordSmtpHintOff')}
</p>
</div>
)}
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>
{t('common.email')}
</label>
<div style={{ position: 'relative' }}>
<Mail size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type="email" value={email}
onChange={(e: ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
required placeholder={t('login.emailPlaceholder')} style={inputBase}
onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }}
onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }}
/>
</div>
</div>
<button type="submit" disabled={isLoading} style={{
width: '100%', padding: '12px', background: '#111827', color: 'white',
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
cursor: isLoading ? 'default' : 'pointer', fontFamily: 'inherit',
opacity: isLoading ? 0.7 : 1, transition: 'opacity 0.15s',
}}>
{isLoading ? t('login.signingIn') : t('login.forgotPasswordSubmit')}
</button>
</form>
</>
)}
</div>
</div>
)
}
export default ForgotPasswordPage
+40 -42
View File
@@ -341,7 +341,7 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-010 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-010: Map tab switches view (renders map-container)', () => {
describe('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.skip('FE-PAGE-JOURNEYDETAIL-012: Shows synced trips in sidebar', () => {
describe('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.skip('FE-PAGE-JOURNEYDETAIL-013: Shows contributors list', () => {
describe('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 a back navigation button (icon-only with aria-label)', async () => {
it('renders the back navigation button text', async () => {
await renderAndWait();
expect(screen.getByLabelText('Back to Journey')).toBeInTheDocument();
expect(screen.getByText('Back to Journey')).toBeInTheDocument();
});
});
@@ -706,7 +706,7 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-030 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-030: Active status badge shows Live indicator', () => {
describe('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.skip('FE-PAGE-JOURNEYDETAIL-031: Synced with Trips badge renders', () => {
describe('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.skip('FE-PAGE-JOURNEYDETAIL-036: Trip place count in sidebar', () => {
describe('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.skip('FE-PAGE-JOURNEYDETAIL-037: Contributor avatar initial renders', () => {
describe('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.skip('FE-PAGE-JOURNEYDETAIL-038: Synced badge on trip cards', () => {
describe('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.skip('FE-PAGE-JOURNEYDETAIL-039: Journey Stats heading in sidebar', () => {
describe('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.skip('FE-PAGE-JOURNEYDETAIL-040: No trips linked message', () => {
describe('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.skip('FE-PAGE-JOURNEYDETAIL-054: Link trip section exists in sidebar', () => {
describe('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.skip('FE-PAGE-JOURNEYDETAIL-057: Map tab renders location list', () => {
describe('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.skip('FE-PAGE-JOURNEYDETAIL-058: Map shows entry count', () => {
describe('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.skip('FE-PAGE-JOURNEYDETAIL-059: Contributors section shows invite button', () => {
describe('FE-PAGE-JOURNEYDETAIL-059: Contributors section shows invite button', () => {
it('renders the Contributors heading with an invite button in sidebar', async () => {
await renderAndWait();
@@ -1173,11 +1173,9 @@ describe('JourneyDetailPage', () => {
expect(screen.getByText(/Sunday, March 15/)).toBeInTheDocument();
expect(screen.getByText(/Monday, March 16/)).toBeInTheDocument();
// 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();
// Day group numbers are shown as badges: 1 and 2
const dayBadges = document.querySelectorAll('[class*="sticky"] [class*="rounded-lg"]');
expect(dayBadges.length).toBeGreaterThanOrEqual(2);
// Each day group shows its entries
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
@@ -1512,7 +1510,7 @@ describe('JourneyDetailPage', () => {
// ── AddTripDialog (075-077) ────────────────────────────────────────────
// ── FE-PAGE-JOURNEYDETAIL-075 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-075: Add Trip button opens dialog with search input', () => {
describe('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 });
@@ -1539,7 +1537,7 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-076 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-076: Trip search shows results', () => {
describe('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 });
@@ -1570,7 +1568,7 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-077 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-077: Select trip and link calls API', () => {
describe('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;
@@ -1614,7 +1612,7 @@ describe('JourneyDetailPage', () => {
// ── ContributorInviteDialog (078-080) ──────────────────────────────────
// ── FE-PAGE-JOURNEYDETAIL-078 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-078: Invite button opens dialog', () => {
describe('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 });
@@ -1641,7 +1639,7 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-079 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-079: User search shows results', () => {
describe('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 });
@@ -1672,7 +1670,7 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-080 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-080: Add contributor calls API', () => {
describe('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;
@@ -1869,7 +1867,7 @@ describe('JourneyDetailPage', () => {
// ── MapView deeper (086-089) ──────────────────────────────────────────────
// ── FE-PAGE-JOURNEYDETAIL-086 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-086: Map view location click highlights item', () => {
describe('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();
@@ -1897,7 +1895,7 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-087 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-087: Map view stats bar shows Places/Days/Stories', () => {
describe('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();
@@ -1918,7 +1916,7 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-088 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-088: Map view shows day separators with day numbers', () => {
describe('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();
@@ -1937,7 +1935,7 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-089 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-089: Map view shows connector lines between locations', () => {
describe('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 });
@@ -2371,7 +2369,7 @@ describe('JourneyDetailPage', () => {
// ── AddTripDialog deeper (108-110) ────────────────────────────────────
// ── FE-PAGE-JOURNEYDETAIL-108 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-108: Add Trip search filters results', () => {
describe('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 });
@@ -2412,7 +2410,7 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-109 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-109: Add Trip dialog shows empty state', () => {
describe('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 });
@@ -2437,7 +2435,7 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-110 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-110: Add Trip dialog shows trip destination and dates', () => {
describe('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 });
@@ -2471,7 +2469,7 @@ describe('JourneyDetailPage', () => {
// ── ContributorInviteDialog deeper (111-113) ──────────────────────────
// ── FE-PAGE-JOURNEYDETAIL-111 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-111: Contributor invite shows role selector', () => {
describe('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 });
@@ -2504,7 +2502,7 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-112 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-112: Contributor invite role toggle works', () => {
describe('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 });
@@ -2540,7 +2538,7 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-113 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-113: Contributor invite search filters users', () => {
describe('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 });
@@ -3103,7 +3101,7 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-135 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-135: Contributor invite Invite button disabled without selection', () => {
describe('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 });
@@ -3136,7 +3134,7 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-136 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-136: Contributor invite shows user avatars', () => {
describe('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 });
@@ -3167,7 +3165,7 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-137 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-137: Contributor invite shows email', () => {
describe('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 });
@@ -3195,7 +3193,7 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-138 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-138: Contributor invite shows check mark when user selected', () => {
describe('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 });
@@ -3654,7 +3652,7 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-150 ──────────────────────────────────────────
describe.skip('FE-PAGE-JOURNEYDETAIL-150: ProviderPicker no-trips shows message', () => {
describe('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 });
+220 -275
View File
@@ -1,5 +1,4 @@
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'
@@ -7,8 +6,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/JourneyMapAuto'
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto'
import JourneyMap from '../components/Journey/JourneyMap'
import type { JourneyMapHandle } from '../components/Journey/JourneyMap'
import JournalBody from '../components/Journey/JournalBody'
import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
import PhotoLightbox from '../components/Journey/PhotoLightbox'
@@ -19,7 +18,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, ChevronUp, ChevronDown, Eye, EyeOff,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff,
Archive, ArchiveRestore,
} from 'lucide-react'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
@@ -85,7 +84,7 @@ export default function JourneyDetailPage() {
const navigate = useNavigate()
const toast = useToast()
const { t } = useTranslation()
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore()
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, uploadPhotos, deletePhoto } = useJourneyStore()
const mapRef = useRef<JourneyMapHandle>(null)
const fullMapRef = useRef<JourneyMapHandle>(null)
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
@@ -97,9 +96,7 @@ 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'>('timeline')
const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
const feedRef = useRef<HTMLDivElement>(null)
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
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)
@@ -140,109 +137,34 @@ export default function JourneyDetailPage() {
return () => removeListener(handler)
}, [id])
// 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
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 }
// 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)
}
})
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()
})
}
}, { threshold: 0.5 })
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
}
}
document.querySelectorAll('[data-entry-id]').forEach(el => {
observerRef.current?.observe(el)
})
}, [])
useEffect(() => {
if (current?.entries?.length) {
const t = window.setTimeout(setupScrollSync, 300)
return () => {
window.clearTimeout(t)
scrollCleanupRef.current?.()
}
setTimeout(setupObserver, 300)
}
return () => scrollCleanupRef.current?.()
}, [current?.entries, setupScrollSync])
return () => observerRef.current?.disconnect()
}, [current?.entries, setupObserver])
const handleMarkerClick = useCallback((entryId: string) => {
const el = document.querySelector(`[data-entry-id="${entryId}"]`)
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)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, [])
const handleLocationClick = useCallback((id: string) => {
@@ -250,30 +172,13 @@ export default function JourneyDetailPage() {
}, [])
useEffect(() => {
// 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())
if (view === 'map') {
requestAnimationFrame(() => fullMapRef.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 &&
e.title !== 'Gallery' &&
e.title !== '[Trip Photos]' &&
e.type !== 'skeleton'
),
() => (current?.entries || []).filter(e => e.location_lat && e.location_lng),
[current?.entries]
)
@@ -282,7 +187,6 @@ 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,
@@ -326,8 +230,6 @@ 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">
@@ -360,8 +262,8 @@ export default function JourneyDetailPage() {
/>
)}
{/* Floating top bar on mobile Journey + Gallery views: back | tabs+title | settings */}
{isMobileChromeless && (
{/* Floating top bar on mobile combined view: back | tabs+title | settings */}
{showMobileCombined && (
<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)' }}
@@ -374,31 +276,28 @@ export default function JourneyDetailPage() {
<ArrowLeft size={16} />
</button>
<div className="flex-1 min-w-0 flex justify-center">
<div className="flex-1 min-w-0 flex flex-col items-center gap-1">
<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 ${
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'
}`}
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"
>
<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 ${
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'
}`}
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"
>
<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 ? (
@@ -416,27 +315,16 @@ export default function JourneyDetailPage() {
)}
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className={showMobileCombined ? 'hidden' : ''}>
<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'}>
<div className="max-w-[1440px] mx-auto px-0 md:px-8 pt-0 md:py-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' : ''}`}>
{/* 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">
<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]">
@@ -447,28 +335,38 @@ 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">
<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>
{/* 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>
{/* 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">
@@ -515,13 +413,13 @@ export default function JourneyDetailPage() {
</div>
</div>
{/* 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' : ''}>
{/* Main grid */}
<div className="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-6 px-4 md:px-0">
{/* Left column */}
<div>
{/* View Controls — hidden on mobile (floating top bar has them) */}
<div className={`flex items-center justify-between mt-5 mb-5 ${isMobileChromeless ? 'hidden' : ''}`}>
{/* View Controls */}
<div className="flex items-center justify-between mt-5 mb-5">
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden">
{(isMobile
? [
@@ -531,6 +429,7 @@ 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
@@ -580,7 +479,7 @@ export default function JourneyDetailPage() {
return (
<div key={date} className="flex flex-col gap-3 trek-stagger">
<div className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between">
<div className="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="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}
@@ -594,71 +493,31 @@ export default function JourneyDetailPage() {
</div>
</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>
)
})}
{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>
))}
</div>
)
})}
</div>
)}
{/* 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}
>
{/* Gallery View */}
<div className={view === 'gallery' ? '' : 'hidden'}>
<GalleryView
entries={current.entries}
journeyId={current.id}
@@ -669,29 +528,126 @@ 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>
</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">
{/* 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">
<JourneyMap
ref={mapRef}
checkins={[]}
entries={sidebarMapItems as any}
height={9999}
activeMarkerId={activeEntryId}
height={240}
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>
</aside>
)}
{/* 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>
</div>
</div>
@@ -1371,15 +1327,14 @@ 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 && createPortal(
{menuOpen && (
<>
<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>
)}
@@ -1411,15 +1366,14 @@ 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 && createPortal(
{menuOpen && (
<>
<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>
)}
@@ -2164,7 +2118,6 @@ 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])
@@ -2258,15 +2211,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
}
return (
<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="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="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
@@ -2609,7 +2555,6 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
</button>
</div>
</div>
</div>
</div>
)
}
-11
View File
@@ -781,17 +781,6 @@ export default function LoginPage(): React.ReactElement {
}} />
</button>
</div>
{mode === 'login' && (
<div style={{ textAlign: 'right', marginTop: 6 }}>
<button type="button" onClick={() => navigate('/forgot-password')} style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit',
}}
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.color = '#111827' }}
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.color = '#6b7280' }}
>{t('login.forgotPassword')}</button>
</div>
)}
</div>
)}
-205
View File
@@ -1,205 +0,0 @@
import React, { useState, useEffect, FormEvent, ChangeEvent } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Lock, KeyRound, CheckCircle2, AlertTriangle, Eye, EyeOff } from 'lucide-react'
import { useTranslation } from '../i18n'
import { authApi } from '../api/client'
import { getApiErrorMessage } from '../types'
const inputBase: React.CSSProperties = {
width: '100%', padding: '11px 44px 11px 38px', borderRadius: 12,
border: '1px solid #e5e7eb', fontSize: 14, fontFamily: 'inherit',
outline: 'none', transition: 'border-color 120ms',
background: 'white', color: '#111827',
}
const ResetPasswordPage: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const [params] = useSearchParams()
const token = params.get('token') || ''
const [pw, setPw] = useState('')
const [pw2, setPw2] = useState('')
const [showPw, setShowPw] = useState(false)
const [mfaCode, setMfaCode] = useState('')
const [mfaRequired, setMfaRequired] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
if (!token) setError(t('login.resetPasswordInvalidLink'))
}, [token, t])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (isLoading) return
setError('')
if (!token) return
if (pw.length < 8) { setError(t('login.passwordMinLength')); return }
if (pw !== pw2) { setError(t('login.passwordsDontMatch')); return }
setIsLoading(true)
try {
const res = await authApi.resetPassword({
token,
new_password: pw,
...(mfaRequired && mfaCode ? { mfa_code: mfaCode.trim() } : {}),
})
if (res.mfa_required) {
setMfaRequired(true)
setIsLoading(false)
return
}
if (res.success) {
setSuccess(true)
}
} catch (err) {
setError(getApiErrorMessage(err, t('login.resetPasswordFailed')))
}
setIsLoading(false)
}
const shell = (inner: React.ReactNode) => (
<div style={{
minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'linear-gradient(180deg, #f9fafb, #ffffff)', padding: 24, fontFamily: 'inherit',
}}>
<div style={{
width: '100%', maxWidth: 440, background: 'white', borderRadius: 20,
boxShadow: '0 12px 40px rgba(0,0,0,0.06), 0 2px 8px rgba(0,0,0,0.04)',
padding: '32px 28px',
}}>{inner}</div>
</div>
)
if (success) {
return shell(
<div style={{ textAlign: 'center', padding: '12px 0' }}>
<div style={{
width: 56, height: 56, borderRadius: '50%', background: '#ecfdf5',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: '#059669', marginBottom: 16,
}}><CheckCircle2 size={28} /></div>
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 10px 0' }}>
{t('login.resetPasswordSuccessTitle')}
</h1>
<p style={{ fontSize: 14, color: '#4b5563', lineHeight: 1.55, margin: 0 }}>
{t('login.resetPasswordSuccessBody')}
</p>
<button type="button" onClick={() => navigate('/login')} style={{
marginTop: 24, padding: '11px 22px', background: '#111827', color: 'white',
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
cursor: 'pointer', fontFamily: 'inherit',
}}>{t('login.signIn')}</button>
</div>
)
}
if (!token) {
return shell(
<div style={{ textAlign: 'center', padding: '12px 0' }}>
<div style={{
width: 56, height: 56, borderRadius: '50%', background: '#fef2f2',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: '#dc2626', marginBottom: 16,
}}><AlertTriangle size={28} /></div>
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 10px 0' }}>
{t('login.resetPasswordInvalidLink')}
</h1>
<p style={{ fontSize: 14, color: '#4b5563', lineHeight: 1.55, margin: 0 }}>
{t('login.resetPasswordInvalidLinkBody')}
</p>
<button type="button" onClick={() => navigate('/forgot-password')} style={{
marginTop: 24, padding: '11px 22px', background: '#111827', color: 'white',
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
cursor: 'pointer', fontFamily: 'inherit',
}}>{t('login.forgotPasswordSubmit')}</button>
</div>
)
}
return shell(
<>
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 8px 0' }}>
{t('login.resetPasswordTitle')}
</h1>
<p style={{ fontSize: 13.5, color: '#6b7280', lineHeight: 1.55, margin: '0 0 22px 0' }}>
{mfaRequired ? t('login.resetPasswordMfaBody') : t('login.resetPasswordBody')}
</p>
{error && (
<div style={{
padding: '10px 12px', background: '#fef2f2', border: '1px solid #fecaca',
borderRadius: 10, color: '#991b1b', fontSize: 13, marginBottom: 14,
}}>{error}</div>
)}
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{!mfaRequired && (
<>
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>
{t('login.newPassword')}
</label>
<div style={{ position: 'relative' }}>
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type={showPw ? 'text' : 'password'} value={pw}
onChange={(e: ChangeEvent<HTMLInputElement>) => setPw(e.target.value)}
required placeholder="••••••••" style={inputBase}
onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }}
onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }}
/>
<button type="button" onClick={() => setShowPw(v => !v)} style={{
position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)',
background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#9ca3af',
}}>{showPw ? <EyeOff size={16} /> : <Eye size={16} />}</button>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>
{t('login.confirmPassword')}
</label>
<div style={{ position: 'relative' }}>
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type={showPw ? 'text' : 'password'} value={pw2}
onChange={(e: ChangeEvent<HTMLInputElement>) => setPw2(e.target.value)}
required placeholder="••••••••" style={inputBase}
onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }}
onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }}
/>
</div>
</div>
</>
)}
{mfaRequired && (
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>
{t('login.mfaCode')}
</label>
<div style={{ position: 'relative' }}>
<KeyRound size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type="text" inputMode="numeric" value={mfaCode}
onChange={(e: ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value)}
required placeholder="123456 or backup-code" style={{ ...inputBase, paddingRight: 12 }}
autoFocus
onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }}
onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }}
/>
</div>
</div>
)}
<button type="submit" disabled={isLoading} style={{
width: '100%', padding: '12px', background: '#111827', color: 'white',
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
cursor: isLoading ? 'default' : 'pointer', fontFamily: 'inherit',
opacity: isLoading ? 0.7 : 1,
}}>
{isLoading ? '…' : (mfaRequired ? t('login.resetPasswordVerify') : t('login.resetPasswordSubmit'))}
</button>
</form>
</>
)
}
export default ResetPasswordPage
+36 -32
View File
@@ -1,11 +1,10 @@
import React, { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Settings, Palette, Map, Bell, Plug, CloudOff, User, Info } from 'lucide-react'
import { Settings } from 'lucide-react'
import { useTranslation } from '../i18n'
import { authApi } from '../api/client'
import { useAddonStore } from '../store/addonStore'
import Navbar from '../components/Layout/Navbar'
import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar'
import DisplaySettingsTab from '../components/Settings/DisplaySettingsTab'
import MapSettingsTab from '../components/Settings/MapSettingsTab'
import NotificationsTab from '../components/Settings/NotificationsTab'
@@ -38,18 +37,14 @@ export default function SettingsPage(): React.ReactElement {
}
}, [searchParams])
const tabs: PageSidebarTab[] = [
{ id: 'display', label: t('settings.tabs.display'), icon: Palette },
{ id: 'map', label: t('settings.tabs.map'), icon: Map },
{ id: 'notifications', label: t('settings.tabs.notifications'), icon: Bell },
...(hasIntegrations
? [{ id: 'integrations', label: t('settings.tabs.integrations'), icon: Plug }]
: []),
{ id: 'offline', label: t('settings.tabs.offline'), icon: CloudOff },
{ id: 'account', label: t('settings.tabs.account'), icon: User },
...(appVersion
? [{ id: 'about', label: t('settings.tabs.about'), icon: Info }]
: []),
const TABS = [
{ id: 'display', label: t('settings.tabs.display') },
{ id: 'map', label: t('settings.tabs.map') },
{ id: 'notifications', label: t('settings.tabs.notifications') },
...(hasIntegrations ? [{ id: 'integrations', label: t('settings.tabs.integrations') }] : []),
{ id: 'offline', label: t('settings.tabs.offline') },
{ id: 'account', label: t('settings.tabs.account') },
...(appVersion ? [{ id: 'about', label: t('settings.tabs.about') }] : []),
]
return (
@@ -57,7 +52,7 @@ export default function SettingsPage(): React.ReactElement {
<Navbar />
<div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="w-full px-4 sm:px-6 lg:px-8 py-8">
<div className="max-w-6xl mx-auto px-4 py-8">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ background: 'var(--bg-tertiary)' }}>
@@ -69,24 +64,33 @@ export default function SettingsPage(): React.ReactElement {
</div>
</div>
{/* Sidebar layout */}
<PageSidebar
sidebarLabel={t('settings.title').toUpperCase()}
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={appVersion ? `v${appVersion} · self-hosted` : 'self-hosted'}
>
{activeTab === 'display' && <DisplaySettingsTab />}
{activeTab === 'map' && <MapSettingsTab />}
{activeTab === 'notifications' && <NotificationsTab />}
{activeTab === 'integrations' && hasIntegrations && <IntegrationsTab />}
{activeTab === 'offline' && <OfflineTab />}
{activeTab === 'account' && <AccountTab />}
{activeTab === 'about' && appVersion && <AboutTab appVersion={appVersion} />}
</PageSidebar>
{/* Tab bar */}
<div className="grid grid-cols-3 sm:flex gap-1 mb-6 rounded-xl p-1" style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)' }}>
{TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium rounded-lg transition-colors ${
activeTab === tab.id
? 'bg-slate-900 text-white'
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab content */}
{activeTab === 'display' && <DisplaySettingsTab />}
{activeTab === 'map' && <MapSettingsTab />}
{activeTab === 'notifications' && <NotificationsTab />}
{activeTab === 'integrations' && hasIntegrations && <IntegrationsTab />}
{activeTab === 'offline' && <OfflineTab />}
{activeTab === 'account' && <AccountTab />}
{activeTab === 'about' && appVersion && <AboutTab appVersion={appVersion} />}
</div>
</div>
</div>
)
}
}
+5 -34
View File
@@ -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 { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
import { MapView } from '../components/Map/MapView'
import { getCached, fetchPhoto } from '../services/photoService'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
import PlacesSidebar from '../components/Planner/PlacesSidebar'
@@ -413,39 +413,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
}, [selectAssignment, setSelectedPlaceId])
const handleMarkerClick = useCallback((placeId) => {
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 opening = placeId !== undefined
setSelectedPlaceId(prev => prev === placeId ? null : placeId)
if (opening) { setLeftCollapsed(false); setRightCollapsed(false) }
}, [])
const handleMapClick = useCallback(() => {
setSelectedPlaceId(null)
-30
View File
@@ -100,7 +100,6 @@ 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>
@@ -188,35 +187,6 @@ 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 || []
-5
View File
@@ -32,11 +32,6 @@ 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,
-5
View File
@@ -214,11 +214,6 @@ 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 {
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig({
VitePWA({
registerType: 'autoUpdate',
workbox: {
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/],
Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 792 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 928 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

-146
View File
@@ -1,146 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 820" role="img" aria-label="TREK feature tiles">
<g transform="translate(0 0)">
<defs>
<clipPath id="clip-planner"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-planner)">
<rect width="520" height="260" fill="#FFE8D9"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#B5492D" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M8 2v4M16 2v4M3 10h18"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#B5492D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M8 2v4M16 2v4M3 10h18"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#B5492D">TRIP PLANNER</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Drag and drop</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#B5492D">day by day</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#7C3B2A">Reorder, move across days, optimise</text>
</g>
</g>
<g transform="translate(540 0)">
<defs>
<clipPath id="clip-maps"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-maps)">
<rect width="520" height="260" fill="#DDEDFB"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#1E5AA8" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M1 6l7-3 8 3 7-3v15l-7 3-8-3-7 3zM8 3v15M16 6v15"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#1E5AA8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 6l7-3 8 3 7-3v15l-7 3-8-3-7 3zM8 3v15M16 6v15"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#1E5AA8">MAPS</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">See it all</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1E5AA8">on the map</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#3B4D66">Leaflet + Mapbox GL, 3D buildings</text>
</g>
</g>
<g transform="translate(1080 0)">
<defs>
<clipPath id="clip-collab"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-collab)">
<rect width="520" height="260" fill="#D6F0E3"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#1F7A4E" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#1F7A4E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#1F7A4E">COLLAB</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Plan together</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1F7A4E">in real time</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#325646">WebSocket sync, chat, polls, notes</text>
</g>
</g>
<g transform="translate(0 280)">
<defs>
<clipPath id="clip-budget"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-budget)">
<rect width="520" height="260" fill="#FDF0C7"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#8A6A1E" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M20 12V8a2 2 0 0 0-2-2H4a2 2 0 0 1 0-4h14v4"/><path d="M2 6v14a2 2 0 0 0 2 2h18v-8H16a2 2 0 0 1 0-4h6"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#8A6A1E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 12V8a2 2 0 0 0-2-2H4a2 2 0 0 1 0-4h14v4"/><path d="M2 6v14a2 2 0 0 0 2 2h18v-8H16a2 2 0 0 1 0-4h6"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#8A6A1E">BUDGET</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Track costs</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#8A6A1E">per person</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#6B5523">Pie chart, multi-currency, splits</text>
</g>
</g>
<g transform="translate(540 280)">
<defs>
<clipPath id="clip-packing"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-packing)">
<rect width="520" height="260" fill="#E4DEF6"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#5443A5" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#5443A5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#5443A5">PACKING</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Lists, sorted.</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#5443A5">by category</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#4A3E72">Templates, bag tracking, weights</text>
</g>
</g>
<g transform="translate(1080 280)">
<defs>
<clipPath id="clip-journal"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-journal)">
<rect width="520" height="260" fill="#FBDAE4"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#9A2F58" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#9A2F58" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#9A2F58">JOURNEY</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">A journal for</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#9A2F58">every trip</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#6E3345">Magazine entries, photos, maps</text>
</g>
</g>
<g transform="translate(0 560)">
<defs>
<clipPath id="clip-vacay"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-vacay)">
<rect width="520" height="260" fill="#D4DCF7"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#2F3FA4" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#2F3FA4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#2F3FA4">VACAY</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Vacation days,</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#2F3FA4">visualised</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#3B4471">Calendar, 100+ country holidays</text>
</g>
</g>
<g transform="translate(540 560)">
<defs>
<clipPath id="clip-mcp"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-mcp)">
<rect width="520" height="260" fill="#CFEDE6"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#116E64" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4M8 16h.01M16 16h.01"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#116E64" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4M8 16h.01M16 16h.01"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#116E64">AI / MCP</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Let AI plan</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#116E64">your trips</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#2C524C">80+ tools, OAuth 2.1, Claude-ready</text>
</g>
</g>
<g transform="translate(1080 560)">
<defs>
<clipPath id="clip-selfhost"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-selfhost)">
<rect width="520" height="260" fill="#E4E6EB"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#2D3544" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#2D3544" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#2D3544">SELF-HOSTED</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Runs on</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#2D3544">your server</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#414958">Docker, SQLite, AGPL — your data, yours</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 17 KiB

-130
View File
@@ -1,130 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1060 1100" role="img" aria-label="TREK feature tiles">
<g transform="translate(0 0)">
<defs>
<clipPath id="clip-planner"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-planner)">
<rect width="520" height="260" fill="#FFE8D9"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#B5492D" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M8 2v4M16 2v4M3 10h18"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#B5492D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M8 2v4M16 2v4M3 10h18"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#B5492D">TRIP PLANNER</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Drag and drop</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#B5492D">day by day</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#7C3B2A">Reorder, move across days, optimise</text>
</g>
</g>
<g transform="translate(540 0)">
<defs>
<clipPath id="clip-maps"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-maps)">
<rect width="520" height="260" fill="#DDEDFB"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#1E5AA8" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M1 6l7-3 8 3 7-3v15l-7 3-8-3-7 3zM8 3v15M16 6v15"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#1E5AA8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 6l7-3 8 3 7-3v15l-7 3-8-3-7 3zM8 3v15M16 6v15"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#1E5AA8">MAPS</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">See it all</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1E5AA8">on the map</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#3B4D66">Leaflet + Mapbox GL, 3D buildings</text>
</g>
</g>
<g transform="translate(0 280)">
<defs>
<clipPath id="clip-collab"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-collab)">
<rect width="520" height="260" fill="#D6F0E3"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#1F7A4E" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#1F7A4E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#1F7A4E">COLLAB</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Plan together</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1F7A4E">in real time</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#325646">WebSocket sync, chat, polls, notes</text>
</g>
</g>
<g transform="translate(540 280)">
<defs>
<clipPath id="clip-budget"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-budget)">
<rect width="520" height="260" fill="#FDF0C7"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#8A6A1E" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M20 12V8a2 2 0 0 0-2-2H4a2 2 0 0 1 0-4h14v4"/><path d="M2 6v14a2 2 0 0 0 2 2h18v-8H16a2 2 0 0 1 0-4h6"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#8A6A1E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 12V8a2 2 0 0 0-2-2H4a2 2 0 0 1 0-4h14v4"/><path d="M2 6v14a2 2 0 0 0 2 2h18v-8H16a2 2 0 0 1 0-4h6"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#8A6A1E">BUDGET</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Track costs</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#8A6A1E">per person</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#6B5523">Pie chart, multi-currency, splits</text>
</g>
</g>
<g transform="translate(0 560)">
<defs>
<clipPath id="clip-packing"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-packing)">
<rect width="520" height="260" fill="#E4DEF6"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#5443A5" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#5443A5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#5443A5">PACKING</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Lists, sorted.</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#5443A5">by category</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#4A3E72">Templates, bag tracking, weights</text>
</g>
</g>
<g transform="translate(540 560)">
<defs>
<clipPath id="clip-journal"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-journal)">
<rect width="520" height="260" fill="#FBDAE4"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#9A2F58" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#9A2F58" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#9A2F58">JOURNEY</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">A journal for</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#9A2F58">every trip</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#6E3345">Magazine entries, photos, maps</text>
</g>
</g>
<g transform="translate(0 840)">
<defs>
<clipPath id="clip-vacay"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-vacay)">
<rect width="520" height="260" fill="#D4DCF7"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#2F3FA4" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#2F3FA4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#2F3FA4">VACAY</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Vacation days,</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#2F3FA4">visualised</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#3B4471">Calendar, 100+ country holidays</text>
</g>
</g>
<g transform="translate(540 840)">
<defs>
<clipPath id="clip-mcp"><rect width="520" height="260" rx="22"/></clipPath>
</defs>
<g clip-path="url(#clip-mcp)">
<rect width="520" height="260" fill="#CFEDE6"/>
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
<g transform="translate(390 22) scale(6)" fill="none" stroke="#116E64" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4M8 16h.01M16 16h.01"/></g>
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#116E64" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4M8 16h.01M16 16h.01"/></g>
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#116E64">AI / MCP</text>
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Let AI plan</text>
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#116E64">your trips</text>
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#2C524C">80+ tools, OAuth 2.1, Claude-ready</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 15 KiB

+2 -11
View File
@@ -77,17 +77,12 @@ export function createApp(): express.Application {
const shouldForceHttps = process.env.FORCE_HTTPS === 'true';
// RFC 8414 / RFC 9728: discovery docs are world-readable — open CORS regardless of deployment config
app.use(
['/.well-known/oauth-authorization-server', '/.well-known/oauth-protected-resource'],
cors({ origin: '*', credentials: false }),
);
app.use(cors({ origin: corsOrigin, credentials: true }));
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'wasm-unsafe-eval'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
imgSrc: ["'self'", "data:", "blob:", "https:"],
connectSrc: [
@@ -99,11 +94,8 @@ 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://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
"https://router.project-osrm.org/route/v1/"
],
workerSrc: ["'self'", "blob:"],
childSrc: ["'self'", "blob:"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
@@ -117,7 +109,6 @@ 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);
});
-25
View File
@@ -1767,31 +1767,6 @@ function runMigrations(db: Database.Database): void {
if (!err.message?.includes('no such table') && !err.message?.includes('FOREIGN KEY')) throw err;
}
},
// Migration: RFC 8707 resource indicators — audience-bind OAuth tokens to /mcp
() => {
try { db.exec('ALTER TABLE oauth_tokens ADD COLUMN audience TEXT'); }
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
},
// Migration: password reset — add password_version for session
// invalidation, and a token table keyed by SHA-256 hash (raw tokens
// never hit the DB).
() => {
try { db.exec('ALTER TABLE users ADD COLUMN password_version INTEGER NOT NULL DEFAULT 0'); }
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
db.exec(`
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
consumed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash);
`);
},
];
if (currentVersion < migrations.length) {
-13
View File
@@ -25,23 +25,10 @@ function createTables(db: Database.Database): void {
synology_password TEXT,
synology_sid TEXT,
must_change_password INTEGER DEFAULT 0,
password_version INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
consumed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-17
View File
@@ -11,7 +11,6 @@ import { registerResources } from './resources';
import { registerTools } from './tools';
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
import { writeAudit, getClientIp } from '../services/auditLog';
import { getAppUrl } from '../services/oidcService';
export { revokeUserSessions, revokeUserSessionsForClient };
@@ -39,7 +38,6 @@ 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
@@ -77,7 +75,6 @@ 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
@@ -152,12 +149,6 @@ const sessionSweepInterval = setInterval(() => {
// Prevent the interval from keeping the process alive if nothing else is running
sessionSweepInterval.unref();
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
const base = (getAppUrl() || '').replace(/\/+$/, '');
res.set('WWW-Authenticate',
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource", error="${error}"`);
}
interface VerifyTokenResult {
user: User;
/** null = full access (static token or JWT); string[] = OAuth 2.1 scoped access */
@@ -180,11 +171,6 @@ function verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
if (token.startsWith('trekoa_')) {
const result = getUserByAccessToken(token);
if (!result) return null;
// RFC 8707: if the token carries an audience, it must match this resource endpoint
if (result.audience !== null) {
const expected = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
if (result.audience !== expected) return null;
}
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
}
@@ -223,7 +209,6 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
const tokenResult = verifyToken(req.headers['authorization']);
if (!tokenResult) {
setAuthChallenge(res);
res.status(401).json({ error: 'Access token required' });
return;
}
@@ -244,12 +229,10 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
return;
}
if (session.userId !== user.id) {
setAuthChallenge(res);
res.status(403).json({ error: 'Session belongs to a different user' });
return;
}
if (session.clientId !== clientId) {
setAuthChallenge(res);
res.status(403).json({ error: 'Session was created with a different OAuth client' });
return;
}
+4 -62
View File
@@ -13,9 +13,8 @@ import { listCategories } from '../services/categoryService';
import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService';
import { getNotifications } from '../services/inAppNotifications';
import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService';
import { isAddonEnabled, getCollabFeatures } from '../services/adminService';
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 {
@@ -188,8 +187,7 @@ export function registerResources(server: McpServer, userId: number, scopes: str
);
// Collab notes for a trip
const collabFeatures = isAddonEnabled(ADDON_IDS.COLLAB) ? getCollabFeatures() : null;
if (collabFeatures?.notes && canRead(scopes, 'collab')) server.registerResource(
if (isAddonEnabled(ADDON_IDS.COLLAB) && canRead(scopes, 'collab')) server.registerResource(
'trip-collab-notes',
new ResourceTemplate('trek://trips/{tripId}/collab-notes', { list: undefined }),
{ description: 'Shared collaborative notes for a trip', mimeType: 'application/json' },
@@ -320,8 +318,8 @@ export function registerResources(server: McpServer, userId: number, scopes: str
);
}
// Collab polls (addon + sub-feature gated)
if (collabFeatures?.polls && canRead(scopes, 'collab')) {
// Collab polls & messages (addon-gated)
if (isAddonEnabled(ADDON_IDS.COLLAB) && canRead(scopes, 'collab')) {
server.registerResource(
'trip-collab-polls',
new ResourceTemplate('trek://trips/{tripId}/collab/polls', { list: undefined }),
@@ -333,10 +331,7 @@ export function registerResources(server: McpServer, userId: number, scopes: str
return jsonContent(uri.href, polls);
}
);
}
// Collab messages (addon + sub-feature gated)
if (collabFeatures?.chat && canRead(scopes, 'collab')) {
server.registerResource(
'trip-collab-messages',
new ResourceTemplate('trek://trips/{tripId}/collab/messages', { list: undefined }),
@@ -386,57 +381,4 @@ 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 ?? []);
}
);
}
}
-12
View File
@@ -27,9 +27,6 @@ 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];
@@ -67,9 +64,6 @@ 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' },
};
// ---------------------------------------------------------------------------
@@ -107,12 +101,6 @@ 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 };
-6
View File
@@ -1,7 +1,6 @@
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';
@@ -13,7 +12,6 @@ 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';
@@ -42,10 +40,6 @@ 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 -37
View File
@@ -1,6 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip, db } from '../../db/database';
import { canAccessTrip } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import {
createBudgetItem, updateBudgetItem, deleteBudgetItem,
@@ -94,42 +94,6 @@ 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',
{
+13 -15
View File
@@ -7,7 +7,7 @@ import {
listPolls, createPoll, votePoll, closePoll, deletePoll,
listMessages, createMessage, deleteMessage, addOrRemoveReaction,
} from '../../services/collabService';
import { isAddonEnabled, getCollabFeatures } from '../../services/adminService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
@@ -22,11 +22,9 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
if (!isAddonEnabled(ADDON_IDS.COLLAB)) return;
const features = getCollabFeatures();
// --- COLLAB NOTES ---
if (features.notes && W) server.registerTool(
if (W) server.registerTool(
'create_collab_note',
{
description: 'Create a shared collaborative note on a trip (visible to all trip members in the Collab tab).',
@@ -49,7 +47,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (features.notes && W) server.registerTool(
if (W) server.registerTool(
'update_collab_note',
{
description: 'Edit an existing collaborative note on a trip.',
@@ -74,7 +72,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (features.notes && W) server.registerTool(
if (W) server.registerTool(
'delete_collab_note',
{
description: 'Delete a collaborative note from a trip.',
@@ -96,7 +94,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
// --- COLLAB POLLS & CHAT ---
if (features.polls && R) server.registerTool(
if (R) server.registerTool(
'list_collab_polls',
{
description: 'List all polls for a trip.',
@@ -112,7 +110,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (features.polls && W) server.registerTool(
if (W) server.registerTool(
'create_collab_poll',
{
description: 'Create a new poll in the collab panel.',
@@ -134,7 +132,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (features.polls && W) server.registerTool(
if (W) server.registerTool(
'vote_collab_poll',
{
description: 'Vote on a poll option (or remove vote if already voted for that option).',
@@ -154,7 +152,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (features.polls && W) server.registerTool(
if (W) server.registerTool(
'close_collab_poll',
{
description: 'Close a poll so no more votes can be cast.',
@@ -174,7 +172,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (features.polls && W) server.registerTool(
if (W) server.registerTool(
'delete_collab_poll',
{
description: 'Delete a poll and all its votes.',
@@ -194,7 +192,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (features.chat && R) server.registerTool(
if (R) server.registerTool(
'list_collab_messages',
{
description: 'List chat messages for a trip (most recent 100, oldest-first).',
@@ -211,7 +209,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (features.chat && W) server.registerTool(
if (W) server.registerTool(
'send_collab_message',
{
description: "Send a chat message to a trip's collab channel.",
@@ -232,7 +230,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (features.chat && W) server.registerTool(
if (W) server.registerTool(
'delete_collab_message',
{
description: 'Delete a chat message (only the message owner can delete their own messages).',
@@ -252,7 +250,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
}
);
if (features.chat && W) server.registerTool(
if (W) server.registerTool(
'react_collab_message',
{
description: 'Toggle a reaction emoji on a chat message (adds if not present, removes if already reacted).',
+1 -49
View File
@@ -1,13 +1,12 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip, db } from '../../db/database';
import { canAccessTrip } 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,
@@ -113,53 +112,6 @@ 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',
{
-421
View File
@@ -1,421 +0,0 @@
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 });
}
);
}
-35
View File
@@ -1,6 +1,5 @@
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 {
@@ -111,38 +110,4 @@ 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 });
}
);
}
+2 -99
View File
@@ -1,10 +1,8 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip, db } from '../../db/database';
import { canAccessTrip } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import { deletePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService';
import { createAssignment, dayExists } from '../../services/assignmentService';
import { onPlaceDeleted } from '../../services/journeyService';
import { listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService';
import { listCategories } from '../../services/categoryService';
import { searchPlaces } from '../../services/mapsService';
import {
@@ -49,48 +47,6 @@ 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',
{
@@ -203,57 +159,4 @@ 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 });
}
);
}
+4 -4
View File
@@ -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. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id.',
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. 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.',
inputSchema: {
tripId: z.number().int().positive(),
title: z.string().min(1).max(200),
type: z.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'other']).describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", or "other"'),
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"'),
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. 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.',
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.',
inputSchema: {
tripId: z.number().int().positive(),
reservationId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
type: z.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'other']).optional().describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", or "other"'),
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"'),
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(),
-158
View File
@@ -1,158 +0,0 @@
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(), 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(), 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 });
}
);
}
+19 -11
View File
@@ -12,7 +12,7 @@ import {
import {
createOrUpdateShareLink, getShareLink, deleteShareLink,
} from '../../services/shareService';
import { isAddonEnabled, getCollabFeatures } from '../../services/adminService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import { countMessages, listPolls } from '../../services/collabService';
import {
@@ -161,7 +161,6 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
const packingEnabled = isAddonEnabled(ADDON_IDS.PACKING);
const budgetEnabled = isAddonEnabled(ADDON_IDS.BUDGET);
const collabEnabled = isAddonEnabled(ADDON_IDS.COLLAB);
const collabFeatures = collabEnabled ? getCollabFeatures() : null;
// Scope gates — sections not covered by the client's OAuth scopes are omitted.
// Core trip data (metadata, days, members, accommodations) is always included
// because this tool is always registered and needed for navigation.
@@ -174,16 +173,16 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
let pollCount = 0;
let messageCount = 0;
if (canReadCollab) {
if (collabFeatures?.polls) pollCount = listPolls(tripId).length;
if (collabFeatures?.chat) messageCount = countMessages(tripId);
pollCount = listPolls(tripId).length;
messageCount = countMessages(tripId);
}
const notice = getDeprecationNotice();
const summaryData = {
const data = {
...summary,
reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab && collabFeatures?.notes ? summary.collab_notes : [],
reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab ? summary.collab_notes : [],
todos,
pollCount,
messageCount,
@@ -192,10 +191,19 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
isError: true as const,
content: [
{ type: 'text' as const, text: notice },
{ type: 'text' as const, text: JSON.stringify(summaryData, null, 2) },
{ type: 'text' as const, text: JSON.stringify(data, null, 2) },
],
};
return ok(summaryData);
return ok({
...summary,
reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab ? summary.collab_notes : [],
todos,
pollCount,
messageCount,
});
}
);
+14 -16
View File
@@ -15,21 +15,11 @@ export function extractToken(req: Request): string | null {
function verifyJwtAndLoadUser(token: string): User | null {
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number };
const row = db.prepare(
'SELECT id, username, email, role, password_version FROM users WHERE id = ?'
).get(decoded.id) as (User & { password_version?: number }) | undefined;
if (!row) return null;
// Session invalidation: any token whose embedded password_version
// predates the user's current one is rejected. Tokens issued before
// the `pv` claim existed (decoded.pv === undefined) are treated as
// version 0 so legacy sessions keep working until the user resets.
const tokenPv = typeof decoded.pv === 'number' ? decoded.pv : 0;
const currentPv = typeof row.password_version === 'number' ? row.password_version : 0;
if (tokenPv !== currentPv) return null;
// Don't leak password_version beyond the middleware.
const { password_version: _pv, ...user } = row;
return user as User;
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
const user = db.prepare(
'SELECT id, username, email, role FROM users WHERE id = ?'
).get(decoded.id) as User | undefined;
return user ?? null;
} catch {
return null;
}
@@ -78,7 +68,15 @@ const optionalAuth = (req: Request, res: Response, next: NextFunction): void =>
return next();
}
(req as OptionalAuthRequest).user = verifyJwtAndLoadUser(token) || null;
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
const user = db.prepare(
'SELECT id, username, email, role FROM users WHERE id = ?'
).get(decoded.id) as User | undefined;
(req as OptionalAuthRequest).user = user || null;
} catch (err: unknown) {
(req as OptionalAuthRequest).user = null;
}
next();
};
-1
View File
@@ -266,7 +266,6 @@ router.get('/collab-features', (_req: Request, res: Response) => {
router.put('/collab-features', (req: Request, res: Response) => {
const result = svc.updateCollabFeatures(req.body);
invalidateMcpSessions();
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
-78
View File
@@ -36,10 +36,7 @@ import {
deleteMcpToken,
createWsToken,
createResourceToken,
requestPasswordReset,
resetPassword,
} from '../services/authService';
import { sendPasswordResetEmail } from '../services/notifications';
const router = express.Router();
@@ -79,8 +76,6 @@ const RATE_LIMIT_CLEANUP = 5 * 60 * 1000;
const loginAttempts = new Map<string, { count: number; first: number }>();
const mfaAttempts = new Map<string, { count: number; first: number }>();
const forgotAttempts = new Map<string, { count: number; first: number }>();
const resetAttempts = new Map<string, { count: number; first: number }>();
setInterval(() => {
const now = Date.now();
for (const [key, record] of loginAttempts) {
@@ -89,12 +84,6 @@ setInterval(() => {
for (const [key, record] of mfaAttempts) {
if (now - record.first >= RATE_LIMIT_WINDOW) mfaAttempts.delete(key);
}
for (const [key, record] of forgotAttempts) {
if (now - record.first >= RATE_LIMIT_WINDOW) forgotAttempts.delete(key);
}
for (const [key, record] of resetAttempts) {
if (now - record.first >= RATE_LIMIT_WINDOW) resetAttempts.delete(key);
}
}, RATE_LIMIT_CLEANUP);
function rateLimiter(maxAttempts: number, windowMs: number, store = loginAttempts) {
@@ -115,8 +104,6 @@ function rateLimiter(maxAttempts: number, windowMs: number, store = loginAttempt
}
const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW);
const mfaLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, mfaAttempts);
const forgotLimiter = rateLimiter(3, RATE_LIMIT_WINDOW, forgotAttempts);
const resetLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, resetAttempts);
// ---------------------------------------------------------------------------
// Routes
@@ -159,71 +146,6 @@ router.post('/login', authLimiter, (req: Request, res: Response) => {
res.json({ token: result.token, user: result.user });
});
// ---------------------------------------------------------------------------
// Password reset (forgot / complete)
// ---------------------------------------------------------------------------
// Generic OK response — identical regardless of email existence, to
// prevent enumeration via response body OR status code.
const GENERIC_FORGOT_RESPONSE = { ok: true };
// Minimum time we spend inside the forgot handler so a "no such user"
// path does not complete noticeably faster than a real reset.
const FORGOT_MIN_LATENCY_MS = 350;
router.post('/forgot-password', forgotLimiter, async (req: Request, res: Response) => {
const started = Date.now();
const rawEmail = typeof req.body?.email === 'string' ? req.body.email : '';
const ip = getClientIp(req);
const outcome = requestPasswordReset(rawEmail, ip);
if (outcome.reason === 'issued' && outcome.tokenForDelivery && outcome.userEmail) {
// Build the reset URL from the incoming request origin so dev /
// prod both work without extra config.
const origin = (req.headers['origin'] as string | undefined)
|| (req.headers['referer'] ? new URL(req.headers['referer'] as string).origin : undefined)
|| `${req.protocol}://${req.get('host')}`;
const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`;
// Audit the REQUEST always — even for "no user" — so abuse is visible.
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } });
try {
const delivery = await sendPasswordResetEmail(outcome.userEmail, url, outcome.userId);
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } });
} catch (err) {
// Never surface delivery failure to the caller — still respond ok.
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } });
}
} else {
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } });
}
// Pad the response so timing doesn't reveal outcome.
const elapsed = Date.now() - started;
if (elapsed < FORGOT_MIN_LATENCY_MS) {
await new Promise((r) => setTimeout(r, FORGOT_MIN_LATENCY_MS - elapsed));
}
res.json(GENERIC_FORGOT_RESPONSE);
});
router.post('/reset-password', resetLimiter, (req: Request, res: Response) => {
const ip = getClientIp(req);
const result = resetPassword(req.body);
if (result.error) {
writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } });
return res.status(result.status!).json({ error: result.error });
}
if (result.mfa_required) {
return res.status(200).json({ mfa_required: true });
}
writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip });
// Purposefully do NOT auto-login — the user just demonstrated they
// have email+password access; asking them to sign in fresh is the
// standard, safer UX.
res.json({ success: true });
});
router.get('/me', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const user = getCurrentUser(authReq.user.id);
-12
View File
@@ -257,18 +257,6 @@ 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) => {
+4 -28
View File
@@ -87,20 +87,6 @@ oauthPublicRouter.get('/.well-known/oauth-authorization-server', (req: Request,
scope_descriptions: Object.fromEntries(
ALL_SCOPES.map(s => [s, SCOPE_INFO[s].label])
),
resource_parameter_supported: true,
});
});
// RFC 9728 Protected Resource Metadata
oauthPublicRouter.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const base = (getAppUrl() || '').replace(/\/+$/, '');
res.json({
resource: `${base}/mcp`,
authorization_servers: [base],
bearer_methods_supported: ['header'],
scopes_supported: ALL_SCOPES,
resource_name: 'TREK MCP',
});
});
@@ -112,7 +98,7 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
// Accept both JSON and application/x-www-form-urlencoded
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token, resource } = body;
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token } = body;
const ip = getClientIp(req);
if (!isAddonEnabled(ADDON_IDS.MCP)) {
@@ -147,12 +133,6 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
// RFC 8707: if the auth code was bound to a resource, the token request must present the same value
if (pending.resource && resource && pending.resource !== resource.replace(/\/+$/, '')) {
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'resource_mismatch' }, ip });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
// Verify client secret
if (!authenticateClient(client_id, client_secret)) {
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
@@ -166,8 +146,8 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
const tokens = issueTokens(client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip });
const tokens = issueTokens(client_id, pending.userId, pending.scopes);
writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes }, ip });
return res.json(tokens);
}
@@ -295,7 +275,6 @@ oauthApiRouter.get('/authorize/validate', validateLimiter, optionalAuth, (req: R
state: params.state,
code_challenge: params.code_challenge || '',
code_challenge_method: params.code_challenge_method || '',
resource: typeof params.resource === 'string' ? params.resource : undefined,
},
userId,
);
@@ -319,7 +298,7 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons
const { user } = req as AuthRequest;
const {
client_id, redirect_uri, scope, state,
code_challenge, code_challenge_method, approved, resource,
code_challenge, code_challenge_method, approved,
} = req.body as {
client_id: string;
redirect_uri: string;
@@ -328,7 +307,6 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons
code_challenge: string;
code_challenge_method: string;
approved: boolean;
resource?: string;
};
const ip = getClientIp(req);
@@ -354,7 +332,6 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons
state,
code_challenge,
code_challenge_method,
resource,
};
const validation = validateAuthorizeRequest(params, user.id);
@@ -373,7 +350,6 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons
userId: user.id,
redirectUri: redirect_uri,
scopes,
resource: validation.resource ?? null,
codeChallenge: code_challenge,
codeChallengeMethod: 'S256',
});
+2 -209
View File
@@ -156,12 +156,9 @@ export function isOidcOnlyMode(): boolean {
return !resolveAuthToggles().password_login;
}
export function generateToken(user: { id: number | bigint; password_version?: number }) {
const pv = typeof user.password_version === 'number'
? user.password_version
: ((db.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version?: number } | undefined)?.password_version ?? 0);
export function generateToken(user: { id: number | bigint }) {
return jwt.sign(
{ id: user.id, pv },
{ id: user.id },
JWT_SECRET,
{ expiresIn: '24h', algorithm: 'HS256' }
);
@@ -997,210 +994,6 @@ export function verifyMfaLogin(body: {
}
}
// ---------------------------------------------------------------------------
// Password reset
// ---------------------------------------------------------------------------
// 60 min; long enough to read the email in a second tab, short enough
// that a leaked link is unlikely to still be valid when someone tries it.
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000;
const PASSWORD_RESET_TOKEN_BYTES = 32; // 256-bit entropy
/**
* Returns the SHA-256 hex hash of a reset token. Raw tokens are never
* persisted we only store and compare their hashes.
*/
function hashResetToken(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}
/**
* Shape returned by requestPasswordReset. For enumeration-safety the
* route ALWAYS returns the same response to the client regardless of
* whether a user existed this struct is only consumed internally by
* the route handler to decide whether to send an email / log a link.
*/
export interface PasswordResetRequestOutcome {
tokenForDelivery: string | null; // raw token — send via email or log, never return to client
userId: number | null;
userEmail: string | null;
reason: 'issued' | 'no_user' | 'oidc_only' | 'throttled_per_email' | 'password_login_disabled';
}
// Per-email throttle (defence-in-depth on top of the per-IP limiter).
const perEmailResetAttempts = new Map<string, { count: number; first: number }>();
const PASSWORD_RESET_PER_EMAIL_WINDOW_MS = 15 * 60 * 1000;
const PASSWORD_RESET_PER_EMAIL_MAX = 3;
setInterval(() => {
const now = Date.now();
for (const [key, record] of perEmailResetAttempts) {
if (now - record.first >= PASSWORD_RESET_PER_EMAIL_WINDOW_MS) perEmailResetAttempts.delete(key);
}
}, 5 * 60 * 1000).unref?.();
export function requestPasswordReset(rawEmail: string, createdIp: string | null): PasswordResetRequestOutcome {
const email = String(rawEmail || '').trim().toLowerCase();
// Basic shape check — a fully empty / malformed email is treated like
// "no user" so we still spend the same time internally.
const looksLikeEmail = email.length > 0 && /.+@.+\..+/.test(email);
// Global policy check: password login disabled → no reset possible.
const toggles = resolveAuthToggles();
if (!toggles.password_login) {
return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'password_login_disabled' };
}
// Per-email throttle. We check this BEFORE the DB lookup so the timing
// is identical regardless of whether the account exists.
const throttleKey = email || '__noemail__';
const now = Date.now();
const record = perEmailResetAttempts.get(throttleKey);
if (record && record.count >= PASSWORD_RESET_PER_EMAIL_MAX && now - record.first < PASSWORD_RESET_PER_EMAIL_WINDOW_MS) {
return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'throttled_per_email' };
}
if (!record || now - record.first >= PASSWORD_RESET_PER_EMAIL_WINDOW_MS) {
perEmailResetAttempts.set(throttleKey, { count: 1, first: now });
} else {
record.count++;
}
if (!looksLikeEmail) {
return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'no_user' };
}
const user = db.prepare('SELECT id, email, password_hash, oidc_sub FROM users WHERE email = ?').get(email) as
| { id: number; email: string; password_hash: string | null; oidc_sub: string | null }
| undefined;
if (!user) {
return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'no_user' };
}
// OIDC-only account (no local password) — we can't reset what isn't there.
// The client still gets the generic "if that email exists…" response.
if (!user.password_hash && user.oidc_sub) {
return { tokenForDelivery: null, userId: user.id, userEmail: user.email, reason: 'oidc_only' };
}
// Invalidate any prior unconsumed tokens for this user so there is
// always at most one live reset link in flight.
db.prepare(
"UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE user_id = ? AND consumed_at IS NULL"
).run(user.id);
const raw = randomBytes(PASSWORD_RESET_TOKEN_BYTES).toString('base64url');
const token_hash = hashResetToken(raw);
const expires_at = new Date(Date.now() + PASSWORD_RESET_TTL_MS).toISOString();
db.prepare(
'INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, created_ip) VALUES (?, ?, ?, ?)'
).run(user.id, token_hash, expires_at, createdIp);
return { tokenForDelivery: raw, userId: user.id, userEmail: user.email, reason: 'issued' };
}
export interface ResetPasswordOutcome {
error?: string;
status?: number;
success?: boolean;
/** When true the client must collect a TOTP/backup code and call again. */
mfa_required?: boolean;
userId?: number;
}
/**
* Consume a reset token and set a new password. If the target user has
* MFA enabled, a valid TOTP code or backup code must be supplied a
* compromised email alone therefore does NOT allow taking over a
* 2FA-protected account.
*/
export function resetPassword(body: {
token?: string;
new_password?: string;
mfa_code?: string;
}): ResetPasswordOutcome {
const { token, new_password, mfa_code } = body;
if (!token || typeof token !== 'string') {
return { error: 'Reset token is required', status: 400 };
}
if (!new_password || typeof new_password !== 'string') {
return { error: 'New password is required', status: 400 };
}
// Check the policy BEFORE touching the token so an invalid password
// does not burn the user's one-time link.
const pwCheck = validatePassword(new_password);
if (!pwCheck.ok) return { error: pwCheck.reason!, status: 400 };
const tokenHash = hashResetToken(token);
const row = db.prepare(
'SELECT id, user_id, expires_at, consumed_at FROM password_reset_tokens WHERE token_hash = ?'
).get(tokenHash) as
| { id: number; user_id: number; expires_at: string; consumed_at: string | null }
| undefined;
if (!row) return { error: 'Invalid or expired reset link', status: 400 };
if (row.consumed_at) return { error: 'This reset link has already been used', status: 400 };
if (new Date(row.expires_at).getTime() < Date.now()) {
return { error: 'Reset link has expired. Please request a new one.', status: 400 };
}
const user = db.prepare(
'SELECT id, email, mfa_enabled, mfa_secret, mfa_backup_codes, password_version FROM users WHERE id = ?'
).get(row.user_id) as
| { id: number; email: string; mfa_enabled: number | boolean; mfa_secret: string | null; mfa_backup_codes: string | null; password_version: number }
| undefined;
if (!user) return { error: 'Invalid or expired reset link', status: 400 };
// MFA gate. If enabled, require a valid TOTP or backup code.
const mfaOn = user.mfa_enabled === 1 || user.mfa_enabled === true;
let backupCodeConsumedIndex: number | null = null;
if (mfaOn) {
if (!user.mfa_secret) {
// Data inconsistency — fail closed.
return { error: 'MFA is enabled but not configured. Contact your administrator.', status: 500 };
}
const supplied = typeof mfa_code === 'string' ? mfa_code.trim() : '';
if (!supplied) return { mfa_required: true, status: 200 };
const secret = decryptMfaSecret(user.mfa_secret);
const okTotp = authenticator.verify({ token: supplied.replace(/\s/g, ''), secret });
if (!okTotp) {
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
const candidateHash = hashBackupCode(supplied);
const idx = hashes.findIndex(h => h === candidateHash);
if (idx === -1) return { error: 'Invalid MFA code', status: 401 };
backupCodeConsumedIndex = idx;
}
}
const newHash = bcrypt.hashSync(new_password, 12);
const newPv = (user.password_version ?? 0) + 1;
db.transaction(() => {
// Burn the token first to keep it atomic with the password change.
db.prepare('UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE id = ?').run(row.id);
// Also burn every OTHER live token for this user — a fresh login
// should not leave a second door open.
db.prepare(
"UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE user_id = ? AND consumed_at IS NULL AND id != ?"
).run(user.id, row.id);
db.prepare(
'UPDATE users SET password_hash = ?, must_change_password = 0, password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
).run(newHash, newPv, user.id);
// Consume backup code if one was used.
if (backupCodeConsumedIndex !== null) {
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
hashes.splice(backupCodeConsumedIndex, 1);
db.prepare('UPDATE users SET mfa_backup_codes = ? WHERE id = ?').run(JSON.stringify(hashes), user.id);
}
})();
// Kick off any MCP/WS session cleanup — same hook the account-delete path uses.
try { revokeUserSessions?.(user.id); } catch { /* best-effort */ }
return { success: true, userId: user.id };
}
// ---------------------------------------------------------------------------
// MCP tokens
// ---------------------------------------------------------------------------
-25
View File
@@ -598,31 +598,6 @@ 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;
-97
View File
@@ -316,103 +316,6 @@ export function buildEmailHtml(subject: string, body: string, lang: string, navi
// ── Send functions ─────────────────────────────────────────────────────────
// ── Password reset email ───────────────────────────────────────────────────
interface PasswordResetStrings { subject: string; greeting: string; body: string; ctaIntro: string; expiry: string; ignore: string }
const PASSWORD_RESET_I18N: Record<string, PasswordResetStrings> = {
en: { subject: 'Reset your password', greeting: 'Hi', body: 'We received a request to reset the password for your TREK account. Click the button below to set a new password.', ctaIntro: 'Reset password', expiry: 'This link expires in 60 minutes.', ignore: "If you didn't request this, you can safely ignore this email — your password won't change." },
de: { subject: 'Passwort zurücksetzen', greeting: 'Hallo', body: 'Wir haben eine Anfrage erhalten, das Passwort für dein TREK-Konto zurückzusetzen. Klicke auf den Button unten, um ein neues Passwort festzulegen.', ctaIntro: 'Passwort zurücksetzen', expiry: 'Dieser Link ist 60 Minuten gültig.', ignore: 'Wenn du das nicht warst, ignoriere diese E-Mail — dein Passwort bleibt unverändert.' },
fr: { subject: 'Réinitialisez votre mot de passe', greeting: 'Bonjour', body: 'Nous avons reçu une demande de réinitialisation du mot de passe de votre compte TREK. Cliquez sur le bouton ci-dessous pour définir un nouveau mot de passe.', ctaIntro: 'Réinitialiser le mot de passe', expiry: 'Ce lien expire dans 60 minutes.', ignore: "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail — votre mot de passe ne changera pas." },
es: { subject: 'Restablecer tu contraseña', greeting: 'Hola', body: 'Recibimos una solicitud para restablecer la contraseña de tu cuenta de TREK. Haz clic en el botón de abajo para establecer una nueva contraseña.', ctaIntro: 'Restablecer contraseña', expiry: 'Este enlace caduca en 60 minutos.', ignore: 'Si no solicitaste esto, puedes ignorar este correo — tu contraseña no cambiará.' },
it: { subject: 'Reimposta la tua password', greeting: 'Ciao', body: 'Abbiamo ricevuto una richiesta di reimpostazione della password per il tuo account TREK. Clicca il pulsante qui sotto per impostare una nuova password.', ctaIntro: 'Reimposta password', expiry: 'Questo link scade tra 60 minuti.', ignore: 'Se non hai richiesto questa operazione, ignora questa email — la tua password non cambierà.' },
nl: { subject: 'Reset je wachtwoord', greeting: 'Hallo', body: 'We hebben een verzoek ontvangen om het wachtwoord voor je TREK-account te resetten. Klik op de knop hieronder om een nieuw wachtwoord in te stellen.', ctaIntro: 'Wachtwoord resetten', expiry: 'Deze link verloopt over 60 minuten.', ignore: 'Als jij dit niet hebt aangevraagd, kun je deze e-mail negeren — je wachtwoord blijft ongewijzigd.' },
ru: { subject: 'Сброс пароля', greeting: 'Здравствуйте', body: 'Мы получили запрос на сброс пароля вашего аккаунта TREK. Нажмите кнопку ниже, чтобы установить новый пароль.', ctaIntro: 'Сбросить пароль', expiry: 'Ссылка действительна 60 минут.', ignore: 'Если вы не запрашивали сброс — просто проигнорируйте это письмо, пароль останется прежним.' },
zh: { subject: '重置您的密码', greeting: '您好', body: '我们收到了重置您的 TREK 账户密码的请求。点击下方按钮设置新密码。', ctaIntro: '重置密码', expiry: '此链接将在 60 分钟后失效。', ignore: '如果这不是您本人的请求,可以忽略本邮件 — 您的密码不会改变。' },
'zh-TW': { subject: '重設您的密碼', greeting: '您好', body: '我們收到了重設您 TREK 帳號密碼的請求。點擊下方按鈕以設定新密碼。', ctaIntro: '重設密碼', expiry: '此連結將於 60 分鐘後失效。', ignore: '若非您本人發起的請求,請忽略此郵件 — 您的密碼不會變更。' },
hu: { subject: 'Jelszó visszaállítása', greeting: 'Szia', body: 'Kérést kaptunk a TREK-fiókod jelszavának visszaállítására. Kattints az alábbi gombra az új jelszó beállításához.', ctaIntro: 'Jelszó visszaállítása', expiry: 'Ez a link 60 perc után lejár.', ignore: 'Ha nem te kérted ezt, nyugodtan hagyd figyelmen kívül ezt az e-mailt — a jelszavad változatlan marad.' },
ar: { subject: 'إعادة تعيين كلمة المرور', greeting: 'مرحبا', body: 'تلقينا طلبًا لإعادة تعيين كلمة المرور لحسابك في TREK. انقر على الزر أدناه لتعيين كلمة مرور جديدة.', ctaIntro: 'إعادة تعيين كلمة المرور', expiry: 'تنتهي صلاحية هذا الرابط خلال 60 دقيقة.', ignore: 'إذا لم تطلب هذا، يمكنك تجاهل هذه الرسالة — لن تتغير كلمة المرور الخاصة بك.' },
br: { subject: 'Redefinir sua senha', greeting: 'Olá', body: 'Recebemos um pedido para redefinir a senha da sua conta TREK. Clique no botão abaixo para definir uma nova senha.', ctaIntro: 'Redefinir senha', expiry: 'Este link expira em 60 minutos.', ignore: 'Se você não solicitou isto, pode ignorar este e-mail — sua senha não será alterada.' },
cs: { subject: 'Obnovení hesla', greeting: 'Ahoj', body: 'Obdrželi jsme žádost o obnovení hesla k tvému účtu TREK. Klikni na tlačítko níže a nastav nové heslo.', ctaIntro: 'Obnovit heslo', expiry: 'Odkaz vyprší za 60 minut.', ignore: 'Pokud jsi o obnovení nežádal/a, tento e-mail ignoruj — heslo zůstane beze změny.' },
pl: { subject: 'Zresetuj hasło', greeting: 'Cześć', body: 'Otrzymaliśmy prośbę o zresetowanie hasła do Twojego konta TREK. Kliknij przycisk poniżej, aby ustawić nowe hasło.', ctaIntro: 'Zresetuj hasło', expiry: 'Link wygaśnie za 60 minut.', ignore: 'Jeśli to nie Ty, zignoruj tę wiadomość — Twoje hasło pozostanie bez zmian.' },
};
function buildPasswordResetHtml(subject: string, strings: PasswordResetStrings, recipient: string, resetUrl: string, lang: string): string {
const safeGreeting = escapeHtml(`${strings.greeting}, ${recipient}`);
const safeBody = escapeHtml(strings.body);
const safeExpiry = escapeHtml(strings.expiry);
const safeIgnore = escapeHtml(strings.ignore);
const safeCta = escapeHtml(strings.ctaIntro);
const block = `
<p style="margin:0 0 16px 0; font-size:16px;">${safeGreeting},</p>
<p style="margin:0 0 20px 0; font-size:15px; line-height:1.6;">${safeBody}</p>
<p style="margin:28px 0;">
<a href="${resetUrl}" style="display:inline-block;padding:14px 28px;background:#111827;color:#fff;text-decoration:none;border-radius:10px;font-weight:600;font-size:15px;">${safeCta}</a>
</p>
<p style="margin:0 0 10px 0; font-size:13px; color:#6B7280;">${safeExpiry}</p>
<p style="margin:0; font-size:13px; color:#6B7280;">${safeIgnore}</p>
`;
return buildEmailHtml(subject, block, lang);
}
/**
* Delivers a password-reset link. When SMTP is configured the user
* receives an email. When it isn't, the link is logged to stdout in a
* clearly-fenced block so the self-hosting admin can hand it off by
* other means. In both cases the caller always gets a boolean that
* indicates only whether the caller should treat delivery as
* best-effort done the API response to the user must NOT leak it.
*/
export async function sendPasswordResetEmail(
to: string,
resetUrl: string,
userId: number | null,
): Promise<{ delivered: 'email' | 'log' | 'failed' }> {
const lang = userId ? getUserLanguage(userId) : 'en';
const strings = PASSWORD_RESET_I18N[lang] || PASSWORD_RESET_I18N.en;
const smtpCfg = getSmtpConfig();
if (!smtpCfg) {
// No SMTP configured — log the link in a visually distinct block so
// the admin can relay it. Never log the associated user id/email
// content at a lower level, only what's needed.
// eslint-disable-next-line no-console
console.log(
`\n===== PASSWORD RESET LINK =====\n` +
`to: ${to}\n` +
`url: ${resetUrl}\n` +
`expires: 60 minutes\n` +
`(SMTP is not configured — deliver this link to the user manually.)\n` +
`================================\n`,
);
logInfo(`Password reset link issued (no SMTP) for=${to}`);
return { delivered: 'log' };
}
try {
const skipTls = process.env.SMTP_SKIP_TLS_VERIFY === 'true' || getAppSetting('smtp_skip_tls_verify') === 'true';
const transporter = nodemailer.createTransport({
host: smtpCfg.host,
port: smtpCfg.port,
secure: smtpCfg.secure,
auth: smtpCfg.user ? { user: smtpCfg.user, pass: smtpCfg.pass } : undefined,
...(skipTls ? { tls: { rejectUnauthorized: false } } : {}),
});
await transporter.sendMail({
from: smtpCfg.from,
to,
subject: `TREK — ${strings.subject}`,
text: `${strings.greeting}, ${to}\n\n${strings.body}\n\n${strings.ctaIntro}: ${resetUrl}\n\n${strings.expiry}\n${strings.ignore}`,
html: buildPasswordResetHtml(strings.subject, strings, to, resetUrl, lang),
});
logInfo(`Password reset email sent to=${to}`);
return { delivered: 'email' };
} catch (err) {
logError(`Password reset email failed to=${to}: ${err instanceof Error ? err.message : err}`);
return { delivered: 'failed' };
}
}
export async function sendEmail(to: string, subject: string, body: string, userId?: number, navigateTarget?: string): Promise<boolean> {
const config = getSmtpConfig();
if (!config) return false;
+6 -23
View File
@@ -6,7 +6,6 @@ import { ADDON_IDS } from '../addons';
import { User } from '../types';
import { writeAudit, logWarn } from './auditLog';
import { revokeUserSessionsForClient } from '../mcp/sessionManager';
import { getAppUrl } from './oidcService';
// ---------------------------------------------------------------------------
// Constants
@@ -29,7 +28,6 @@ interface PendingCode {
userId: number;
redirectUri: string;
scopes: string[];
resource: string | null;
codeChallenge: string;
codeChallengeMethod: 'S256';
expiresAt: number;
@@ -69,7 +67,6 @@ interface OAuthTokenRow {
access_token_hash: string;
refresh_token_hash: string;
scopes: string; // JSON array
audience: string | null;
access_token_expires_at: string;
refresh_token_expires_at: string;
revoked_at: string | null;
@@ -246,7 +243,6 @@ export function createAuthCode(params: {
userId: number;
redirectUri: string;
scopes: string[];
resource: string | null;
codeChallenge: string;
codeChallengeMethod: 'S256';
}): string | null {
@@ -298,7 +294,6 @@ export function issueTokens(
userId: number,
scopes: string[],
parentTokenId: number | null = null,
audience: string | null = null,
): {
access_token: string;
refresh_token: string;
@@ -317,9 +312,9 @@ export function issueTokens(
db.prepare(`
INSERT INTO oauth_tokens
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, audience, access_token_expires_at, refresh_token_expires_at, parent_token_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(clientId, userId, accessHash, refreshHash, JSON.stringify(scopes), audience, accessExpiry.toISOString(), refreshExpiry.toISOString(), parentTokenId);
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, access_token_expires_at, refresh_token_expires_at, parent_token_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(clientId, userId, accessHash, refreshHash, JSON.stringify(scopes), accessExpiry.toISOString(), refreshExpiry.toISOString(), parentTokenId);
return {
access_token: rawAccess,
@@ -338,13 +333,12 @@ export interface OAuthTokenInfo {
user: User;
scopes: string[];
clientId: string;
audience: string | null;
}
export function getUserByAccessToken(rawToken: string): OAuthTokenInfo | null {
const hash = hashToken(rawToken);
const row = db.prepare(`
SELECT ot.scopes, ot.audience, ot.revoked_at, ot.access_token_expires_at,
SELECT ot.scopes, ot.revoked_at, ot.access_token_expires_at,
ot.user_id, ot.client_id, u.username, u.email, u.role
FROM oauth_tokens ot
JOIN users u ON ot.user_id = u.id
@@ -359,7 +353,6 @@ export function getUserByAccessToken(rawToken: string): OAuthTokenInfo | null {
user: { id: row.user_id, username: row.username, email: row.email, role: row.role as 'admin' | 'user' },
scopes: JSON.parse(row.scopes),
clientId: row.client_id,
audience: row.audience ?? null,
};
}
@@ -413,7 +406,7 @@ export function refreshTokens(
const hash = hashToken(rawRefreshToken);
const row = db.prepare(`
SELECT id, client_id, user_id, scopes, audience, refresh_token_expires_at, revoked_at, parent_token_id
SELECT id, client_id, user_id, scopes, refresh_token_expires_at, revoked_at, parent_token_id
FROM oauth_tokens WHERE refresh_token_hash = ?
`).get(hash) as OAuthTokenRow | undefined;
@@ -449,7 +442,7 @@ export function refreshTokens(
revokeUserSessionsForClient(row.user_id, clientId);
const tokens = issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null);
const tokens = issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id);
writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip });
return { tokens };
@@ -529,7 +522,6 @@ export interface AuthorizeParams {
state?: string;
code_challenge: string;
code_challenge_method: string;
resource?: string;
}
export interface ValidateAuthorizeResult {
@@ -538,7 +530,6 @@ export interface ValidateAuthorizeResult {
error_description?: string;
client?: { name: string; allowed_scopes: string[] };
scopes?: string[];
resource?: string | null;
/** true when user is logged in but consent UI must be shown */
consentRequired?: boolean;
/** true when the request is valid but user is not authenticated */
@@ -582,13 +573,6 @@ export function validateAuthorizeRequest(
return { valid: false, error: 'invalid_redirect_uri', error_description: 'redirect_uri does not match any registered URI' };
}
// RFC 8707 resource indicator: if provided, must identify the TREK MCP endpoint exactly
const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
const resource = params.resource ? params.resource.replace(/\/+$/, '') : null;
if (resource !== null && resource !== mcpResource) {
return { valid: false, error: 'invalid_target', error_description: 'Requested resource must be the TREK MCP endpoint' };
}
const requestedScopes = (params.scope || '').split(' ').filter(Boolean);
if (requestedScopes.length === 0) {
return { valid: false, error: 'invalid_scope', error_description: 'At least one scope is required' };
@@ -615,7 +599,6 @@ export function validateAuthorizeRequest(
valid: true,
client: { name: client.name, allowed_scopes: allowedScopes },
scopes: grantedScopes,
resource: resource ?? mcpResource,
consentRequired,
scopeSelectable: client.created_via === 'dcr',
};
+2 -17
View File
@@ -94,22 +94,8 @@ describe('Photo endpoint auth', () => {
});
describe('Force HTTPS redirect', () => {
it('MISC-004 — FORCE_HTTPS redirect sends 301 for HTTP requests on non-health paths', async () => {
it('MISC-004 — FORCE_HTTPS redirect sends 301 for HTTP requests', 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 {
@@ -120,8 +106,7 @@ describe('Force HTTPS redirect', () => {
const res = await request(httpsApp)
.get('/api/health')
.set('X-Forwarded-Proto', 'http');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
expect(res.status).toBe(301);
});
it('MISC-004 — no redirect when FORCE_HTTPS is not set', async () => {

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