Compare commits

..

1 Commits

Author SHA1 Message Date
Maurice 73ce54eac5 Merge 5e9c8d2c43 into 39f13881c5 2026-04-17 19:03:28 +02:00
418 changed files with 3902 additions and 48189 deletions
-4
View File
@@ -30,7 +30,3 @@ sonar-project.properties
server/tests/ server/tests/
server/vitest.config.ts server/vitest.config.ts
server/reset-admin.js server/reset-admin.js
**/*.test.ts
wiki/
scripts/
charts/
+2
View File
@@ -1,5 +1,6 @@
# Normalize line endings to LF on commit # Normalize line endings to LF on commit
* text=auto eol=lf * text=auto eol=lf
# Explicitly enforce LF for source files # Explicitly enforce LF for source files
*.ts text eol=lf *.ts text eol=lf
*.tsx text eol=lf *.tsx text eol=lf
@@ -13,6 +14,7 @@
*.yaml text eol=lf *.yaml text eol=lf
*.py text eol=lf *.py text eol=lf
*.sh text eol=lf *.sh text eol=lf
# Binary files — no line ending conversion # Binary files — no line ending conversion
*.png binary *.png binary
*.jpg binary *.jpg binary
-3
View File
@@ -12,8 +12,6 @@ body:
required: true required: true
- label: I am running the latest available version of TREK - label: I am running the latest available version of TREK
required: true required: true
- label: I have read the [Troubleshooting guide](https://github.com/mauriceboe/TREK/wiki/Troubleshooting) and my issue is not covered there
required: true
- type: input - type: input
id: version id: version
@@ -62,7 +60,6 @@ body:
- Docker (standalone) - Docker (standalone)
- Kubernetes / Helm - Kubernetes / Helm
- Unraid template - Unraid template
- Proxmox Community Script
- Sources - Sources
- Other - Other
validations: validations:
@@ -26,9 +26,6 @@ jobs:
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const pull of pulls) { for (const pull of pulls) {
const hasBypass = pull.labels.some(l => l.name === 'bypass-branch-check');
if (hasBypass) continue;
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch'); const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
if (!hasLabel) continue; if (!hasLabel) continue;
-5
View File
@@ -6,11 +6,6 @@ on:
paths-ignore: paths-ignore:
- 'docs/**' - 'docs/**'
- '**/*.md' - '**/*.md'
- 'wiki/**'
- '.github/workflows/**'
- '.github/ISSUE_TEMPLATE/**'
- '.github/FUNDING.yml'
- '.github/PULL_REQUEST_TEMPLATE.md'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
bump: bump:
@@ -21,12 +21,6 @@ jobs:
const labels = context.payload.pull_request.labels.map(l => l.name); const labels = context.payload.pull_request.labels.map(l => l.name);
const prNumber = context.payload.pull_request.number; const prNumber = context.payload.pull_request.number;
// bypass-branch-check label skips all enforcement
if (labels.includes('bypass-branch-check')) {
console.log('bypass-branch-check label present, skipping enforcement.');
return;
}
// If the base was fixed, remove the label and let it through // If the base was fixed, remove the label and let it through
if (base !== 'main') { if (base !== 'main') {
if (labels.includes('wrong-base-branch')) { if (labels.includes('wrong-base-branch')) {
-26
View File
@@ -1,26 +0,0 @@
name: Deploy Wiki
on:
push:
branches: [main]
paths:
- 'wiki/**'
- '.github/workflows/wiki.yml'
workflow_dispatch:
permissions:
contents: write
concurrency:
group: wiki-deploy
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish to GitHub wiki
uses: Andrew-Chen-Wang/github-wiki-action@v5
with:
strategy: clone
+2 -4
View File
@@ -17,6 +17,7 @@ client/public/icons/*.png
# User data # User data
server/data/* server/data/*
!server/data/airports.json
server/uploads/ server/uploads/
# Environment # Environment
@@ -58,7 +59,4 @@ coverage
*.tgz *.tgz
.scannerwork .scannerwork
test-data test-data
.run
.full-review
+1 -1
View File
@@ -4,7 +4,7 @@ Thanks for your interest in contributing! Please read these guidelines before op
## Ground Rules ## Ground Rules
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed 1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/P7TUxHJs). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors 2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
3. **No breaking changes** — Backwards compatibility is non-negotiable 3. **No breaking changes** — Backwards compatibility is non-negotiable
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main` 4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
+24 -103
View File
@@ -16,7 +16,6 @@ structured API.
- [Limitations & Important Notes](#limitations--important-notes) - [Limitations & Important Notes](#limitations--important-notes)
- [Resources (read-only)](#resources-read-only) - [Resources (read-only)](#resources-read-only)
- [Tools (read-write)](#tools-read-write) - [Tools (read-write)](#tools-read-write)
- [Compound Tools](#compound-tools)
- [Prompts](#prompts) - [Prompts](#prompts)
- [Example](#example) - [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). > 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:** **What happens automatically:**
1. The client fetches `/.well-known/oauth-protected-resource` (RFC 9728) to discover the authorization server and bind the `/mcp` endpoint. 1. The client fetches `/.well-known/oauth-authorization-server` to discover the TREK authorization server.
2. The client fetches `/.well-known/oauth-authorization-server` for the full AS metadata. 2. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
3. 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. 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.
5. The client receives a short-lived access token audience-bound to `/mcp` (RFC 8707) 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 > **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
> discovery to work correctly. > discovery to work correctly.
@@ -142,17 +140,13 @@ that match your granted scopes for that session.
| `vacay:write` | Manage vacation plans | Vacation | | `vacay:write` | Manage vacation plans | Vacation |
| `geo:read` | Maps & geocoding | Geo | | `geo:read` | Maps & geocoding | Geo |
| `weather:read` | Weather forecasts | Weather | | `weather:read` | Weather forecasts | Weather |
| `journey:read` | View journeys | Journey |
| `journey:write` | Manage journeys | Journey |
| `journey:share` | Manage journey share links | Journey |
**Scope rules:** **Scope rules:**
- A `:write` scope implies `:read` access for the same group (e.g. `budget:write` also grants budget read access). - 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 `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. - `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). - 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. | | **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. | | **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. | | **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 | | Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
| Members | `trek://trips/{tripId}/members` | Owner and collaborators | | Members | `trek://trips/{tripId}/members` | Owner and collaborators |
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes | | 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 | | To-Dos | `trek://trips/{tripId}/todos` | To-do items ordered by position |
| Categories | `trek://categories` | Available place categories (for use when creating places) | | Categories | `trek://categories` | Available place categories (for use when creating places) |
| Bucket List | `trek://bucket-list` | Your personal travel bucket list | | 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 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 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 | | 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 | | 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. | | `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. |
### Compound Tools
Compound tools collapse common multi-step workflows into a single atomic call. Each one wraps two sequential operations in a database transaction — if the second step fails, the first is rolled back automatically.
> **When to use:** Only use compound tools when the place or item does not yet exist. If it already exists, call the individual tools (`assign_place_to_day`, `create_accommodation`, `set_budget_item_members`) directly.
| Tool | Wraps | Description |
|---|---|---|
| `create_and_assign_place` | `create_place` + `assign_place_to_day` | Create a new place and immediately assign it to a specific day. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `dayId` and optional `assignment_notes`. Returns `{ place, assignment }`. |
| `create_place_accommodation` | `create_place` + `create_accommodation` | Create a new place and immediately book it as an accommodation for a date range. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `start_day_id`, `end_day_id`, `check_in`, `check_out`, `confirmation`, and `accommodation_notes`. Also auto-creates a linked hotel reservation. Returns `{ place, accommodation }`. |
| `create_budget_item_with_members` | `create_budget_item` + `set_budget_item_members` | Create a budget item and optionally set which members are splitting it. Accepts all `create_budget_item` fields plus an optional `userIds` array. If `userIds` is omitted or empty, behaves identically to `create_budget_item`. Returns `{ item }` with members populated. |
**Scope requirements** match the underlying tools: `places:write` for `create_and_assign_place`, `trips:write` for `create_place_accommodation`, `budget:write` for `create_budget_item_with_members` (Budget addon required).
---
### Trips ### Trips
@@ -272,18 +247,14 @@ Compound tools collapse common multi-step workflows into a single atomic call. E
### Places ### Places
> To create a place and assign it to a day in one call, use [`create_and_assign_place`](#compound-tools).
| Tool | Description | | Tool | Description |
|------------------|--------------------------------------------------------------------------------------------------| |------------------|--------------------------------------------------------------------------------------------------|
| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. | | `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. | | `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. | | `update_place` | Update any field of an existing place including transport mode, timing, and price. |
| `delete_place` | Remove a place from a trip. | | `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.** | | `list_categories`| List all available place categories with id, name, icon and color. |
| `import_places_from_url` | Import all places from a publicly shared Google Maps or Naver Maps list URL. | | `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_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 ### Day Planning
@@ -302,40 +273,24 @@ Compound tools collapse common multi-step workflows into a single atomic call. E
### Accommodations ### Accommodations
> To create a place and book it as an accommodation in one call, use [`create_place_accommodation`](#compound-tools).
| Tool | Description | | Tool | Description |
|------------------------|------------------------------------------------------------------------------------------| |------------------------|------------------------------------------------------------------------------------------|
| `create_accommodation` | Add an accommodation (hotel, Airbnb, etc.) linked to a place and a check-in/out date range. | | `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). | | `update_accommodation` | Update fields on an existing accommodation (dates, times, confirmation, notes). |
| `delete_accommodation` | Delete an accommodation record from a trip. | | `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 ### Reservations
For flights, trains, cars, and cruises, use the **Transport** tools above. Reservations cover all other booking types. | Tool | Description |
|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 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`). |
| `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. | | `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). | | `reorder_reservations` | Update the display order of reservations within a day. |
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. | | `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
| `reorder_reservations` | Update the display order of reservations (and transports) within a day. |
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
### Budget ### Budget
> To create a budget item and set its members in one call, use [`create_budget_item_with_members`](#compound-tools).
| Tool | Description | | Tool | Description |
|----------------------------|---------------------------------------------------------------------------------------| |----------------------------|---------------------------------------------------------------------------------------|
| `create_budget_item` | Add an expense with name, category, and price. | | `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_weather` | Get weather forecast for a location and date. |
| `get_detailed_weather`| Get hourly/detailed weather forecast for a location and date. | | `get_detailed_weather`| Get hourly/detailed weather forecast for a location and date. |
### Airports ### Collab Notes
| Tool | Description |
|-------------------|-------------------------------------------------------------------------------------------------------------------|
| `search_airports` | Search for airports by name, city, or IATA code. Returns IATA code, name, city, country, coordinates, timezone. |
| `get_airport` | Look up a single airport by IATA code (e.g. `"ZRH"`, `"AMS"`, `"CDG"`). |
### Collab Notes _(Collab addon required)_
| Tool | Description | | 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). | | `delete_collab_message`| Delete a chat message (own messages only). |
| `react_collab_message`| Toggle a reaction emoji on a chat message. | | `react_collab_message`| Toggle a reaction emoji on a chat message. |
### Bucket List _(Atlas addon required)_ ### Bucket List
| Tool | Description | | Tool | Description |
|---------------------------|--------------------------------------------------------------------------------------------| |---------------------------|--------------------------------------------------------------------------------------------|
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. | | `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. | | `delete_bucket_list_item` | Remove an item from your bucket list. |
### Atlas _(Atlas addon required)_ ### Atlas
| Tool | Description | | 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_holiday_countries` | List countries available for public holiday calendars. |
| `list_holidays` | List public holidays for a country and year. | | `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 ## Prompts
+216 -289
View File
@@ -1,174 +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> <p align="center">
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.gif" /> <a href="https://discord.gg/NhZBDSd4qW"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" /> <a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" /> <a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
</picture> <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>
<br /> <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>
<picture> ![TREK Screenshot](docs/screenshot.png)
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" /> ![TREK Screenshot 2](docs/screenshot-2.png)
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
</picture>
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="Demo" src="https://img.shields.io/badge/Demo-try-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" /></a>
&nbsp;
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
&nbsp;
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
<br />
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
&nbsp;
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></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/trek-media/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>
<details> <details>
<summary><b>See all features</b></summary> <summary>More Screenshots</summary>
<table> | | |
<tr> |---|---|
<td width="50%" valign="top"> | ![Plan Detail](docs/screenshot-plan-detail.png) | ![Bookings](docs/screenshot-bookings.png) |
| ![Budget](docs/screenshot-budget.png) | ![Packing List](docs/screenshot-packing.png) |
#### 🧭 Trip planning | ![Collab](docs/screenshot-collab.png) | |
- **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)
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
- **Budget** — expense tracker with splits, pie chart, multi-currency
- **Documents** — file attachments on trips, places, and reservations
- **Collab** — chat, notes, polls, day-by-day attendance
- **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
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
- **Naver List Import** — one-click import from shared Naver Maps lists
- **MCP** — expose TREK to AI assistants via OAuth 2.1
</td>
<td width="50%" valign="top">
#### 🤖 AI / MCP
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
- **Granular scopes** — 27 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
- **15 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>
</details> </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 ```bash
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
@@ -176,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 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
``` ```
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`). 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> 1. Open your TREK instance in the browser (HTTPS required)
2. **iOS**: Share button → "Add to Home Screen"
<br /> 3. **Android**: Menu → "Install app" or "Add to Home Screen"
4. TREK launches fullscreen with its own icon, just like a native app
## 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>
<details> <details>
<summary>Full compose example with secure defaults</summary> <summary>Docker Compose (recommended for production)</summary>
```yaml ```yaml
services: services:
@@ -232,19 +158,30 @@ services:
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=3000 - PORT=3000
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # generate with: openssl rand -hex 32 - 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} - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
- LOG_LEVEL=${LOG_LEVEL:-info} - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # - 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
- APP_URL=${APP_URL:-} # required for OIDC + email links - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
# - FORCE_HTTPS=true # behind a TLS-terminating proxy # - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
# - TRUST_PROXY=1 # - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
# - OIDC_ISSUER=https://auth.example.com # - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
# - OIDC_CLIENT_ID=trek # - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
# - OIDC_CLIENT_SECRET=supersecret - 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_DISPLAY_NAME=SSO # - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
# - OIDC_ADMIN_CLAIM=groups # - OIDC_CLIENT_ID=trek # OpenID Connect client ID
# - OIDC_ADMIN_VALUE=app-trek-admins # - 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: volumes:
- ./data:/app/data - ./data:/app/data
- ./uploads:/app/uploads - ./uploads:/app/uploads
@@ -257,49 +194,29 @@ services:
start_period: 15s 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 ```bash
docker compose up -d 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> </details>
<br /> ### Updating
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2> **Docker Compose** (recommended):
```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:**
```bash ```bash
docker compose pull && docker compose up -d 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 ```bash
docker pull mauriceboe/trek docker pull mauriceboe/trek
@@ -307,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 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 ```bash
docker exec -it trek node --import tsx scripts/migrate-encryption.ts 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> <details>
<summary>Nginx</summary> <summary>Nginx</summary>
@@ -339,20 +260,8 @@ server {
listen 443 ssl http2; listen 443 ssl http2;
server_name trek.yourdomain.com; server_name trek.yourdomain.com;
ssl_certificate /etc/ssl/fullchain.pem; ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /etc/ssl/privkey.pem; ssl_certificate_key /path/to/privkey.pem;
# 500 MB covers backup-restore uploads (capped at 500 MB server-side).
client_max_body_size 500m;
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;
}
location /ws { location /ws {
proxy_pass http://localhost:3000; proxy_pass http://localhost:3000;
@@ -360,7 +269,21 @@ server {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
proxy_set_header Host $host; 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; 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;
} }
} }
``` ```
@@ -370,24 +293,17 @@ server {
<details> <details>
<summary>Caddy</summary> <summary>Caddy</summary>
```caddy Caddy handles WebSocket upgrades automatically:
```
trek.yourdomain.com { trek.yourdomain.com {
reverse_proxy localhost:3000 reverse_proxy localhost:3000
} }
``` ```
Caddy handles TLS and WebSockets automatically.
</details> </details>
<br /> ## Environment Variables
## Environment variables
<details>
<summary><b>Full reference</b></summary>
<br />
| Variable | Description | Default | | Variable | Description | Default |
|----------|-------------|---------| |----------|-------------|---------|
@@ -397,47 +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 | | `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` | | `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` | | `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 | | `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` | | `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` |
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `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) |
| `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`. 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) |
| `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 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` |
| `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 — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — |
| `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. | — |
| **OIDC / SSO** | | | | **OIDC / SSO** | | |
| `OIDC_ISSUER` | OpenID Connect provider URL | — | | `OIDC_ISSUER` | OpenID Connect provider URL | — |
| `OIDC_CLIENT_ID` | OIDC client ID | — | | `OIDC_CLIENT_ID` | OIDC client ID | — |
| `OIDC_CLIENT_SECRET` | OIDC client secret | — | | `OIDC_CLIENT_SECRET` | OIDC client secret | — |
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` | | `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_CLAIM` | OIDC claim used to identify admin users | — |
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — | | `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_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 (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — | | `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** | | | | **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_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 on initial boot. Pairs with `ADMIN_EMAIL`. | random | | `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random |
| **Other** | | | | **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` | | `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` | | `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` | | `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 ## Data & Backups
- **Database** SQLite, stored in `./data/travel.db` - **Database**: SQLite, stored in `./data/travel.db`
- **Uploads** — stored in `./uploads/` - **Uploads**: Stored in `./uploads/`
- **Logs** `./data/logs/trek.log` (auto-rotated) - **Logs**: `./data/logs/trek.log` (auto-rotated)
- **Backups** — create and restore via Admin Panel - **Backups**: Create and restore via Admin Panel
- **Auto-Backups** — configurable schedule and retention in Admin Panel - **Auto-Backups**: Configurable schedule and retention in Admin Panel
<br />
## License ## 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)
-121
View File
@@ -1,121 +0,0 @@
# Trademark Policy
## Introduction
This is the TREK project's policy for the use of our trademarks. While TREK is
available under the GNU Affero General Public License v3.0 (AGPL-3.0), that
license does not include a license to use our trademarks.
This policy describes how you may use our trademarks. Our goal is to strike a
balance between: 1) our need to ensure that our trademarks remain reliable
indicators of the software we release; and 2) our community members' desire to
be full participants in the TREK project.
## Our trademarks
This policy covers the name "TREK" as well as any associated logos, trade dress,
goodwill, or designs (our "Marks").
## In general
Whenever you use our Marks, you must always do so in a way that does not mislead
anyone about exactly who is the source of the software. For example, you cannot
say you are distributing TREK when you're distributing a modified version of it,
because people would think they would be getting the same software that they
can get directly from us when they aren't. You also cannot use our Marks on
your website in a way that suggests that your website is an official TREK
website or that we endorse your website. But, if true, you can say you like
TREK, that you participate in the TREK community, that you are providing an
unmodified version of TREK, or that you wrote a guide describing how to use
TREK.
This fundamental requirement, that it is always clear to people what they are
getting and from whom, is reflected throughout this policy. It should also
serve as your guide if you are not sure about how you are using the Marks.
In addition:
* You may not use or register, in whole or in part, the Marks as part of your
own trademark, service mark, domain name, company name, trade name, product
name or service name.
* Trademark law does not allow your use of names or trademarks that are too
similar to ours. You therefore may not use an obvious variation of any of our
Marks or any phonetic equivalent, foreign language equivalent, takeoff, or
abbreviation for a similar or compatible product or service.
* You agree that you will not acquire any rights in the Marks and that any
goodwill generated by your use of the Marks and participation in our
community inures solely to our benefit.
## Distribution of unmodified source code or unmodified executable code we have compiled
When you redistribute an unmodified copy of TREK, you are not changing the
quality or nature of it. Therefore, you may retain the Marks we have placed on
the software to identify your redistribution. This kind of use only applies if
you are redistributing an official TREK distribution that has not been changed
in any way.
## Distribution of executable code that you have compiled, or modified code
You may use the word mark "TREK", but not any TREK logos, to truthfully
describe the origin of the software that you are providing, that is, that the
code you are distributing is a modification of TREK. You may say, for example,
that "this software is derived from the source code for TREK."
Of course, you can place your own trademarks or logos on versions of the
software to which you have made substantive modifications, because by modifying
the software, you have become the origin of that exact version. In that case,
you should not use our Marks.
However, you may use our Marks for the distribution of code (source or
executable) on the condition that any executable is built from an official TREK
source code release and that any modifications are limited to switching on or
off features already included in the software, translations into other
languages, and incorporating minor bug-fix patches. Use of our Marks on any
further modification is not permitted.
## Mobile wrappers, hosted instances, and forks
The following clarifications apply specifically to common ways TREK is
redistributed:
* **Self-hosted instances of unmodified TREK.** You may refer to your instance
as "a TREK instance" or "running TREK." You may not name the service itself
in a way that suggests it is the official TREK ("TREK Cloud," "TREK
Official," etc.).
* **Mobile wrappers (WebView shells, Capacitor apps, native apps) pointing at
TREK.** You may describe your app as "a mobile client for TREK" or "for use
with TREK." You may not publish it on app stores under the name "TREK" or a
confusingly similar name, and you may not use the TREK logo as the app icon
unless your wrapper distributes only an unmodified, official TREK instance
and you have obtained permission.
* **Forks of the TREK source code.** Forks that diverge from upstream must use
a different name. You may state that your fork is "based on TREK" or "a fork
of TREK," but the project name itself must be your own.
## Statements about your software's relation to TREK
You may use the word mark, but not TREK logos, to truthfully describe the
relationship between your software and ours. The word mark "TREK" should be
used after a verb or preposition that describes the relationship between your
software and ours. So you may say, for example, "Bob's app for TREK" but may
not say "Bob's TREK app." Some other examples that may work for you are:
* [Your software] uses TREK
* [Your software] is powered by TREK
* [Your software] runs on TREK
* [Your software] for use with TREK
* [Your software] for TREK
## Questions and permission requests
If you are not sure whether your intended use of the Marks is permitted under
this policy, or if you would like to request explicit permission for a use that
is not covered, please open an issue on the TREK GitHub repository or contact
the maintainers directly.
---
These guidelines are based on the
[Model Trademark Guidelines](http://www.modeltrademarkguidelines.org), used
under a
[Creative Commons Attribution 3.0 Unported license](https://creativecommons.org/licenses/by/3.0/deed.en_US).
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 3.0.13 version: 2.9.14
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "3.0.13" appVersion: "2.9.14"
-3
View File
@@ -22,9 +22,6 @@ data:
{{- if .Values.env.FORCE_HTTPS }} {{- if .Values.env.FORCE_HTTPS }}
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }} FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
{{- end }} {{- end }}
{{- if .Values.env.HSTS_INCLUDE_SUBDOMAINS }}
HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | quote }}
{{- end }}
{{- if .Values.env.COOKIE_SECURE }} {{- if .Values.env.COOKIE_SECURE }}
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }} COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
{{- end }} {{- end }}
-2
View File
@@ -30,8 +30,6 @@ env:
# Also used as the base URL for links in email notifications and other external links. # Also used as the base URL for links in email notifications and other external links.
# FORCE_HTTPS: "false" # FORCE_HTTPS: "false"
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY. # Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
# HSTS_INCLUDE_SUBDOMAINS: "false"
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
# COOKIE_SECURE: "true" # COOKIE_SECURE: "true"
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production. # Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
# TRUST_PROXY: "1" # TRUST_PROXY: "1"
+81 -228
View File
@@ -1,19 +1,18 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "3.0.13", "version": "2.9.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-client", "name": "trek-client",
"version": "3.0.13", "version": "2.9.14",
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7", "axios": "^1.6.7",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
"marked": "^18.0.0", "marked": "^18.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@@ -2368,6 +2367,9 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2385,6 +2387,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2402,6 +2407,9 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2419,6 +2427,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2436,6 +2447,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2453,6 +2467,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2470,6 +2487,9 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2493,6 +2513,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2516,6 +2539,9 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2539,6 +2565,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2562,6 +2591,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2585,6 +2617,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2868,41 +2903,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@mswjs/interceptors": {
"version": "0.41.3", "version": "0.41.3",
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz",
@@ -3399,6 +3399,9 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3413,6 +3416,9 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3427,6 +3433,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3441,6 +3450,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3455,6 +3467,9 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3469,6 +3484,9 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3483,6 +3501,9 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3497,6 +3518,9 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3511,6 +3535,9 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3525,6 +3552,9 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3539,6 +3569,9 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3553,6 +3586,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3567,6 +3603,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3878,17 +3917,9 @@
"version": "7946.0.16", "version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT" "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": { "node_modules/@types/hast": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
@@ -3923,12 +3954,6 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT" "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": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -3979,15 +4004,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -4808,12 +4824,6 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/check-error": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
@@ -5131,12 +5141,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -5406,12 +5410,6 @@
"node": ">= 0.4" "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": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -6059,12 +6057,6 @@
"node": ">=6.9.0" "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": { "node_modules/get-caller-file": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -6137,12 +6129,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/glob": {
"version": "10.5.0", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@@ -6257,12 +6243,6 @@
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" "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": { "node_modules/has-bigints": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -7288,12 +7268,6 @@
"node": ">=0.10.0" "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": { "node_modules/leaflet": {
"version": "1.9.4", "version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
@@ -7489,44 +7463,6 @@
"node": ">=10" "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": { "node_modules/markdown-table": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -7549,17 +7485,6 @@
"node": ">= 20" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -8561,12 +8486,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": { "node_modules/mute-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
@@ -8844,18 +8763,6 @@
"node": ">= 14.16" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -8907,9 +8814,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.10", "version": "8.5.9",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -9068,12 +8975,6 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT" "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": { "node_modules/pretty-bytes": {
"version": "6.1.1", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
@@ -9130,12 +9031,6 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/proxy-from-env": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -9185,12 +9080,6 @@
], ],
"license": "MIT" "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": { "node_modules/randombytes": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -9643,15 +9532,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/restructure": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
@@ -9676,12 +9556,6 @@
"node": ">=0.10.0" "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": { "node_modules/rollup": {
"version": "4.60.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
@@ -10197,12 +10071,6 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/stackback": {
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -10554,15 +10422,6 @@
"node": ">=16 || 14 >=14.17" "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": { "node_modules/supports-color": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -10837,12 +10696,6 @@
"node": "^18.0.0 || >=20.0.0" "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": { "node_modules/tinyrainbow": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+1 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "3.0.13", "version": "2.9.14",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -20,7 +20,6 @@
"dexie": "^4.4.2", "dexie": "^4.4.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
"marked": "^18.0.0", "marked": "^18.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
+4 -14
View File
@@ -4,8 +4,6 @@ import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore' import { useSettingsStore } from './store/settingsStore'
import { useAddonStore } from './store/addonStore' import { useAddonStore } from './store/addonStore'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import ForgotPasswordPage from './pages/ForgotPasswordPage'
import ResetPasswordPage from './pages/ResetPasswordPage'
import DashboardPage from './pages/DashboardPage' import DashboardPage from './pages/DashboardPage'
import TripPlannerPage from './pages/TripPlannerPage' import TripPlannerPage from './pages/TripPlannerPage'
import FilesPage from './pages/FilesPage' import FilesPage from './pages/FilesPage'
@@ -58,7 +56,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
} }
if (!isAuthenticated) { if (!isAuthenticated) {
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash) const redirectParam = encodeURIComponent(location.pathname + location.search)
return <Navigate to={`/login?redirect=${redirectParam}`} replace /> return <Navigate to={`/login?redirect=${redirectParam}`} replace />
} }
@@ -102,7 +100,7 @@ function RootRedirect() {
} }
export default function App() { export default function App() {
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore() const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore() const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore() const { loadAddons } = useAddonStore()
@@ -118,7 +116,7 @@ export default function App() {
loadUser() loadUser()
} }
} }
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => { authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
if (config?.demo_mode) setDemoMode(true) if (config?.demo_mode) setDemoMode(true)
if (config?.dev_mode) setDevMode(true) if (config?.dev_mode) setDevMode(true)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease) if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
@@ -127,9 +125,6 @@ export default function App() {
if (config?.timezone) setServerTimezone(config.timezone) if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa) if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled) if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled)
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions) if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
if (config?.version) { if (config?.version) {
@@ -199,10 +194,7 @@ export default function App() {
applyDark(mode === true || mode === 'dark') applyDark(mode === true || mode === 'dark')
}, [settings.dark_mode, isSharedPage]) }, [settings.dark_mode, isSharedPage])
const isAuthPage = location.pathname.startsWith('/login') const isAuthPage = location.pathname.startsWith('/login') || location.pathname.startsWith('/register')
|| location.pathname.startsWith('/register')
|| location.pathname.startsWith('/forgot-password')
|| location.pathname.startsWith('/reset-password')
return ( return (
<TranslationProvider> <TranslationProvider>
@@ -215,8 +207,6 @@ export default function App() {
<Route path="/shared/:token" element={<SharedTripPage />} /> <Route path="/shared/:token" element={<SharedTripPage />} />
<Route path="/public/journey/:token" element={<JourneyPublicPage />} /> <Route path="/public/journey/:token" element={<JourneyPublicPage />} />
<Route path="/register" element={<LoginPage />} /> <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 */} {/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} /> <Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
<Route <Route
+7 -36
View File
@@ -62,20 +62,13 @@ apiClient.interceptors.request.use(
(error) => Promise.reject(error) (error) => Promise.reject(error)
) )
export function isAuthPublicPath(pathname: string): boolean {
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password']
const publicPrefixes = ['/shared/', '/public/']
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
}
// Response interceptor - handle 401, 403 MFA, 429 rate limit // Response interceptor - handle 401, 403 MFA, 429 rate limit
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') { if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
const { pathname } = window.location if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/') && !window.location.pathname.startsWith('/public/')) {
if (!isAuthPublicPath(pathname)) { const currentPath = window.location.pathname + window.location.search
const currentPath = pathname + window.location.search + window.location.hash
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath) window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
} }
} }
@@ -121,8 +114,6 @@ export const authApi = {
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data), validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
travelStats: () => apiClient.get('/auth/travel-stats').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), 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), deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data), demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
mcpTokens: { mcpTokens: {
@@ -199,27 +190,18 @@ export const placesApi = {
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data), update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data), delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data), searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => { importGpx: (tripId: number | string, file: File) => {
const fd = new FormData() const fd = new FormData(); fd.append('file', file)
fd.append('file', file)
if (opts?.waypoints !== undefined) fd.append('importWaypoints', String(opts.waypoints))
if (opts?.routes !== undefined) fd.append('importRoutes', String(opts.routes))
if (opts?.tracks !== undefined) fd.append('importTracks', String(opts.tracks))
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
}, },
importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean }) => { importMapFile: (tripId: number | string, file: File) => {
const fd = new FormData() const fd = new FormData(); fd.append('file', file)
fd.append('file', file)
if (opts?.points !== undefined) fd.append('importPoints', String(opts.points))
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
}, },
importGoogleList: (tripId: number | string, url: string) => importGoogleList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data), apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
importNaverList: (tripId: number | string, url: string) => importNaverList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data), apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) =>
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
} }
export const assignmentsApi = { export const assignmentsApi = {
@@ -290,12 +272,6 @@ export const adminApi = {
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data), checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data), getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data), updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data), getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data), updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data), packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
@@ -352,17 +328,12 @@ export const journeyApi = {
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data), 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), 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), 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 // Photos
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data), linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data),
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data), updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data), deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
+2 -2
View File
@@ -32,8 +32,8 @@ describe('SCOPE_GROUPS', () => {
}) })
describe('ALL_SCOPES', () => { describe('ALL_SCOPES', () => {
it('FE-OAUTH-SCOPES-003: contains exactly 27 scopes', () => { it('FE-OAUTH-SCOPES-003: contains exactly 24 scopes', () => {
expect(ALL_SCOPES).toHaveLength(27) expect(ALL_SCOPES).toHaveLength(24)
}) })
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => { 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' }, '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' }, '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' }, '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) export const ALL_SCOPES = Object.keys(SCOPE_GROUPS)
+6 -6
View File
@@ -130,7 +130,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://ko-fi.com/mauriceboe" href="https://ko-fi.com/mauriceboe"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }} onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -148,7 +148,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://buymeacoffee.com/mauriceboe" href="https://buymeacoffee.com/mauriceboe"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }} onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -166,7 +166,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://discord.gg/NhZBDSd4qW" href="https://discord.gg/NhZBDSd4qW"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }} onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -187,7 +187,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml" href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }} onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -205,7 +205,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests" href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }} onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -223,7 +223,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://github.com/mauriceboe/TREK/wiki" href="https://github.com/mauriceboe/TREK/wiki"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }} onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -107,12 +107,10 @@ export default function PermissionsPanel(): React.ReactElement {
<button <button
onClick={handleReset} onClick={handleReset}
disabled={saving} disabled={saving}
title={t('perm.resetDefaults')} className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
aria-label={t('perm.resetDefaults')}
className="flex items-center justify-center gap-1.5 px-0 sm:px-3 py-1.5 text-sm w-8 sm:w-auto border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
> >
<RotateCcw className="w-3.5 h-3.5" /> <RotateCcw className="w-3.5 h-3.5" />
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span> {t('perm.resetDefaults')}
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
@@ -416,8 +416,8 @@ describe('BudgetPanel', () => {
render(<BudgetPanel tripId={1} />); render(<BudgetPanel tripId={1} />);
await screen.findByText('Flight'); await screen.findByText('Flight');
await screen.findByText('Hotel'); await screen.findByText('Hotel');
// Grand total card shows 300.00 (integer and decimal are rendered in separate spans) // Grand total card shows 300.00
expect(document.body.textContent?.replace(/\s+/g, '')).toMatch(/300[,.]00/); expect(screen.getByText('300.00')).toBeInTheDocument();
}); });
it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => { it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => {
+161 -411
View File
@@ -4,69 +4,7 @@ import DOM from 'react-dom'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, GripVertical, TrendingUp, TrendingDown, PieChart as PieChartIcon } from 'lucide-react' import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, GripVertical } from 'lucide-react'
function useIsDark(): boolean {
const [dark, setDark] = useState<boolean>(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'))
useEffect(() => {
if (typeof document === 'undefined') return
const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark')))
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => mo.disconnect()
}, [])
return dark
}
function widgetTheme(dark: boolean) {
if (dark) return {
bg: 'linear-gradient(180deg, #17171d 0%, #0d0d12 100%)',
border: 'rgba(255,255,255,0.07)',
text: '#ffffff',
sub: 'rgba(255,255,255,0.6)',
faint: 'rgba(255,255,255,0.4)',
track: 'rgba(255,255,255,0.04)',
divider: 'rgba(255,255,255,0.07)',
iconBg: 'rgba(255,255,255,0.08)',
iconBorder: 'rgba(255,255,255,0.12)',
iconColor: 'rgba(255,255,255,0.9)',
centerBg: '#17171d',
flowBg: 'rgba(255,255,255,0.05)',
flowBorder: 'rgba(255,255,255,0.07)',
flowHoverBg: 'rgba(255,255,255,0.08)',
flowHoverBorder: 'rgba(255,255,255,0.12)',
rowHover: 'rgba(255,255,255,0.03)',
shadow: '0 20px 50px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.04)',
donutShadow: 'drop-shadow(0 0 20px rgba(0,0,0,0.3))',
}
return {
bg: 'linear-gradient(180deg, #ffffff 0%, #f9fafb 100%)',
border: 'rgba(15,23,42,0.08)',
text: '#111827',
sub: 'rgba(17,24,39,0.6)',
faint: 'rgba(17,24,39,0.4)',
track: 'rgba(15,23,42,0.05)',
divider: 'rgba(15,23,42,0.08)',
iconBg: 'rgba(15,23,42,0.05)',
iconBorder: 'rgba(15,23,42,0.1)',
iconColor: 'rgba(17,24,39,0.75)',
centerBg: '#ffffff',
flowBg: 'rgba(15,23,42,0.03)',
flowBorder: 'rgba(15,23,42,0.08)',
flowHoverBg: 'rgba(15,23,42,0.06)',
flowHoverBorder: 'rgba(15,23,42,0.14)',
rowHover: 'rgba(15,23,42,0.04)',
shadow: '0 12px 32px rgba(15,23,42,0.08), 0 2px 6px rgba(0,0,0,0.04)',
donutShadow: 'drop-shadow(0 4px 18px rgba(15,23,42,0.12))',
}
}
function hexLighten(hex: string, amount: number): string {
const m = hex.replace('#', '').match(/.{2}/g)
if (!m || m.length !== 3) return hex
const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount))
const [r, g, b] = m.map(x => parseInt(x, 16))
return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}`
}
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { budgetApi } from '../../api/client' import { budgetApi } from '../../api/client'
import { CustomDatePicker } from '../shared/CustomDateTimePicker' import { CustomDatePicker } from '../shared/CustomDateTimePicker'
@@ -423,47 +361,9 @@ interface PerPersonInlineProps {
locale: string locale: string
} }
const SPLIT_COLORS = [ function PerPersonInline({ tripId, budgetItems, currency, locale }: PerPersonInlineProps) {
{ solid: '#6366f1', gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }, const [data, setData] = useState(null)
{ solid: '#ec4899', gradient: 'linear-gradient(135deg, #ec4899, #f43f5e)' }, const fmt = (v) => fmtNum(v, locale, currency)
{ solid: '#10b981', gradient: 'linear-gradient(135deg, #10b981, #22c55e)' },
{ solid: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b, #f97316)' },
{ solid: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4, #3b82f6)' },
{ solid: '#a855f7', gradient: 'linear-gradient(135deg, #a855f7, #d946ef)' },
]
export function splitColorFor(userId: number, order: number) {
return SPLIT_COLORS[order % SPLIT_COLORS.length]
}
function colorForUserId(userId: number) {
return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
}
function RingAvatar({ userId, username, avatarUrl, size = 34, innerBg = '#17171d', textColor = '#fff' }: { userId: number; username?: string; avatarUrl?: string | null; size?: number; innerBg?: string; textColor?: string }) {
const color = colorForUserId(userId)
return (
<div style={{
width: size, height: size, borderRadius: '50%', flexShrink: 0,
padding: 2, background: color.gradient,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{
width: '100%', height: '100%', borderRadius: '50%',
background: innerBg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
fontSize: size < 28 ? 10 : 12, fontWeight: 600, color: textColor,
}}>
{avatarUrl ? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : username?.[0]?.toUpperCase()}
</div>
</div>
)
}
function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, theme }: PerPersonInlineProps & { grandTotal: number; theme: ReturnType<typeof widgetTheme> }) {
const [data, setData] = useState<any[] | null>(null)
const fmt = (v: number) => fmtNum(v, locale, currency)
useEffect(() => { useEffect(() => {
budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {}) budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
@@ -471,38 +371,25 @@ function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, th
if (!data || data.length === 0) return null if (!data || data.length === 0) return null
const people = data.map((p: any) => ({ ...p, color: colorForUserId(p.user_id) }))
return ( return (
<> <div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
{grandTotal > 0 && ( {data.map(person => (
<div style={{ display: 'flex', height: 6, borderRadius: 999, overflow: 'hidden', marginTop: 8, marginBottom: 4, gap: 3 }}> <div key={person.user_id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{people.map(p => ( <div style={{
<div key={p.user_id} style={{ width: 22, height: 22, borderRadius: '50%', background: 'rgba(255,255,255,0.1)',
height: '100%', borderRadius: 999, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, fontWeight: 700,
flex: Math.max(p.total_assigned || 0, 0.01), color: 'rgba(255,255,255,0.7)', overflow: 'hidden', flexShrink: 0,
background: p.color.gradient, }}>
}} /> {person.avatar_url
))} ? <img src={person.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: person.username?.[0]?.toUpperCase()
}
</div>
<span style={{ flex: 1, fontSize: 12, fontWeight: 500, color: 'rgba(255,255,255,0.7)' }}>{person.username}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#fff' }}>{fmt(person.total_assigned)}</span>
</div> </div>
)} ))}
</div>
<div style={{ marginTop: 14, paddingTop: 14, borderTop: `1px solid ${theme.divider}`, display: 'flex', flexDirection: 'column', gap: 2 }}>
{people.map(p => {
const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0
return (
<div key={p.user_id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '6px 0' }}>
<RingAvatar userId={p.user_id} username={p.username} avatarUrl={p.avatar_url} size={34} innerBg={theme.centerBg} textColor={theme.text} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text }}>{p.username}</div>
<div style={{ fontSize: 11, color: theme.faint, marginTop: 1 }}>{percent}%</div>
</div>
<div style={{ fontSize: 13.5, fontWeight: 600, color: theme.text, letterSpacing: '-0.01em' }}>{fmt(p.total_assigned)}</div>
</div>
)
})}
</div>
</>
) )
} }
@@ -529,14 +416,11 @@ function PieChart({ segments, size = 200, totalLabel }: PieChartProps) {
return ( return (
<div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}> <div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
<div <div style={{
className="trek-pie-reveal" width: size, height: size, borderRadius: '50%',
style={{ background: `conic-gradient(${stops})`,
width: size, height: size, borderRadius: '50%', boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
background: `conic-gradient(${stops})`, }} />
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
}}
/>
<div style={{ <div style={{
position: 'absolute', top: '50%', left: '50%', position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
@@ -562,8 +446,6 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore() const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
const can = useCanDo() const can = useCanDo()
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const isDark = useIsDark()
const theme = useMemo(() => widgetTheme(isDark), [isDark])
const [newCategoryName, setNewCategoryName] = useState('') const [newCategoryName, setNewCategoryName] = useState('')
const [editingCat, setEditingCat] = useState(null) // { name, value } const [editingCat, setEditingCat] = useState(null) // { name, value }
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null) const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
@@ -634,7 +516,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
} }
const handleRenameCategory = async (oldName, newName) => { const handleRenameCategory = async (oldName, newName) => {
if (!newName.trim() || newName.trim() === oldName) return if (!newName.trim() || newName.trim() === oldName) return
const items = grouped.get(oldName) || [] const items = grouped[oldName] || []
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
} }
const handleAddCategory = () => { const handleAddCategory = () => {
@@ -707,69 +589,20 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
} }
// ── Main Layout ────────────────────────────────────────────────────────── // ── Main Layout ──────────────────────────────────────────────────────────
const totalBudget = budgetItems.reduce((s, x) => s + (x.total_price || 0), 0)
return ( return (
<div> <div style={{ fontFamily: "'Poppins', -apple-system, BlinkMacSystemFont, system-ui, sans-serif" }}>
<div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4"> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 16px 12px', flexWrap: 'wrap', gap: 8 }}>
<div style={{ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
background: 'var(--bg-tertiary)', borderRadius: 18, <Calculator size={20} color="var(--text-primary)" />
padding: '14px 16px 14px 22px', <h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap',
}}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
{t('budget.title')}
</h2>
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
<div style={{ width: 150 }}>
<CustomSelect
value={currency}
onChange={setCurrency}
disabled={!canEdit}
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
searchable
/>
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 6, width: 260 }}>
<input
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
placeholder={t('budget.categoryName')}
style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
/>
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
title={t('budget.addCategory')}
style={{
appearance: 'none', border: 'none', cursor: newCategoryName.trim() ? 'pointer' : 'default', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
opacity: newCategoryName.trim() ? 1 : 0.4,
transition: 'opacity 0.15s ease',
}}>
<Plus size={14} strokeWidth={2.5} />
</button>
</div>
)}
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
transition: 'opacity 0.15s ease',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
<Download size={14} strokeWidth={2.5} /> CSV
</button>
</div>
</div> </div>
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
<Download size={13} /> CSV
</button>
</div> </div>
<div style={{ display: 'flex', gap: 20, padding: '24px 28px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }} className="max-md:!px-4"> <div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{categoryNames.map((cat, ci) => { {categoryNames.map((cat, ci) => {
const items = grouped.get(cat) || [] const items = grouped.get(cat) || []
@@ -900,30 +733,29 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
}} }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<td style={td}> <td style={{ ...td, display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> {canEdit && (
{canEdit && ( <div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }}
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }} onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }}
onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }} style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}>
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}> <GripVertical size={12} />
<GripVertical size={12} />
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} />
{hasMultipleMembers && (
<div className="sm:hidden" style={{ marginTop: 4 }}>
<BudgetMemberChips
members={item.members || []}
tripMembers={tripMembers}
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
compact={false}
readOnly={!canEdit}
/>
</div>
)}
</div> </div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} />
{/* Mobile: larger chips under name since Persons column is hidden */}
{hasMultipleMembers && (
<div className="sm:hidden" style={{ marginTop: 4 }}>
<BudgetMemberChips
members={item.members || []}
tripMembers={tripMembers}
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
compact={false}
readOnly={!canEdit}
/>
</div>
)}
</div> </div>
</td> </td>
<td style={{ ...td, textAlign: 'center' }}> <td style={{ ...td, textAlign: 'center' }}>
@@ -979,57 +811,61 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
})} })}
</div> </div>
<div className="w-full md:w-[320px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}> <div className="w-full md:w-[240px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
<div style={{ marginBottom: 12 }}>
<CustomSelect
value={currency}
onChange={setCurrency}
disabled={!canEdit}
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
searchable
/>
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
<input
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
placeholder={t('budget.categoryName')}
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
/>
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
<Plus size={16} />
</button>
</div>
)}
<div style={{ <div style={{
background: theme.bg, background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16, borderRadius: 16, padding: '24px 20px', color: '#fff', marginBottom: 16,
border: `1px solid ${theme.border}`, boxShadow: '0 8px 32px rgba(15,23,42,0.18)',
boxShadow: theme.shadow,
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<div style={{ <div style={{ width: 36, height: 36, borderRadius: 10, background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
width: 40, height: 40, borderRadius: 12, <Wallet size={18} color="rgba(255,255,255,0.8)" />
background: theme.iconBg,
border: `1px solid ${theme.iconBorder}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: theme.iconColor, flexShrink: 0,
}}>
<Wallet size={20} strokeWidth={2} />
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div>
<div style={{ fontSize: 11, color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</div> <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</div>
</div> </div>
</div> </div>
<div style={{ fontSize: 22, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
{(() => { {Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
const decimals = currencyDecimals(currency)
const full = Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
const sep = (0.1).toLocaleString(locale).replace(/\d/g, '')
const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, '']
return (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}>
<span style={{ fontSize: 38, fontWeight: 700 }}>{integerPart}</span>
{decimalPart && <span style={{ fontSize: 22, fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>}
<span style={{ fontSize: 22, fontWeight: 500, color: theme.sub, marginLeft: 2 }}>{SYMBOLS[currency] || currency}</span>
</div>
)
})()}
<div style={{ color: theme.faint, fontSize: 12, marginTop: 8, fontWeight: 500, letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 6 }}>
<span>{currency}</span>
</div> </div>
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && ( {hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} grandTotal={grandTotal} theme={theme} /> <PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} />
)} )}
{/* Settlement dropdown inside the total card */} {/* Settlement dropdown inside the total card */}
{hasMultipleMembers && settlement && settlement.flows.length > 0 && ( {hasMultipleMembers && settlement && settlement.flows.length > 0 && (
<div style={{ marginTop: 16, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}> <div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 12 }}>
<button onClick={() => setSettlementOpen(v => !v)} style={{ <button onClick={() => setSettlementOpen(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%', display: 'flex', alignItems: 'center', gap: 6, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
color: theme.sub, fontSize: 11, fontWeight: 600, letterSpacing: 0.5, color: 'rgba(255,255,255,0.6)', fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
}}> }}>
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />} {settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{t('budget.settlement')} {t('budget.settlement')}
@@ -1054,60 +890,53 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
</button> </button>
{settlementOpen && ( {settlementOpen && (
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
{settlement.flows.map((flow, i) => ( {settlement.flows.map((flow, i) => (
<div key={i} style={{ <div key={i} style={{
display: 'flex', alignItems: 'center', gap: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
padding: '12px 14px', borderRadius: 14, padding: '8px 10px', borderRadius: 10,
background: theme.flowBg, background: 'rgba(255,255,255,0.06)',
border: `1px solid ${theme.flowBorder}`, }}>
transition: 'all 0.2s', <ChipWithTooltip label={flow.from.username} avatarUrl={flow.from.avatar_url} size={28} />
}} <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
onMouseEnter={e => { e.currentTarget.style.background = theme.flowHoverBg; e.currentTarget.style.borderColor = theme.flowHoverBorder }} <span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}></span>
onMouseLeave={e => { e.currentTarget.style.background = theme.flowBg; e.currentTarget.style.borderColor = theme.flowBorder }} <span style={{ fontSize: 12, fontWeight: 700, color: '#f87171', whiteSpace: 'nowrap' }}>
>
<RingAvatar userId={flow.from.user_id} username={flow.from.username} avatarUrl={flow.from.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}>
{fmt(flow.amount, currency)} {fmt(flow.amount, currency)}
</span> </span>
<div style={{ width: '100%', height: 2, borderRadius: 2, background: 'linear-gradient(90deg, rgba(239,68,68,0.1), rgba(239,68,68,0.55), rgba(239,68,68,0.3))', position: 'relative' }}> <span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}></span>
<div style={{ position: 'absolute', right: -1, top: '50%', transform: 'translateY(-50%)', width: 0, height: 0, borderLeft: '6px solid rgba(239,68,68,0.55)', borderTop: '4px solid transparent', borderBottom: '4px solid transparent' }} />
</div>
</div> </div>
<RingAvatar userId={flow.to.user_id} username={flow.to.username} avatarUrl={flow.to.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} /> <ChipWithTooltip label={flow.to.username} avatarUrl={flow.to.avatar_url} size={28} />
</div> </div>
))} ))}
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && ( {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
<div style={{ marginTop: 8, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}> <div style={{ marginTop: 4, borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 8 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}> <div style={{ fontSize: 9, fontWeight: 600, color: 'rgba(255,255,255,0.35)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
{t('budget.netBalances')} {t('budget.netBalances')}
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}> {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => (
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => { <div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '2px 0' }}>
const positive = b.balance > 0 <div style={{
const Trend = positive ? TrendingUp : TrendingDown width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
return ( background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center',
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 0' }}> fontSize: 8, fontWeight: 700, color: 'rgba(255,255,255,0.6)', overflow: 'hidden',
<RingAvatar userId={b.user_id} username={b.username} avatarUrl={b.avatar_url} size={26} innerBg={theme.centerBg} textColor={theme.text} /> }}>
<span style={{ flex: 1, fontSize: 13, color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> {b.avatar_url
{b.username} ? <img src={b.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</span> : b.username?.[0]?.toUpperCase()
<span style={{ }
display: 'inline-flex', alignItems: 'center', gap: 4, </div>
padding: '4px 10px', borderRadius: 8, <span style={{ flex: 1, fontSize: 11, color: 'rgba(255,255,255,0.6)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
fontSize: 12, fontWeight: 700, letterSpacing: '-0.01em', {b.username}
background: positive ? 'rgba(16,185,129,0.13)' : 'rgba(239,68,68,0.13)', </span>
color: positive ? '#10b981' : '#ef4444', <span style={{
}}> fontSize: 11, fontWeight: 600, flexShrink: 0,
<Trend size={11} strokeWidth={3} /> color: b.balance > 0 ? '#4ade80' : '#f87171',
{positive ? '+' : ''}{fmt(b.balance, currency)} }}>
</span> {b.balance > 0 ? '+' : ''}{fmt(b.balance, currency)}
</div> </span>
) </div>
})} ))}
</div>
</div> </div>
)} )}
</div> </div>
@@ -1116,115 +945,36 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
)} )}
</div> </div>
{pieSegments.length > 0 && (() => { {pieSegments.length > 0 && (
const decimals = currencyDecimals(currency) <div style={{
const total = pieSegments.reduce((s, x) => s + x.value, 0) background: 'var(--bg-card)', borderRadius: 16, padding: '20px 16px',
const totalFmt = Number(total).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) border: '1px solid var(--border-primary)',
const decimalSep = (0.1).toLocaleString(locale).replace(/\d/g, '') boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
const [totalInt, totalDec] = decimals > 0 ? totalFmt.split(decimalSep) : [totalFmt, ''] marginBottom: 16,
const R = 80 }}>
const CIRC = 2 * Math.PI * R <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 16, textAlign: 'center' }}>{t('budget.byCategory')}</div>
let dashOffset = 0
return (
<div style={{
background: theme.bg,
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
border: `1px solid ${theme.border}`,
boxShadow: theme.shadow,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
<div style={{
width: 38, height: 38, borderRadius: 11,
background: theme.iconBg,
border: `1px solid ${theme.iconBorder}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: theme.iconColor, flexShrink: 0,
}}>
<PieChartIcon size={18} strokeWidth={2} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div>
</div>
</div>
<div style={{ position: 'relative', display: 'flex', justifyContent: 'center', margin: '4px 0 16px' }}> <PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
<svg width={200} height={200} viewBox="0 0 200 200" style={{ transform: 'rotate(-90deg)', filter: theme.donutShadow }}>
<defs>
{pieSegments.map((seg, i) => {
const c2 = hexLighten(seg.color, 0.2)
return (
<linearGradient key={`grad-${i}`} id={`cat-grad-${i}`} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor={seg.color} />
<stop offset="100%" stopColor={c2} />
</linearGradient>
)
})}
</defs>
<circle cx={100} cy={100} r={R} fill="none" stroke={theme.track} strokeWidth={22} />
{pieSegments.map((seg, i) => {
const segLen = total > 0 ? (seg.value / total) * CIRC : 0
const circle = (
<circle key={i}
cx={100} cy={100} r={R}
fill="none" strokeLinecap="round" strokeWidth={22}
stroke={`url(#cat-grad-${i})`}
strokeDasharray={`${segLen} ${CIRC}`}
strokeDashoffset={-dashOffset}
/>
)
dashOffset += segLen
return circle
})}
</svg>
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, pointerEvents: 'none' }}>
<div style={{ fontSize: 10.5, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{t('budget.total')}</div>
<div style={{ fontSize: 22, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
<span>{totalInt}</span>
{totalDec && <span style={{ fontSize: 13, fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
</div>
<div style={{ fontSize: 10.5, color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div>
</div>
</div>
<div style={{ borderTop: `1px solid ${theme.divider}`, paddingTop: 10, display: 'flex', flexDirection: 'column', gap: 2 }}> <div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 0 }}>
{pieSegments.map((seg, i) => { {pieSegments.map((seg, i) => {
const pct = total > 0 ? (seg.value / total) * 100 : 0 const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
const pctLabel = pct.toFixed(1).replace('.', decimalSep) + '%' return (
const c2 = hexLighten(seg.color, 0.2) <div key={seg.name} style={{ padding: '8px 0', borderTop: i > 0 ? '1px solid var(--border-secondary)' : 'none' }}>
const chipColor = isDark ? hexLighten(seg.color, 0.35) : seg.color <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
return ( <div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
<div key={seg.name} style={{ <span style={{ fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>{seg.name}</span>
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 8px', borderRadius: 12,
transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = theme.rowHover}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<div style={{
width: 10, height: 10, borderRadius: 3, flexShrink: 0,
background: `linear-gradient(135deg, ${seg.color}, ${c2})`,
boxShadow: `0 0 12px ${seg.color}80`,
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{seg.name}</div>
<div style={{ fontSize: 11.5, color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div>
</div>
<span style={{
flexShrink: 0,
padding: '4px 9px', borderRadius: 7,
fontSize: 11, fontWeight: 700, letterSpacing: '-0.01em',
background: `${seg.color}26`,
border: `1px solid ${seg.color}40`,
color: chipColor,
}}>{pctLabel}</span>
</div> </div>
) <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 3, paddingLeft: 18 }}>
})} <span style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 500 }}>{fmt(seg.value, currency)}</span>
</div> <span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 600, background: 'var(--bg-secondary)', padding: '1px 6px', borderRadius: 99 }}>{pct}%</span>
</div>
</div>
)
})}
</div> </div>
) </div>
})()} )}
</div> </div>
</div> </div>
+73 -165
View File
@@ -1,7 +1,7 @@
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { useState, useCallback, useRef, useEffect } from 'react' import { useState, useCallback, useRef, useEffect } from 'react'
import { useDropzone } from 'react-dropzone' import { useDropzone } from 'react-dropzone'
import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight, Plane, Train, Car, Ship } from 'lucide-react' import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { filesApi } from '../../api/client' import { filesApi } from '../../api/client'
@@ -10,7 +10,7 @@ import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { getAuthUrl } from '../../api/authUrl' import { getAuthUrl } from '../../api/authUrl'
import { downloadFile, openFile as openFileUrl } from '../../utils/fileDownload' import { downloadFile, openFile } from '../../utils/fileDownload'
function isImage(mimeType) { function isImage(mimeType) {
if (!mimeType) return false if (!mimeType) return false
@@ -113,7 +113,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
</span> </span>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}> <div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button <button
onClick={() => openFileUrl(file.url, file.original_name).catch(() => {})} onClick={() => openFile(file.url).catch(() => {})}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
title={t('files.openTab')}> title={t('files.openTab')}>
<ExternalLink size={16} /> <ExternalLink size={16} />
@@ -236,15 +236,6 @@ function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avatarUrl?:
) )
} }
const TRANSPORT_TYPES = new Set(['flight', 'train', 'car', 'cruise'])
function transportIcon(type: string) {
if (type === 'train') return Train
if (type === 'car') return Car
if (type === 'cruise') return Ship
return Plane
}
interface FileManagerProps { interface FileManagerProps {
files?: TripFile[] files?: TripFile[]
onUpload: (fd: FormData) => Promise<any> onUpload: (fd: FormData) => Promise<any>
@@ -499,9 +490,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} /> <SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
))} ))}
{linkedReservations.map(r => ( {linkedReservations.map(r => (
TRANSPORT_TYPES.has(r.type) <SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
? <SourceBadge key={r.id} icon={transportIcon(r.type)} label={`${t('files.sourceTransport')} · ${r.title || t('files.sourceTransport')}`} />
: <SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
))} ))}
{file.note_id && ( {file.note_id && (
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} /> <SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
@@ -660,17 +649,8 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</div> </div>
{dayGroups.map(({ day, dayPlaces }) => ( {dayGroups.map(({ day, dayPlaces }) => (
<div key={day.id}> <div key={day.id}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
<span>{day.title || t('dayplan.dayN', { n: day.day_number })}</span> {day.title || `${t('dayplan.dayN', { n: day.day_number })}${day.date ? ` · ${day.date}` : ''}`}
{(() => {
const badge = day.date || (day.title ? t('dayplan.dayN', { n: day.day_number }) : null)
return badge ? (
<span style={{
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 999,
}}>{badge}</span>
) : null
})()}
</div> </div>
{dayPlaces.map(placeBtn)} {dayPlaces.map(placeBtn)}
</div> </div>
@@ -684,68 +664,52 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</div> </div>
) )
const bookingReservations = reservations.filter(r => !TRANSPORT_TYPES.has(r.type))
const transportReservations = reservations.filter(r => TRANSPORT_TYPES.has(r.type))
const reservationBtn = (r: Reservation) => {
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
const Icon = TRANSPORT_TYPES.has(r.type) ? transportIcon(r.type) : Ticket
return (
<button key={r.id} onClick={async () => {
if (isLinked) {
if (file.reservation_id === r.id) {
await handleAssign(file.id, { reservation_id: null })
} else {
try {
const linksRes = await filesApi.getLinks(tripId, file.id)
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
if (link) await filesApi.removeLink(tripId, file.id, link.id)
refreshFiles()
} catch {}
}
} else {
if (!file.reservation_id) {
await handleAssign(file.id, { reservation_id: r.id })
} else {
try {
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
refreshFiles()
} catch {}
}
}
}} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
<Icon size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
}
const bookingsSection = reservations.length > 0 && ( const bookingsSection = reservations.length > 0 && (
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{bookingReservations.length > 0 && ( <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
<> {t('files.assignBooking')}
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}> </div>
{t('files.assignBooking')} {reservations.map(r => {
</div> const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
{bookingReservations.map(reservationBtn)} return (
</> <button key={r.id} onClick={async () => {
)} if (isLinked) {
{transportReservations.length > 0 && ( // Unlink: if primary reservation_id, clear it; if via file_links, remove link
<> if (file.reservation_id === r.id) {
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5, marginTop: bookingReservations.length > 0 ? 4 : 0 }}> await handleAssign(file.id, { reservation_id: null })
{t('files.assignTransport')} } else {
</div> try {
{transportReservations.map(reservationBtn)} const linksRes = await filesApi.getLinks(tripId, file.id)
</> const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
)} if (link) await filesApi.removeLink(tripId, file.id, link.id)
refreshFiles()
} catch {}
}
} else {
// Link: if no primary, set it; otherwise use file_links
if (!file.reservation_id) {
await handleAssign(file.id, { reservation_id: r.id })
} else {
try {
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
refreshFiles()
} catch {}
}
}
}} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
})}
</div> </div>
) )
@@ -779,7 +743,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span> <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<button <button
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))} onClick={() => openFile(previewFile.url).catch(() => {})}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'} onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}> onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
@@ -807,7 +771,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
title={previewFile.original_name} title={previewFile.original_name}
> >
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}> <p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<button onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button> <button onClick={() => openFile(previewFile.url).catch(() => {})} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
</p> </p>
</object> </object>
</div> </div>
@@ -815,81 +779,25 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
document.body document.body
)} )}
{/* Toolbar */} {/* Header */}
<div style={{ padding: '24px 28px 0', flexShrink: 0 }} className="max-md:!px-4 max-md:!pt-4"> <div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<div style={{ <div>
background: 'var(--bg-tertiary)', borderRadius: 18, <h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}</h2>
padding: '14px 16px 14px 22px', <p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', {showTrash
}}> ? `${trashFiles.length} ${trashFiles.length === 1 ? 'file' : 'files'}`
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}> : (files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length }))}
{showTrash ? (t('files.trash') || 'Trash') : t('files.title')} </p>
</h2>
{!showTrash && (
<>
<div className="hidden md:block" style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
<div className="hidden md:inline-flex" style={{ gap: 4, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
{[
{ id: 'all', label: t('files.filterAll') },
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star } as const] : []),
{ id: 'pdf', label: t('files.filterPdf') },
{ id: 'image', label: t('files.filterImages') },
{ id: 'doc', label: t('files.filterDocs') },
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
].map(tab => {
const active = filterType === tab.id
const TabIcon = 'icon' in tab ? tab.icon : null
const count = tab.id === 'all' ? files.length
: tab.id === 'starred' ? files.filter(f => f.starred).length
: tab.id === 'pdf' ? files.filter(f => (f.mime_type || '').includes('pdf') || /\.pdf$/i.test(f.original_name)).length
: tab.id === 'image' ? files.filter(f => (f.mime_type || '').startsWith('image/')).length
: tab.id === 'doc' ? files.filter(f => /\.(docx?|xlsx?|txt|csv)$/i.test(f.original_name)).length
: tab.id === 'collab' ? files.filter(f => f.note_id).length
: 0
return (
<button key={tab.id} onClick={() => setFilterType(tab.id)}
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
background: active ? 'var(--bg-card)' : 'transparent',
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
fontWeight: active ? 500 : 400,
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
transition: 'all 0.15s ease',
}}
>
{TabIcon ? <TabIcon size={13} fill={active ? '#facc15' : 'none'} color={active ? '#facc15' : 'currentColor'} /> : null}
{'label' in tab && tab.label}
<span style={{
fontSize: 10, fontWeight: 600,
background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
color: 'var(--text-faint)',
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
}}>{count}</span>
</button>
)
})}
</div>
</>
)}
<button onClick={toggleTrash} style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)',
flexShrink: 0, marginLeft: 'auto',
opacity: showTrash ? 1 : 0.88,
transition: 'opacity 0.15s ease',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '1'}
onMouseLeave={e => e.currentTarget.style.opacity = showTrash ? '1' : '0.88'}
>
<Trash2 size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">{t('files.trash') || 'Trash'}</span>
</button>
</div> </div>
<button onClick={toggleTrash} style={{
padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)',
background: showTrash ? 'var(--accent)' : 'var(--bg-card)',
color: showTrash ? 'var(--accent-text)' : 'var(--text-muted)',
fontSize: 12, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 5,
fontFamily: 'inherit',
}}>
<Trash2 size={13} /> {t('files.trash') || 'Trash'}
</button>
</div> </div>
{showTrash ? ( {showTrash ? (
@@ -927,7 +835,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{can('file_upload', trip) && <div {can('file_upload', trip) && <div
{...getRootProps()} {...getRootProps()}
style={{ style={{
margin: '16px 28px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px', margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s', textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)', borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)', background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
@@ -952,7 +860,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</div>} </div>}
{/* Filter tabs */} {/* Filter tabs */}
<div className="md:!hidden" style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
{[ {[
{ id: 'all', label: t('files.filterAll') }, { id: 'all', label: t('files.filterAll') },
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []), ...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []),
@@ -975,7 +883,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</div> </div>
{/* File list */} {/* File list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 28px 16px' }} className="max-md:!px-4"> <div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
{filteredFiles.length === 0 ? ( {filteredFiles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}> <div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} /> <FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
+27 -37
View File
@@ -9,14 +9,11 @@ export interface MapMarkerItem {
label: string label: string
mood?: string | null mood?: string | null
time: string time: string
dayColor: string
dayLabel: number
} }
export interface JourneyMapHandle { export interface JourneyMapHandle {
highlightMarker: (id: string | null) => void highlightMarker: (id: string | null) => void
focusMarker: (id: string) => void focusMarker: (id: string) => void
invalidateSize: () => void
} }
interface MapEntry { interface MapEntry {
@@ -26,8 +23,6 @@ interface MapEntry {
title?: string | null title?: string | null
mood?: string | null mood?: string | null
entry_date: string entry_date: string
dayColor?: string
dayLabel?: number
} }
interface Props { interface Props {
@@ -53,8 +48,6 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
label: e.title || 'Entry', label: e.title || 'Entry',
mood: e.mood, mood: e.mood,
time: e.entry_date, time: e.entry_date,
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
}) })
} }
} }
@@ -65,19 +58,30 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
const MARKER_W = 28 const MARKER_W = 28
const MARKER_H = 36 const MARKER_H = 36
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string { function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)' // Highlighted: inverted colors for contrast (black on light, white on dark)
const fill = dark
? (highlighted ? '#FAFAFA' : '#A1A1AA')
: (highlighted ? '#18181B' : '#52525B')
const textColor = dark
? (highlighted ? '#18181B' : '#18181B')
: (highlighted ? '#fff' : '#fff')
const stroke = highlighted
? (dark ? '#fff' : '#18181B')
: (dark ? '#3F3F46' : '#fff')
const shadow = highlighted const shadow = highlighted
? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))' ? (dark
? 'filter:drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
: 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))' : 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
const label = String(dayLabel) const label = String(index + 1)
const scale = highlighted ? 1.2 : 1 const scale = highlighted ? 1.2 : 1
return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center"> return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg"> <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="${dayColor}" stroke="${stroke}" stroke-width="1.5"/> <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="${dayColor}"/> <circle cx="14" cy="13" r="8" fill="${fill}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text> <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> </svg>
</div>` </div>`
} }
@@ -110,11 +114,12 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
const marker = markersRef.current.get(prev) const marker = markersRef.current.get(prev)
const item = itemsRef.current.find(i => i.id === prev) const item = itemsRef.current.find(i => i.id === prev)
if (marker && item) { if (marker && item) {
const idx = itemsRef.current.indexOf(item)
marker.setIcon(L.divIcon({ marker.setIcon(L.divIcon({
className: '', className: '',
iconSize: [MARKER_W, MARKER_H], iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H], iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, false), html: markerSvg(idx, false, isDark),
})) }))
marker.setZIndexOffset(0) marker.setZIndexOffset(0)
} }
@@ -124,11 +129,12 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
const marker = markersRef.current.get(id) const marker = markersRef.current.get(id)
const item = itemsRef.current.find(i => i.id === id) const item = itemsRef.current.find(i => i.id === id)
if (marker && item) { if (marker && item) {
const idx = itemsRef.current.indexOf(item)
marker.setIcon(L.divIcon({ marker.setIcon(L.divIcon({
className: '', className: '',
iconSize: [MARKER_W, MARKER_H], iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H], iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, true), html: markerSvg(idx, true, isDark),
})) }))
marker.setZIndexOffset(1000) marker.setZIndexOffset(1000)
} }
@@ -145,11 +151,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
} }
}, []) }, [])
const invalidateSize = useCallback(() => { useImperativeHandle(ref, () => ({ highlightMarker, focusMarker }), [])
try { mapRef.current?.invalidateSize() } catch { /* map not yet initialized */ }
}, [])
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [])
useEffect(() => { useEffect(() => {
if (!containerRef.current) return if (!containerRef.current) return
@@ -176,12 +178,6 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
maxZoom: 18, maxZoom: 18,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
referrerPolicy: 'strict-origin-when-cross-origin', 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) } as any).addTo(map)
const items = buildMarkerItems(entries) const items = buildMarkerItems(entries)
@@ -219,7 +215,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
className: '', className: '',
iconSize: [MARKER_W, MARKER_H], iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H], iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, false), html: markerSvg(i, false, !!dark),
}) })
const marker = L.marker(pos, { icon }).addTo(map) const marker = L.marker(pos, { icon }).addTo(map)
@@ -243,7 +239,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
map.invalidateSize() map.invalidateSize()
if (allCoords.length > 0) { if (allCoords.length > 0) {
const pb = paddingBottom || 50 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 { } else {
map.setView([30, 0], 2) map.setView([30, 0], 2)
} }
@@ -268,14 +264,8 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
const timer = setTimeout(() => { const timer = setTimeout(() => {
highlightMarker(activeMarkerId) highlightMarker(activeMarkerId)
const marker = markersRef.current.get(activeMarkerId) const marker = markersRef.current.get(activeMarkerId)
if (!marker || !mapRef.current) return if (marker && mapRef.current) {
// fitBounds may still be pending when this fires — getZoom() throws mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
// "Set map center and zoom first" until the map has a view. Guard it.
try {
const currentZoom = mapRef.current.getZoom()
mapRef.current.flyTo(marker.getLatLng(), Math.max(currentZoom, 12), { duration: 0.5 })
} catch {
mapRef.current.setView(marker.getLatLng(), 12)
} }
}, 50) }, 50)
return () => clearTimeout(timer) return () => clearTimeout(timer)
@@ -1,57 +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
dayColor?: string
dayLabel?: number
}
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,463 +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
dayColor?: string
dayLabel?: number
}
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
dayColor: string
dayLabel: number
}
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,
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
})
}
}
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(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
const fill = dayColor
const textColor = '#fff'
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
const shadow = highlighted
? 'drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
const scale = highlighted ? 1.2 : 1
const label = String(dayLabel)
// 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="1.5"/>
<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 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(item.dayColor, item.dayLabel, highlighted)
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) => {
const el = markerHtml(item.dayColor, item.dayLabel, false)
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,5 +1,4 @@
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react' import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
import { formatLocationName } from '../../utils/formatters'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
const MOOD_ICONS: Record<string, typeof Smile> = { const MOOD_ICONS: Record<string, typeof Smile> = {
@@ -38,14 +37,13 @@ function stripMarkdown(text: string): string {
interface Props { interface Props {
entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null } entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null }
dayLabel: number index: number
dayColor: string
isActive: boolean isActive: boolean
onClick: () => void onClick: () => void
publicPhotoUrl?: (photoId: number) => string publicPhotoUrl?: (photoId: number) => string
} }
export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, onClick, publicPhotoUrl }: Props) { export default function MobileEntryCard({ entry, index, isActive, onClick, publicPhotoUrl }: Props) {
const hasLocation = !!(entry.location_lat && entry.location_lng) const hasLocation = !!(entry.location_lat && entry.location_lng)
const hasPhotos = entry.photos && entry.photos.length > 0 const hasPhotos = entry.photos && entry.photos.length > 0
const firstPhoto = hasPhotos ? entry.photos![0] : null const firstPhoto = hasPhotos ? entry.photos![0] : null
@@ -100,8 +98,8 @@ export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, o
<div className="flex-1 p-3 flex flex-col min-w-0"> <div className="flex-1 p-3 flex flex-col min-w-0">
{/* Day number + date + mood/weather */} {/* Day number + date + mood/weather */}
<div className="flex items-center gap-1.5 mb-1"> <div className="flex items-center gap-1.5 mb-1">
<span className="w-5 h-5 rounded text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0" style={{ background: dayColor }}> <span className="w-5 h-5 rounded bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] font-bold flex items-center justify-center flex-shrink-0">
{dayLabel} {index + 1}
</span> </span>
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span> <span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
{entry.entry_time && ( {entry.entry_time && (
@@ -143,7 +141,7 @@ export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, o
{hasLocation ? ( {hasLocation ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden"> <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" /> <MapPin size={10} className="flex-shrink-0" />
<span className="truncate">{formatLocationName(entry.location_name) || 'On the map'}</span> <span className="truncate">{entry.location_name || 'On the map'}</span>
</span> </span>
) : ( ) : (
<span className="text-[10px] text-zinc-400 italic">No location</span> <span className="text-[10px] text-zinc-400 italic">No location</span>
@@ -6,7 +6,6 @@ import {
ThumbsUp, ThumbsDown, ChevronDown, ThumbsUp, ThumbsDown, ChevronDown,
} from 'lucide-react' } from 'lucide-react'
import JournalBody from './JournalBody' import JournalBody from './JournalBody'
import { formatLocationName } from '../../utils/formatters'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = { const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
@@ -25,22 +24,19 @@ const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
cold: { icon: Snowflake, label: 'Cold' }, cold: { icon: Snowflake, label: 'Cold' },
} }
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string { function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string {
if (builder) return builder(p.photo_id)
return `/api/photos/${p.photo_id}/${size}` return `/api/photos/${p.photo_id}/${size}`
} }
interface Props { interface Props {
entry: JourneyEntry entry: JourneyEntry
readOnly?: boolean
publicPhotoUrl?: (photoId: number) => string
onClose: () => void onClose: () => void
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
onPhotoClick: (photos: JourneyPhoto[], index: number) => void onPhotoClick: (photos: JourneyPhoto[], index: number) => void
} }
export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClose, onEdit, onDelete, onPhotoClick }: Props) { export default function MobileEntryView({ entry, onClose, onEdit, onDelete, onPhotoClick }: Props) {
const photos = entry.photos || [] const photos = entry.photos || []
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
@@ -61,23 +57,21 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
> >
<X size={20} /> <X size={20} />
</button> </button>
{!readOnly && ( <div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5"> <button
<button onClick={() => { onClose(); onEdit(); }}
onClick={() => { onClose(); onEdit(); }} className="h-8 px-3 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[12px] font-medium flex items-center gap-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
className="h-8 px-3 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[12px] font-medium flex items-center gap-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors" >
> <Pencil size={13} />
<Pencil size={13} /> Edit
Edit </button>
</button> <button
<button onClick={() => { onClose(); onDelete(); }}
onClick={() => { onClose(); onDelete(); }} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 transition-colors"
className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 transition-colors" >
> <Trash2 size={15} />
<Trash2 size={15} /> </button>
</button> </div>
</div>
)}
</div> </div>
{/* Scrollable content */} {/* Scrollable content */}
@@ -87,7 +81,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
{photos.length > 0 && ( {photos.length > 0 && (
<div className="relative"> <div className="relative">
<img <img
src={photoUrl(photos[0], 'original', publicPhotoUrl)} src={photoUrl(photos[0])}
alt="" alt=""
className="w-full max-h-[50vh] object-cover cursor-pointer" className="w-full max-h-[50vh] object-cover cursor-pointer"
onClick={() => onPhotoClick(photos, 0)} onClick={() => onPhotoClick(photos, 0)}
@@ -104,7 +98,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
{photos.map((p, i) => ( {photos.map((p, i) => (
<img <img
key={p.id || i} key={p.id || i}
src={photoUrl(p, 'thumbnail', publicPhotoUrl)} src={photoUrl(p, 'thumbnail')}
alt="" alt=""
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all" className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
onClick={() => onPhotoClick(photos, i)} onClick={() => onPhotoClick(photos, i)}
@@ -133,7 +127,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
<div className="mb-3"> <div className="mb-3">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300"> <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300">
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" /> <MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
{formatLocationName(entry.location_name)} {entry.location_name}
</span> </span>
</div> </div>
)} )}
@@ -1,10 +1,9 @@
import { useRef, useState, useEffect, useCallback, useMemo } from 'react' import { useRef, useState, useEffect, useCallback } from 'react'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import JourneyMap from './JourneyMap' import JourneyMap from './JourneyMap'
import MobileEntryCard from './MobileEntryCard' import MobileEntryCard from './MobileEntryCard'
import type { JourneyMapHandle } from './JourneyMap' import type { JourneyMapHandle } from './JourneyMap'
import type { JourneyEntry } from '../../store/journeyStore' import type { JourneyEntry } from '../../store/journeyStore'
import { DAY_COLORS } from './dayColors'
interface MapEntry { interface MapEntry {
id: string id: string
@@ -24,7 +23,6 @@ interface Props {
onEntryClick: (entry: any) => void onEntryClick: (entry: any) => void
onAddEntry?: () => void onAddEntry?: () => void
publicPhotoUrl?: (photoId: number) => string publicPhotoUrl?: (photoId: number) => string
carouselBottom?: string
} }
export default function MobileMapTimeline({ export default function MobileMapTimeline({
@@ -36,23 +34,12 @@ export default function MobileMapTimeline({
onEntryClick, onEntryClick,
onAddEntry, onAddEntry,
publicPhotoUrl, publicPhotoUrl,
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
}: Props) { }: Props) {
const mapRef = useRef<JourneyMapHandle>(null) const mapRef = useRef<JourneyMapHandle>(null)
const carouselRef = useRef<HTMLDivElement>(null) const carouselRef = useRef<HTMLDivElement>(null)
const [activeIndex, setActiveIndex] = useState(0) const [activeIndex, setActiveIndex] = useState(0)
const entryDayMeta = useMemo(() => {
const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort())]
const counters = new Map<string, number>()
return entries.map((e: any) => {
const dayIdx = uniqueDates.indexOf(e.entry_date)
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
counters.set(e.entry_date, dayLabel)
return { dayLabel, dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length] }
})
}, [entries])
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map()) const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
// Sync map focus when carousel scrolls (with guard for uninitialized map) // Sync map focus when carousel scrolls (with guard for uninitialized map)
const syncMapToCarousel = useCallback((index: number) => { const syncMapToCarousel = useCallback((index: number) => {
const entry = entries[index] const entry = entries[index]
@@ -66,68 +53,41 @@ export default function MobileMapTimeline({
} }
}, [entries, mapEntries]) }, [entries, mapEntries])
// Pick the card that's currently closest to the carousel horizontal center. // IntersectionObserver for instant snap detection
// More stable than IntersectionObserver thresholds when the active card can
// drift toward the viewport edge with proximity snapping.
const pickNearestCard = useCallback(() => {
const el = carouselRef.current
if (!el) return
const containerCenter = el.getBoundingClientRect().left + el.clientWidth / 2
let bestIdx = 0
let bestDist = Infinity
cardRefs.current.forEach((node, idx) => {
const r = node.getBoundingClientRect()
const cardCenter = r.left + r.width / 2
const d = Math.abs(cardCenter - containerCenter)
if (d < bestDist) { bestDist = d; bestIdx = idx }
})
setActiveIndex(prev => {
if (prev !== bestIdx) syncMapToCarousel(bestIdx)
return bestIdx
})
}, [syncMapToCarousel])
// Defer all state updates until scrolling settles — updating activeIndex
// mid-swipe resizes cards (240→320px), causing layout reflow every frame.
useEffect(() => { useEffect(() => {
const el = carouselRef.current const el = carouselRef.current
if (!el || entries.length === 0) return if (!el || entries.length === 0) return
let settleTimer: number | null = null
const onScroll = () => {
if (settleTimer != null) window.clearTimeout(settleTimer)
settleTimer = window.setTimeout(pickNearestCard, 150)
}
el.addEventListener('scroll', onScroll, { passive: true })
return () => {
el.removeEventListener('scroll', onScroll)
if (settleTimer != null) window.clearTimeout(settleTimer)
}
}, [entries.length, pickNearestCard])
// Scroll a given card into the horizontal center of the carousel const observer = new IntersectionObserver(
const scrollCardIntoCenter = useCallback((idx: number) => { (observed) => {
const card = cardRefs.current.get(idx) for (const o of observed) {
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }) if (o.isIntersecting) {
}, []) const idx = Number(o.target.getAttribute('data-idx'))
if (!isNaN(idx)) {
setActiveIndex(idx)
syncMapToCarousel(idx)
}
}
}
},
{ root: el, threshold: 0.6 },
)
cardRefs.current.forEach(node => observer.observe(node))
return () => observer.disconnect()
}, [entries.length, syncMapToCarousel])
// Scroll carousel to entry when map marker is clicked // Scroll carousel to entry when map marker is clicked
const handleMarkerClick = useCallback((id: string) => { const handleMarkerClick = useCallback((id: string) => {
const idx = entries.findIndex((e: any) => String(e.id) === id) const idx = entries.findIndex((e: any) => String(e.id) === id)
if (idx === -1) return if (idx === -1) return
setActiveIndex(idx) setActiveIndex(idx)
scrollCardIntoCenter(idx)
}, [entries, scrollCardIntoCenter])
// Tap on a card: if it's already active, open the edit view; otherwise const el = carouselRef.current
// activate + center it first (don't jump straight into the editor). if (!el) return
const handleCardTap = useCallback((entry: any, idx: number) => { const cardWidth = 272
if (idx === activeIndex) { el.scrollTo({ left: idx * cardWidth, behavior: 'smooth' })
onEntryClick(entry) }, [entries])
} else {
setActiveIndex(idx)
scrollCardIntoCenter(idx)
}
}, [activeIndex, onEntryClick, scrollCardIntoCenter])
// Initial map focus — delay to let Leaflet initialize and fitBounds // Initial map focus — delay to let Leaflet initialize and fitBounds
useEffect(() => { useEffect(() => {
@@ -143,10 +103,7 @@ export default function MobileMapTimeline({
if (entries.length === 0) { if (entries.length === 0) {
return ( return (
<div <div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
className="fixed left-0 right-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
<JourneyMap <JourneyMap
ref={mapRef} ref={mapRef}
entries={mapEntries} entries={mapEntries}
@@ -158,12 +115,12 @@ export default function MobileMapTimeline({
fullScreen fullScreen
/> />
{!readOnly && onAddEntry && ( {!readOnly && onAddEntry && (
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}> <div className="fixed top-[calc(var(--nav-h,56px)+12px)] right-4 z-30">
<button <button
onClick={onAddEntry} onClick={onAddEntry}
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform" className="w-10 h-10 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
> >
<Plus size={20} /> <Plus size={18} />
</button> </button>
</div> </div>
)} )}
@@ -172,10 +129,7 @@ export default function MobileMapTimeline({
} }
return ( return (
<div <div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
className="fixed left-0 right-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
{/* Full-screen map */} {/* Full-screen map */}
<JourneyMap <JourneyMap
ref={mapRef} ref={mapRef}
@@ -192,12 +146,12 @@ export default function MobileMapTimeline({
{/* Bottom carousel */} {/* Bottom carousel */}
<div <div
className="fixed left-0 right-0 z-40" className="fixed bottom-20 left-0 right-0 z-40"
style={{ touchAction: 'pan-x', bottom: carouselBottom }} style={{ touchAction: 'pan-x' }}
> >
<div <div
ref={carouselRef} ref={carouselRef}
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1" className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
style={{ style={{
scrollSnapType: 'x mandatory', scrollSnapType: 'x mandatory',
WebkitOverflowScrolling: 'touch', WebkitOverflowScrolling: 'touch',
@@ -214,10 +168,9 @@ export default function MobileMapTimeline({
> >
<MobileEntryCard <MobileEntryCard
entry={entry} entry={entry}
dayLabel={entryDayMeta[i]?.dayLabel ?? i + 1} index={i}
dayColor={entryDayMeta[i]?.dayColor ?? DAY_COLORS[0]}
isActive={i === activeIndex} isActive={i === activeIndex}
onClick={() => handleCardTap(entry, i)} onClick={() => onEntryClick(entry)}
publicPhotoUrl={publicPhotoUrl} publicPhotoUrl={publicPhotoUrl}
/> />
</div> </div>
@@ -225,17 +178,14 @@ export default function MobileMapTimeline({
</div> </div>
</div> </div>
{/* FAB: add entry — bottom right, above the timeline carousel */} {/* FAB: add entry — top right */}
{!readOnly && onAddEntry && ( {!readOnly && onAddEntry && (
<div <div className="fixed top-[calc(var(--nav-h,56px)+12px)] right-4 z-30">
className="fixed right-4 z-30"
style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 168px)' }}
>
<button <button
onClick={onAddEntry} onClick={onAddEntry}
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform" className="w-10 h-10 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
> >
<Plus size={20} /> <Plus size={18} />
</button> </button>
</div> </div>
)} )}
@@ -1,32 +0,0 @@
export const DAY_COLORS = [
'#6366f1', // indigo
'#f97316', // orange
'#14b8a6', // teal
'#ec4899', // pink
'#22c55e', // green
'#3b82f6', // blue
'#a855f7', // purple
'#ef4444', // red
'#f59e0b', // amber
'#06b6d4', // cyan
'#84cc16', // lime
'#f43f5e', // rose
'#8b5cf6', // violet
'#10b981', // emerald
'#fb923c', // orange-400
'#60a5fa', // blue-400
'#c084fc', // purple-400
'#34d399', // emerald-400
'#fbbf24', // amber-400
'#e879f9', // fuchsia
'#4ade80', // green-400
'#f87171', // red-400
'#38bdf8', // sky-400
'#a3e635', // lime-400
'#fb7185', // rose-400
'#818cf8', // indigo-400
'#2dd4bf', // teal-400
'#facc15', // yellow
'#c026d3', // fuchsia-600
'#0ea5e9', // sky-500
]
@@ -19,10 +19,8 @@ vi.mock('react-router-dom', async () => {
import { render, screen, fireEvent } from '../../../tests/helpers/render'; import { render, screen, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildSettings } from '../../../tests/helpers/factories'; import { buildUser } from '../../../tests/helpers/factories';
import BottomNav from './BottomNav'; import BottomNav from './BottomNav';
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' }); const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
@@ -41,7 +39,7 @@ describe('BottomNav', () => {
it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => { it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => {
render(<BottomNav />); render(<BottomNav />);
expect(screen.getByText('My Trips')).toBeInTheDocument(); expect(screen.getByText('Trips')).toBeInTheDocument();
}); });
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => { it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
@@ -101,39 +99,4 @@ describe('BottomNav', () => {
// Sheet should be closed — username no longer visible (only the nav Profile text remains) // Sheet should be closed — username no longer visible (only the nav Profile text remains)
expect(screen.queryByText('testuser')).not.toBeInTheDocument(); expect(screen.queryByText('testuser')).not.toBeInTheDocument();
}); });
it('FE-COMP-BOTTOMNAV-010: Trips label translates when language is fr', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
render(<BottomNav />);
expect(screen.getByText('Mes voyages')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-011: Profile label translates when language is fr', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
render(<BottomNav />);
expect(screen.getByText('Profil')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-012: addon labels translate when language is fr', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
seedStore(useAddonStore, {
addons: [
{ id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true },
{ id: 'atlas', name: 'Atlas', type: 'global', icon: 'globe', enabled: true },
{ id: 'journey', name: 'Journey', type: 'global', icon: 'compass', enabled: true },
],
});
render(<BottomNav />);
expect(screen.getByText('Vacances')).toBeInTheDocument();
expect(screen.getByText('Atlas')).toBeInTheDocument();
expect(screen.getByText('Journal de voyage')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-013: unknown addon id is not rendered', () => {
seedStore(useAddonStore, {
addons: [{ id: 'foo', name: 'Foo Addon', type: 'global', icon: 'star', enabled: true }],
});
render(<BottomNav />);
expect(screen.queryByText('Foo Addon')).not.toBeInTheDocument();
});
}); });
+13 -11
View File
@@ -7,10 +7,14 @@ import { useTranslation } from '../../i18n'
import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react' import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = { const BASE_ITEMS: { to: string; label: string; icon: LucideIcon; addonId?: string }[] = [
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' }, { to: '/trips', label: 'Trips', icon: Plane },
atlas: { icon: Globe, labelKey: 'admin.addons.catalog.atlas.name' }, ]
journey: { icon: Compass, labelKey: 'admin.addons.catalog.journey.name' },
const ADDON_NAV: Record<string, { to: string; label: string; icon: LucideIcon }> = {
vacay: { to: '/vacay', label: 'Vacay', icon: CalendarDays },
atlas: { to: '/atlas', label: 'Atlas', icon: Globe },
journey: { to: '/journey', label: 'Journey', icon: Compass },
} }
export default function BottomNav() { export default function BottomNav() {
@@ -21,13 +25,11 @@ export default function BottomNav() {
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled) const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
const [showProfile, setShowProfile] = useState(false) const [showProfile, setShowProfile] = useState(false)
const items: { to: string; label: string; icon: LucideIcon }[] = [ const items = [...BASE_ITEMS]
{ to: '/trips', label: t('nav.myTrips'), icon: Plane }, for (const addon of globalAddons) {
...globalAddons.flatMap(addon => { const nav = ADDON_NAV[addon.id]
const nav = ADDON_NAV[addon.id] if (nav) items.push(nav)
return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : [] }
}),
]
return ( return (
<> <>
+5 -12
View File
@@ -266,22 +266,17 @@ export default function DemoBanner(): React.ReactElement | null {
return ( return (
<div style={{ <div style={{
position: 'fixed', inset: 0, zIndex: 99999, position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)', background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
paddingTop: 'max(16px, env(safe-area-inset-top))', padding: 16, overflow: 'auto',
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
paddingLeft: 16, paddingRight: 16,
overflow: 'auto',
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}} onClick={() => setDismissed(true)}> }} onClick={() => setDismissed(true)}>
<div style={{ <div style={{
background: 'white', borderRadius: 20, padding: '28px 24px 0', background: 'white', borderRadius: 20, padding: '28px 24px 20px',
maxWidth: 480, width: '100%', maxWidth: 480, width: '100%',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
maxHeight: 'min(90vh, calc(100dvh - 96px))', maxHeight: '90vh', overflow: 'auto',
overflow: 'auto',
display: 'flex', flexDirection: 'column',
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}> }} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
{/* Header */} {/* Header */}
@@ -372,10 +367,8 @@ export default function DemoBanner(): React.ReactElement | null {
{/* Footer */} {/* Footer */}
<div style={{ <div style={{
padding: '14px 0 20px', borderTop: '1px solid #e5e7eb', paddingTop: 14, borderTop: '1px solid #e5e7eb',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
position: 'sticky', bottom: 0, background: 'white',
marginTop: 'auto',
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
<Github size={13} /> <Github size={13} />
+8 -47
View File
@@ -34,21 +34,9 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false) const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
const [scrolled, setScrolled] = useState<boolean>(false)
const darkMode = settings.dark_mode const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8 || (document.body.scrollTop || 0) > 8)
onScroll()
window.addEventListener('scroll', onScroll, { passive: true })
document.body.addEventListener('scroll', onScroll, { passive: true })
return () => {
window.removeEventListener('scroll', onScroll)
document.body.removeEventListener('scroll', onScroll)
}
}, [])
// Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page // Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page
const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled) const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled)
@@ -61,26 +49,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
navigate('/login', { state: { noRedirect: true } }) navigate('/login', { state: { noRedirect: true } })
} }
// Keep track of the pending theme-transition cleanup so we can cancel it
// on unmount. Without this the timer fires after jsdom teardown in unit
// tests (document is gone) and triggers an unhandled ReferenceError that
// trips vitest's exit code.
const themeTransitionTimer = useRef<number | null>(null)
useEffect(() => () => {
if (themeTransitionTimer.current !== null) {
window.clearTimeout(themeTransitionTimer.current)
themeTransitionTimer.current = null
}
}, [])
const toggleDarkMode = () => { const toggleDarkMode = () => {
document.documentElement.classList.add('trek-theme-transitioning')
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {}) updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
if (themeTransitionTimer.current !== null) window.clearTimeout(themeTransitionTimer.current)
themeTransitionTimer.current = window.setTimeout(() => {
document.documentElement.classList.remove('trek-theme-transitioning')
themeTransitionTimer.current = null
}, 360)
} }
const getAddonName = (addon: Addon): string => { const getAddonName = (addon: Addon): string => {
@@ -91,29 +61,23 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
return ( return (
<nav style={{ <nav style={{
background: dark background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
? (scrolled ? 'rgba(9,9,11,0.78)' : 'rgba(9,9,11,0.95)') backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
: (scrolled ? 'rgba(255,255,255,0.72)' : 'rgba(255,255,255,0.95)'),
backdropFilter: scrolled ? 'blur(28px) saturate(180%)' : 'blur(20px)',
WebkitBackdropFilter: scrolled ? 'blur(28px) saturate(180%)' : 'blur(20px)',
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`, borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
boxShadow: scrolled boxShadow: dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)',
? (dark ? '0 4px 24px rgba(0,0,0,0.35)' : '0 4px 24px rgba(0,0,0,0.08)')
: (dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)'),
touchAction: 'manipulation', touchAction: 'manipulation',
paddingTop: 'env(safe-area-inset-top, 0px)', paddingTop: 'env(safe-area-inset-top, 0px)',
height: 'var(--nav-h)', height: 'var(--nav-h)',
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]"> }} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
{/* Left side */} {/* Left side */}
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
{showBack && ( {showBack && (
<button onClick={onBack} <button onClick={onBack}
className="trek-back-btn p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0" className="p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0"
style={{ color: 'var(--text-muted)' }} style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<ArrowLeft className="trek-back-icon w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.back')}</span> <span className="hidden sm:inline">{t('common.back')}</span>
</button> </button>
)} )}
@@ -197,14 +161,11 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */} {/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')} <button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex relative w-8 h-8 items-center justify-center" className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex"
style={{ color: 'var(--text-muted)' }} style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<Sun className="w-4 h-4 absolute transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]" {dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
style={{ opacity: dark ? 1 : 0, transform: dark ? 'rotate(0deg) scale(1)' : 'rotate(-90deg) scale(0.6)' }} />
<Moon className="w-4 h-4 absolute transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ opacity: dark ? 0 : 1, transform: dark ? 'rotate(90deg) scale(0.6)' : 'rotate(0deg) scale(1)' }} />
</button> </button>
{/* Notification bell — only in trip view on mobile, everywhere on desktop */} {/* Notification bell — only in trip view on mobile, everywhere on desktop */}
@@ -235,7 +196,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{userMenuOpen && ReactDOM.createPortal( {userMenuOpen && ReactDOM.createPortal(
<> <>
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setUserMenuOpen(false)} /> <div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setUserMenuOpen(false)} />
<div className="trek-menu-enter w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}> <div className="w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}> <div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p> <p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p> <p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
+20 -26
View File
@@ -1,15 +1,11 @@
/** /**
* OfflineBanner — connectivity + sync state indicator. * OfflineBanner — persistent top bar indicating connectivity + sync state.
* *
* States: * States:
* offline + N queued → amber pill "Offline · N queued" * offline + N queued → amber bar "Offline N changes queued"
* offline + 0 queued → amber pill "Offline" * offline + 0 queued → amber bar "Offline"
* online + N pending → blue pill "Syncing N…" * online + N pending → blue bar "Syncing N changes…"
* online + 0 pending → hidden * online + 0 pending → hidden
*
* Rendered as a small floating pill anchored to the bottom-center of the
* viewport so it never competes with top navigation or sticky modal
* headers. On mobile it hovers just above the bottom tab bar.
*/ */
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { WifiOff, RefreshCw } from 'lucide-react' import { WifiOff, RefreshCw } from 'lucide-react'
@@ -52,9 +48,9 @@ export default function OfflineBanner(): React.ReactElement | null {
const label = offline const label = offline
? pendingCount > 0 ? pendingCount > 0
? `Offline · ${pendingCount} queued` ? `Offline ${pendingCount} change${pendingCount !== 1 ? 's' : ''} queued`
: 'Offline' : 'Offline'
: `Syncing ${pendingCount}` : `Syncing ${pendingCount} change${pendingCount !== 1 ? 's' : ''}`
return ( return (
<div <div
@@ -62,29 +58,27 @@ export default function OfflineBanner(): React.ReactElement | null {
aria-live="polite" aria-live="polite"
style={{ style={{
position: 'fixed', position: 'fixed',
// Hover above the mobile bottom nav; on desktop --bottom-nav-h is 0, top: 0,
// so the pill sits 16px from the bottom. left: 0,
bottom: 'calc(var(--bottom-nav-h) + 16px)', right: 0,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 9999, zIndex: 9999,
background: bg, background: bg,
color: text, color: text,
display: 'inline-flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 6, justifyContent: 'center',
padding: '6px 14px', gap: 8,
borderRadius: 999, paddingTop: 'calc(env(safe-area-inset-top, 0px) + 6px)',
boxShadow: '0 4px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.08)', paddingBottom: '6px',
fontSize: 12, paddingLeft: '16px',
fontWeight: 600, paddingRight: '16px',
whiteSpace: 'nowrap', fontSize: 13,
pointerEvents: 'none', fontWeight: 500,
}} }}
> >
{offline {offline
? <WifiOff size={12} /> ? <WifiOff size={14} />
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} /> : <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
} }
{label} {label}
</div> </div>
@@ -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>
)
}
+17 -59
View File
@@ -1,22 +1,11 @@
import React from 'react' import React from 'react'
import { describe, it, expect, vi, afterEach } from 'vitest' import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen } from '../../../tests/helpers/render' import { render, screen } from '../../../tests/helpers/render'
import { fireEvent, waitFor } from '@testing-library/react' import { fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { resetAllStores } from '../../../tests/helpers/store' import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories' import { buildPlace } from '../../../tests/helpers/factories'
import * as photoService from '../../services/photoService' import * as photoService from '../../services/photoService'
const mapMock = vi.hoisted(() => ({
panTo: vi.fn(),
setView: vi.fn(),
fitBounds: vi.fn(),
getZoom: vi.fn().mockReturnValue(10),
on: vi.fn(),
off: vi.fn(),
panBy: vi.fn(),
}))
vi.mock('react-leaflet', () => ({ vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: any) => <div data-testid="map-container">{children}</div>, MapContainer: ({ children }: any) => <div data-testid="map-container">{children}</div>,
TileLayer: () => <div data-testid="tile-layer" />, TileLayer: () => <div data-testid="tile-layer" />,
@@ -27,17 +16,22 @@ vi.mock('react-leaflet', () => ({
data-lng={position[1]} data-lng={position[1]}
onClick={() => eventHandlers?.click?.()} onClick={() => eventHandlers?.click?.()}
> >
<button
data-testid="marker-hover-trigger"
onClick={() => eventHandlers?.mouseover?.({ originalEvent: { clientX: 100, clientY: 100 } })}
/>
{children} {children}
</div> </div>
), ),
Tooltip: ({ children }: any) => <div data-testid="tooltip">{children}</div>,
Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />, Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />,
CircleMarker: () => <div data-testid="circle-marker" />, CircleMarker: () => <div data-testid="circle-marker" />,
Circle: () => <div data-testid="circle" />, Circle: () => <div data-testid="circle" />,
useMap: () => mapMock, useMap: () => ({
panTo: vi.fn(),
setView: vi.fn(),
fitBounds: vi.fn(),
getZoom: () => 10,
on: vi.fn(),
off: vi.fn(),
panBy: vi.fn(),
}),
useMapEvents: () => ({}), useMapEvents: () => ({}),
})) }))
@@ -81,7 +75,6 @@ function buildMapPlace(overrides: Record<string, any> = {}) {
} }
afterEach(() => { afterEach(() => {
vi.clearAllMocks()
resetAllStores() resetAllStores()
}) })
@@ -108,26 +101,22 @@ describe('MapView', () => {
expect(onMarkerClick).toHaveBeenCalledWith(42) expect(onMarkerClick).toHaveBeenCalledWith(42)
}) })
it('FE-COMP-MAPVIEW-004: tooltip shows place name', async () => { it('FE-COMP-MAPVIEW-004: tooltip shows place name', () => {
const user = userEvent.setup()
const places = [buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })] const places = [buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })]
render(<MapView places={places} />) render(<MapView places={places} />)
await user.click(screen.getByTestId('marker-hover-trigger'))
expect(screen.getByTestId('tooltip').textContent).toContain('Eiffel Tower') expect(screen.getByTestId('tooltip').textContent).toContain('Eiffel Tower')
}) })
it('FE-COMP-MAPVIEW-005: tooltip shows category name when present', async () => { it('FE-COMP-MAPVIEW-005: tooltip shows category name when present', () => {
const user = userEvent.setup()
const places = [ const places = [
buildMapPlace({ name: 'Louvre', lat: 48.86, lng: 2.337, category_name: 'Museum', category_icon: null }), buildMapPlace({ name: 'Louvre', lat: 48.86, lng: 2.337, category_name: 'Museum', category_icon: null }),
] ]
render(<MapView places={places} />) render(<MapView places={places} />)
await user.click(screen.getByTestId('marker-hover-trigger'))
expect(screen.getByTestId('tooltip').textContent).toContain('Museum') expect(screen.getByTestId('tooltip').textContent).toContain('Museum')
}) })
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => { it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />) render(<MapView route={[[48.0, 2.0], [49.0, 3.0]]} />)
expect(screen.getByTestId('polyline')).toBeTruthy() expect(screen.getByTestId('polyline')).toBeTruthy()
}) })
@@ -137,7 +126,7 @@ describe('MapView', () => {
}) })
it('FE-COMP-MAPVIEW-008: does not render polyline for single-point route', () => { it('FE-COMP-MAPVIEW-008: does not render polyline for single-point route', () => {
render(<MapView route={[[[48.0, 2.0]]]} />) render(<MapView route={[[48.0, 2.0]]} />)
expect(screen.queryByTestId('polyline')).toBeNull() expect(screen.queryByTestId('polyline')).toBeNull()
}) })
@@ -156,7 +145,7 @@ describe('MapView', () => {
}) })
it('FE-COMP-MAPVIEW-011: renders RouteLabel marker when routeSegments provided with route', () => { it('FE-COMP-MAPVIEW-011: renders RouteLabel marker when routeSegments provided with route', () => {
const route = [[[48.0, 2.0], [49.0, 3.0]]] as [number, number][][][] const route = [[48.0, 2.0], [49.0, 3.0]] as [number, number][]
const routeSegments = [ const routeSegments = [
{ mid: [48.5, 2.5] as [number, number], from: 0, to: 1, walkingText: '10 min', drivingText: '3 min' }, { mid: [48.5, 2.5] as [number, number], from: 0, to: 1, walkingText: '10 min', drivingText: '3 min' },
] ]
@@ -202,13 +191,11 @@ describe('MapView', () => {
vi.mocked(photoService.getCached).mockReturnValue(null) vi.mocked(photoService.getCached).mockReturnValue(null)
}) })
it('FE-COMP-MAPVIEW-016: tooltip shows address when present', async () => { it('FE-COMP-MAPVIEW-016: tooltip shows address when present', () => {
const user = userEvent.setup()
const places = [ const places = [
buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, address: '5 Av. Anatole France' }), buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, address: '5 Av. Anatole France' }),
] ]
render(<MapView places={places} />) render(<MapView places={places} />)
await user.click(screen.getByTestId('marker-hover-trigger'))
expect(screen.getByTestId('tooltip').textContent).toContain('5 Av. Anatole France') expect(screen.getByTestId('tooltip').textContent).toContain('5 Av. Anatole France')
}) })
@@ -219,33 +206,4 @@ describe('MapView', () => {
render(<MapView places={places} selectedPlaceId={5} />) render(<MapView places={places} selectedPlaceId={5} />)
expect(screen.getByTestId('marker')).toBeTruthy() expect(screen.getByTestId('marker')).toBeTruthy()
}) })
it('FE-COMP-MAPVIEW-018: changing selectedPlaceId/hasInspector does not refit bounds (issue #921)', () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
buildMapPlace({ id: 2, lat: 48.86, lng: 2.337 }),
]
const { rerender } = render(<MapView places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />)
const initialCount = mapMock.fitBounds.mock.calls.length
// Toggle selectedPlaceId on — mimics opening place inspector (hasInspector flips,
// paddingOpts memo creates new object). fitBounds must NOT fire again.
rerender(<MapView places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount)
// Toggle selectedPlaceId off — mimics closing inspector via X button.
rerender(<MapView places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount)
})
it('FE-COMP-MAPVIEW-019: bumping fitKey triggers a new fitBounds call', () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
]
const { rerender } = render(<MapView places={places} fitKey={1} />)
const afterFirst = mapMock.fitBounds.mock.calls.length
rerender(<MapView places={places} fitKey={2} />)
expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst)
})
}) })
+170 -236
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react' import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
import DOM from 'react-dom' import DOM from 'react-dom'
import { renderToStaticMarkup } from 'react-dom/server' import { renderToStaticMarkup } from 'react-dom/server'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet' import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster' import MarkerClusterGroup from 'react-leaflet-cluster'
import L from 'leaflet' import L from 'leaflet'
import 'leaflet.markercluster/dist/MarkerCluster.css' import 'leaflet.markercluster/dist/MarkerCluster.css'
@@ -68,9 +68,9 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
">${label}</span>` ">${label}</span>`
} }
// Prefer base64 data URLs (no zoom lag); also accept same-origin proxy URLs as a fallback // Base64 data URL thumbnails — no external image fetch during zoom
// while the thumb is still being generated in the background // Only use base64 data URLs for markers — external URLs cause zoom lag
if (place.image_url && (place.image_url.startsWith('data:') || place.image_url.startsWith('/api/maps/place-photo/'))) { if (place.image_url && place.image_url.startsWith('data:')) {
const imgIcon = L.divIcon({ const imgIcon = L.divIcon({
className: '', className: '',
html: `<div style=" html: `<div style="
@@ -186,7 +186,7 @@ function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsC
} }
} }
} catch {} } catch {}
}, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps }, [fitKey, places, paddingOpts, map, hasDayDetail])
return null return null
} }
@@ -233,7 +233,18 @@ interface RouteLabelProps {
} }
function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) { function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
if (!midpoint) return null const map = useMap()
const [visible, setVisible] = useState(map ? map.getZoom() >= 12 : false)
useEffect(() => {
if (!map) return
const check = () => setVisible(map.getZoom() >= 12)
check()
map.on('zoomend', check)
return () => map.off('zoomend', check)
}, [map])
if (!visible || !midpoint) return null
const icon = L.divIcon({ const icon = L.divIcon({
className: 'route-info-pill', className: 'route-info-pill',
@@ -266,110 +277,97 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
// Module-level photo cache shared with PlaceAvatar // Module-level photo cache shared with PlaceAvatar
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { useAuthStore } from '../../store/authStore'
import { useGeolocation } from '../../hooks/useGeolocation'
import LocationButton from './LocationButton'
// Live-location rendering inside the Leaflet map. Subscribes via the // Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
// shared useGeolocation hook so the Leaflet and Mapbox variants behave function LocationTracker() {
// identically. Heading is shown as a rotated conic SVG when available.
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
const map = useMap() const 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. const startTracking = useCallback(() => {
// setView (no animation) is what Google Maps does during navigation — if (!('geolocation' in navigator)) return
// it feels responsive and avoids animation jitter at walking speed. 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(() => { useEffect(() => {
if (mode !== 'follow' || !position) return if (position && !centered.current) {
try { map.setView([position.lat, position.lng], Math.max(map.getZoom(), 16), { animate: true, duration: 0.35 }) } catch { /* noop */ } map.setView(position, 15)
}, [position, mode, map]) centered.current = true
}
}, [position, map])
// Once, when the user first acquires a fix in "show" mode, pan to it so // Cleanup on unmount
// they don't have to scroll the map. Subsequent fixes only move the dot. useEffect(() => () => { if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) }, [])
const centeredRef = useRef(false)
useEffect(() => {
if (mode === 'off') { centeredRef.current = false; return }
if (!position || centeredRef.current) return
try { map.setView([position.lat, position.lng], Math.max(map.getZoom(), 15)) } catch { /* noop */ }
centeredRef.current = true
}, [position, mode, map])
if (!position) return null
const headingIcon = position.heading === null || Number.isNaN(position.heading) ? null : L.divIcon({
className: '',
iconSize: [60, 60],
iconAnchor: [30, 30],
html: `<div style="
width:60px;height:60px;
transform:rotate(${position.heading}deg);transition:transform 120ms ease-out;
background:conic-gradient(from -30deg, rgba(59,130,246,0) 0deg, rgba(59,130,246,0.35) 15deg, rgba(59,130,246,0) 60deg, rgba(59,130,246,0) 360deg);
border-radius:50%;
-webkit-mask:radial-gradient(circle, transparent 12px, black 13px);
mask:radial-gradient(circle, transparent 12px, black 13px);
pointer-events:none;
"></div>`,
})
return ( return (
<> <>
{position.accuracy < 500 && ( {/* Location button */}
<Circle <div style={{
center={[position.lat, position.lng]} position: 'absolute', bottom: 20, right: 10, zIndex: 1000,
radius={position.accuracy} }}>
pathOptions={{ color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.12, weight: 1, opacity: 0.35 }} <button onClick={toggleTracking} style={{
interactive={false} 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 {/* Pulse animation CSS */}
position={[position.lat, position.lng]} {position && (
icon={headingIcon} <style>{`
interactive={false} @keyframes location-pulse {
zIndexOffset={900} 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}
/>
</> </>
) )
} }
interface MemoMarkerProps {
place: any
isSelected: boolean
orderNumbers: number[] | null
photoUrl: string | null
onClickPlace: (id: number) => void
onHover: (place: any, x: number, y: number) => void
onHoverOut: () => void
}
const MemoMarker = memo(function MemoMarker({
place, isSelected, orderNumbers, photoUrl, onClickPlace, onHover, onHoverOut,
}: MemoMarkerProps) {
const icon = createPlaceIcon({ ...place, image_url: photoUrl }, orderNumbers, isSelected)
return (
<Marker
position={[place.lat, place.lng]}
icon={icon}
eventHandlers={{
click: () => onClickPlace(place.id),
mouseover: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
mousemove: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
mouseout: onHoverOut,
}}
zIndexOffset={isSelected ? 1000 : 0}
/>
)
})
export const MapView = memo(function MapView({ export const MapView = memo(function MapView({
places = [], places = [],
dayPlaces = [], dayPlaces = [],
@@ -409,51 +407,22 @@ export const MapView = memo(function MapView({
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] } return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
}, [leftWidth, rightWidth, hasInspector, hasDayDetail]) }, [leftWidth, rightWidth, hasInspector, hasDayDetail])
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
const [hoveredPlace, setHoveredPlace] = useState<any>(null)
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null)
const handleMarkerHover = useCallback((place: any, x: number, y: number) => {
setHoveredPlace(place)
setTooltipPos({ x, y })
}, [])
const handleMarkerHoverOut = useCallback(() => {
setHoveredPlace(null)
}, [])
const handleMarkerClick = useCallback((id: number) => {
onMarkerClick?.(id)
}, [onMarkerClick])
// photoUrls: only base64 thumbs for smooth map zoom // photoUrls: only base64 thumbs for smooth map zoom
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs) const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
// Batch photo state updates through a RAF so N simultaneous photo loads
// collapse into a single re-render instead of N separate renders.
const pendingThumbsRef = useRef<Record<string, string>>({})
const thumbRafRef = useRef<number | null>(null)
// Fetch photos via shared service — subscribe to thumb (base64) availability
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places]) const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
useEffect(() => { useEffect(() => {
if (!places || places.length === 0 || !placesPhotosEnabled) return if (!places || places.length === 0) return
const cleanups: (() => void)[] = [] const cleanups: (() => void)[] = []
const setThumb = (cacheKey: string, thumb: string) => { const setThumb = (cacheKey: string, thumb: string) => {
pendingThumbsRef.current[cacheKey] = thumb iconCache.clear()
if (thumbRafRef.current !== null) return setPhotoUrls(prev => prev[cacheKey] === thumb ? prev : { ...prev, [cacheKey]: thumb })
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) { for (const place of places) {
if (place.image_url && place.image_url.startsWith('data:')) continue
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
if (!cacheKey) continue if (!cacheKey) continue
@@ -463,28 +432,20 @@ export const MapView = memo(function MapView({
continue continue
} }
// Subscribe for when thumb becomes available
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb))) cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
// Always fetch through API — returns fresh URL + converts to base64
if (!cached && !isLoading(cacheKey)) { if (!cached && !isLoading(cacheKey)) {
const photoId = const photoId = place.google_place_id || place.osm_id
(place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null)
|| place.google_place_id
|| place.osm_id
|| place.image_url
if (photoId || (place.lat && place.lng)) { if (photoId || (place.lat && place.lng)) {
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
} }
} }
} }
return () => { return () => cleanups.forEach(fn => fn())
cleanups.forEach(fn => fn()) }, [placeIds])
if (thumbRafRef.current !== null) {
cancelAnimationFrame(thumbRafRef.current)
thumbRafRef.current = null
}
}
}, [placeIds, placesPhotosEnabled])
const clusterIconCreateFunction = useCallback((cluster) => { const clusterIconCreateFunction = useCallback((cluster) => {
const count = cluster.getChildCount() const count = cluster.getChildCount()
@@ -496,56 +457,57 @@ export const MapView = memo(function MapView({
}) })
}, []) }, [])
const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0 const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
const markers = useMemo(() => places.map((place) => { const markers = useMemo(() => places.map((place) => {
const isSelected = place.id === selectedPlaceId const isSelected = place.id === selectedPlaceId
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
const photoUrl = (pck && photoUrls[pck]) || place.image_url || null const resolvedPhoto = (pck && photoUrls[pck]) || (place.image_url?.startsWith('data:') ? place.image_url : null) || null
const orderNumbers = dayOrderMap[place.id] ?? null const orderNumbers = dayOrderMap[place.id] ?? null
const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected)
return ( return (
<MemoMarker <Marker
key={place.id} key={place.id}
place={place} position={[place.lat, place.lng]}
isSelected={isSelected} icon={icon}
orderNumbers={orderNumbers} eventHandlers={{
photoUrl={photoUrl} click: () => onMarkerClick && onMarkerClick(place.id),
onClickPlace={handleMarkerClick} }}
onHover={handleMarkerHover} zIndexOffset={isSelected ? 1000 : 0}
onHoverOut={handleMarkerHoverOut} >
/> <Tooltip
direction="right"
offset={[0, 0]}
opacity={1}
className="map-tooltip"
permanent={isTouchDevice && isSelected}
>
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
{place.name}
</div>
{place.category_name && (() => {
const CatIcon = getCategoryIcon(place.category_icon)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
</div>
)
})()}
{place.address && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{place.address}
</div>
)}
</div>
</Tooltip>
</Marker>
) )
}), [places, selectedPlaceId, dayOrderMap, photoUrls, handleMarkerClick, handleMarkerHover, handleMarkerHoverOut]) }), [places, selectedPlaceId, dayOrderMap, photoUrls, onMarkerClick, isTouchDevice])
const gpxPolylines = useMemo(() => 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 [(
<Polyline
key={`gpx-${place.id}`}
positions={coords}
color={place.category_color || '#3b82f6'}
weight={3.5}
opacity={0.75}
/>
)]
} catch { return [] }
}), [places])
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 ( return (
<>
<div className="w-full h-full relative">
<MapContainer <MapContainer
id="trek-map" id="trek-map"
center={center} center={center}
@@ -569,7 +531,7 @@ export const MapView = memo(function MapView({
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} /> <SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} /> <MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} /> <MapContextMenuHandler onContextMenu={onMapContextMenu} />
<LeafletLocationLayer position={userPosition} mode={trackingMode} /> <LocationTracker />
<MarkerClusterGroup <MarkerClusterGroup
chunkedLoading chunkedLoading
@@ -586,18 +548,15 @@ export const MapView = memo(function MapView({
{markers} {markers}
</MarkerClusterGroup> </MarkerClusterGroup>
{route && route.length > 0 && ( {route && route.length > 1 && (
<> <>
{route.map((seg, i) => seg.length > 1 && ( <Polyline
<Polyline positions={route}
key={i} color="#111827"
positions={seg} weight={3}
color="#111827" opacity={0.9}
weight={3} dashArray="6, 5"
opacity={0.9} />
dashArray="6, 5"
/>
))}
{routeSegments.map((seg, i) => ( {routeSegments.map((seg, i) => (
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} /> <RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
))} ))}
@@ -605,7 +564,22 @@ export const MapView = memo(function MapView({
)} )}
{/* GPX imported route geometries */} {/* GPX imported route geometries */}
{gpxPolylines} {places.map((place) => {
if (!place.route_geometry) return null
try {
const coords = JSON.parse(place.route_geometry) as [number, number][]
if (!coords || coords.length < 2) return null
return (
<Polyline
key={`gpx-${place.id}`}
positions={coords}
color={place.category_color || '#3b82f6'}
weight={3.5}
opacity={0.75}
/>
)
} catch { return null }
})}
<ReservationOverlay <ReservationOverlay
reservations={visibleReservations} reservations={visibleReservations}
@@ -614,45 +588,5 @@ export const MapView = memo(function MapView({
onEndpointClick={onReservationClick} onEndpointClick={onReservationClick}
/> />
</MapContainer> </MapContainer>
{isMobile && <LocationButton
mode={trackingMode}
error={trackingError}
onClick={cycleTrackingMode}
bottomOffset={locationButtonBottom as unknown as number}
/>}
</div>
{TooltipOverlay && (
<div data-testid="tooltip" style={{
position: 'fixed',
left: tooltipPos.x + 14,
top: tooltipPos.y - 10,
zIndex: 9999,
pointerEvents: 'none',
background: 'white',
borderRadius: 8,
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
padding: '6px 10px',
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
maxWidth: 220,
whiteSpace: 'nowrap',
}}>
<div style={{ fontWeight: 600, fontSize: 12, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{hoveredPlace.name}
</div>
{hoveredPlace.category_name && CatIcon && (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
<CatIcon size={10} style={{ color: hoveredPlace.category_color || '#6b7280', flexShrink: 0 }} />
<span style={{ fontSize: 11, color: '#6b7280' }}>{hoveredPlace.category_name}</span>
</div>
)}
{hoveredPlace.address && (
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{hoveredPlace.address}
</div>
)}
</div>
)}
</>
) )
}) })
-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} />
}
@@ -1,164 +0,0 @@
import React from 'react'
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
import { render } from '../../../tests/helpers/render'
import { act } from '@testing-library/react'
import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories'
import { useSettingsStore } from '../../store/settingsStore'
// Stable fake map so fitBounds call counts survive re-renders.
const glMap = vi.hoisted(() => ({
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
loaded: vi.fn().mockReturnValue(true),
fitBounds: vi.fn(),
flyTo: vi.fn(),
jumpTo: vi.fn(),
getZoom: vi.fn().mockReturnValue(10),
addControl: vi.fn(),
removeControl: vi.fn(),
remove: vi.fn(),
addSource: vi.fn(),
getSource: vi.fn().mockReturnValue(null),
addLayer: vi.fn(),
setLayoutProperty: vi.fn(),
getStyle: vi.fn().mockReturnValue({ layers: [] }),
isStyleLoaded: vi.fn().mockReturnValue(true),
getCanvasContainer: vi.fn(() => document.createElement('div')),
}))
vi.mock('mapbox-gl', () => ({
default: {
accessToken: '',
Map: vi.fn(() => glMap),
Marker: vi.fn(() => ({
setLngLat: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
getElement: vi.fn(() => document.createElement('div')),
})),
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
NavigationControl: vi.fn(),
},
}))
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
vi.mock('./mapboxSetup', () => ({
isStandardFamily: vi.fn(() => false),
supportsCustom3d: vi.fn(() => false),
wantsTerrain: vi.fn(() => false),
addCustom3dBuildings: vi.fn(),
addTerrainAndSky: vi.fn(),
}))
vi.mock('./locationMarkerMapbox', () => ({
attachLocationMarker: vi.fn(() => ({ update: vi.fn() })),
}))
vi.mock('./reservationsMapbox', () => ({
ReservationMapboxOverlay: vi.fn().mockImplementation(() => ({ update: vi.fn() })),
}))
vi.mock('../../hooks/useGeolocation', () => ({
useGeolocation: vi.fn(() => ({
position: null,
mode: 'off',
error: null,
cycleMode: vi.fn(),
setMode: vi.fn(),
})),
}))
vi.mock('../../services/photoService', () => ({
getCached: vi.fn(() => null),
isLoading: vi.fn(() => false),
fetchPhoto: vi.fn(),
onThumbReady: vi.fn(() => () => {}),
getAllThumbs: vi.fn(() => ({})),
}))
import { MapViewGL } from './MapViewGL'
function buildMapPlace(overrides: Record<string, any> = {}) {
return {
...buildPlace(),
category_name: null,
category_color: null,
category_icon: null,
...overrides,
} as any
}
beforeEach(() => {
useSettingsStore.setState({
settings: {
...useSettingsStore.getState().settings,
map_provider: 'mapbox-gl',
mapbox_access_token: 'pk.test_token',
mapbox_style: 'mapbox://styles/mapbox/streets-v12',
mapbox_3d_enabled: false,
},
} as any)
})
afterEach(() => {
vi.clearAllMocks()
resetAllStores()
})
describe('MapViewGL', () => {
it('FE-COMP-MAPVIEWGL-001: opening place inspector does not refit bounds (issue #921)', async () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
buildMapPlace({ id: 2, lat: 48.86, lng: 2.337 }),
]
const { rerender } = render(
<MapViewGL places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />,
)
await act(async () => {})
const after_initial = glMap.fitBounds.mock.calls.length
// Selecting a place flips hasInspector → paddingOpts memo changes.
// fitBounds must NOT fire again (this was the bug).
rerender(
<MapViewGL places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />,
)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(after_initial)
})
it('FE-COMP-MAPVIEWGL-002: closing inspector does not refit bounds (issue #921)', async () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
]
const { rerender } = render(
<MapViewGL places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />,
)
await act(async () => {})
const after_initial = glMap.fitBounds.mock.calls.length
// Closing inspector (X button) clears selectedPlaceId → hasInspector=false → new paddingOpts.
rerender(
<MapViewGL places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />,
)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(after_initial)
})
it('FE-COMP-MAPVIEWGL-003: bumping fitKey triggers a new fitBounds call', async () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
]
const { rerender } = render(<MapViewGL places={places} fitKey={1} />)
await act(async () => {})
const after_first = glMap.fitBounds.mock.calls.length
rerender(<MapViewGL places={places} fitKey={2} />)
await act(async () => {})
expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first)
})
})
-619
View File
@@ -1,619 +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 { ReservationMapboxOverlay } from './reservationsMapbox'
import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation'
import type { Place, Reservation } 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
reservations?: Reservation[]
visibleConnectionIds?: number[]
showReservationStats?: boolean
onReservationClick?: (reservationId: number) => void
}
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,
reservations = [],
visibleConnectionIds = [],
showReservationStats = false,
onReservationClick,
}: 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 showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
const [mapReady, setMapReady] = useState(false)
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 reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
// Refs so the reservation overlay always sees the latest callback /
// options without forcing a full overlay rebuild on every prop change.
const onReservationClickRef = useRef(onReservationClick)
onReservationClickRef.current = onReservationClick
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' },
})
}
// Signal that sources/layers are attached so overlay effects can
// safely add their own sources. Style rebuilds reset this via the
// cleanup below.
setMapReady(true)
})
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 (reservationOverlayRef.current) {
reservationOverlayRef.current.destroy()
reservationOverlayRef.current = null
}
if (locationMarkerRef.current) {
locationMarkerRef.current.destroy()
locationMarkerRef.current = null
}
try { map.remove() } catch { /* noop */ }
mapRef.current = null
setMapReady(false)
}
}, [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?.startsWith('/api/maps/place-photo/') ? place.image_url : null)
|| place.google_place_id
|| place.osm_id
|| place.image_url
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])
// Reservation overlay — mirrors the Leaflet ReservationOverlay: great-
// circle arcs for flights/cruises, straight lines for trains/cars,
// clickable endpoint badges, rotating mid-arc stats label for flights.
// The overlay is a small imperative manager that owns its own source,
// layer, and HTML markers; it lives next to the map for the map's
// lifetime and is rebuilt when the style/token/3d effect rebuilds.
//
// `visibleConnectionIds` is driven by the per-reservation toggle in
// DayPlanSidebar — nothing is rendered until the user enables a
// booking's route, matching the Leaflet MapView's behaviour.
const visibleReservations = useMemo(() => {
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
const set = new Set(visibleConnectionIds)
return reservations.filter(r => set.has(r.id))
}, [reservations, visibleConnectionIds])
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
if (!reservationOverlayRef.current) {
reservationOverlayRef.current = new ReservationMapboxOverlay(map, {
showConnections: true,
showStats: showReservationStats,
showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id),
})
}
reservationOverlayRef.current.update(visibleReservations, {
showConnections: true,
showStats: showReservationStats,
showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id),
})
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady])
// 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])
const prevFitKey = useRef(-1)
useEffect(() => {
if (fitKey === prevFitKey.current) return
prevFitKey.current = fitKey
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]) // 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 */ }
}
@@ -1,388 +0,0 @@
// Mapbox GL counterpart to ReservationOverlay.tsx.
//
// react-leaflet is component-driven, mapbox-gl is imperative — so instead of
// a React component, this exports a small manager class the MapViewGL wires
// up next to its other sources/layers. The geometry logic (great-circle arcs,
// antimeridian split, duration math) mirrors the Leaflet overlay so both
// renderers produce the same visual result on the globe or a flat projection.
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import mapboxgl from 'mapbox-gl'
import { Plane, Train, Ship, Car } from 'lucide-react'
import type { Reservation, ReservationEndpoint } from '../../types'
export const RESERVATION_SOURCE_ID = 'trek-reservations'
export const RESERVATION_LINE_LAYER_ID = 'trek-reservations-lines'
type TransportType = 'flight' | 'train' | 'cruise' | 'car'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car']
const TRANSPORT_COLOR = '#3b82f6'
const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }> = {
flight: { icon: Plane, geodesic: true },
train: { icon: Train, geodesic: false },
cruise: { icon: Ship, geodesic: true },
car: { icon: Car, geodesic: false },
}
// ── geometry helpers (ported from ReservationOverlay.tsx) ────────────────
const toRad = (d: number) => d * Math.PI / 180
const toDeg = (r: number) => r * 180 / Math.PI
function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
if (d === 0) return [a, b]
const pts: [number, number][] = []
for (let i = 0; i <= steps; i++) {
const f = i / steps
const A = Math.sin((1 - f) * d) / Math.sin(d)
const B = Math.sin(f * d) / Math.sin(d)
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
const lng = Math.atan2(y, x)
pts.push([toDeg(lat), toDeg(lng)])
}
return pts
}
function splitAntimeridian(points: [number, number][]): [number, number][][] {
const segments: [number, number][][] = []
let cur: [number, number][] = []
for (let i = 0; i < points.length; i++) {
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
if (cur.length > 1) segments.push(cur)
cur = []
}
cur.push(points[i])
}
if (cur.length > 1) segments.push(cur)
return segments
}
function haversineKm(a: [number, number], b: [number, number]): number {
const R = 6371
const dLat = toRad(b[0] - a[0])
const dLng = toRad(b[1] - a[1])
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(h))
}
function parseInTz(isoLocal: string, tz: string): number {
const [datePart, timePart] = isoLocal.split('T')
const [y, mo, d] = datePart.split('-').map(Number)
const [h, mi] = (timePart || '00:00').split(':').map(Number)
const guess = Date.UTC(y, mo - 1, d, h, mi)
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: tz, hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
const parts = Object.fromEntries(fmt.formatToParts(new Date(guess)).filter(p => p.type !== 'literal').map(p => [p.type, p.value]))
const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) % 24, Number(parts.minute), Number(parts.second))
return guess - (asUtc - guess)
}
function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fallbackStart: string | null, fallbackEnd: string | null): string | null {
let start = from.local_date && from.local_time ? `${from.local_date}T${from.local_time}` : fallbackStart
let end = to.local_date && to.local_time ? `${to.local_date}T${to.local_time}` : fallbackEnd
if (!start || !end) return null
if (!start.includes('T') && end.includes('T')) start = `${end.split('T')[0]}T${start}`
if (!end.includes('T') && start.includes('T')) end = `${start.split('T')[0]}T${end}`
if (!start.includes('T') || !end.includes('T')) return null
const fromTz = from.timezone || to.timezone
const toTz = to.timezone || fromTz
let startMs: number, endMs: number
if (fromTz && toTz) {
startMs = parseInTz(start, fromTz)
endMs = parseInTz(end, toTz)
} else {
startMs = new Date(start).getTime()
endMs = new Date(end).getTime()
}
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null
if (endMs <= startMs) endMs += 24 * 60 * 60000
const minutes = Math.round((endMs - startMs) / 60000)
if (minutes <= 0 || minutes > 48 * 60) return null
const h = Math.floor(minutes / 60)
const m = minutes % 60
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
const cleanName = (name: string) => name.replace(/\s*\([^)]*\)/g, '').trim()
// ── item building ─────────────────────────────────────────────────────────
interface TransportItem {
res: Reservation
from: ReservationEndpoint
to: ReservationEndpoint
type: TransportType
arcs: [number, number][][]
primaryArc: [number, number][]
mainLabel: string | null
subLabel: string | null
}
function buildItems(reservations: Reservation[]): TransportItem[] {
const out: TransportItem[] = []
for (const r of reservations) {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
const eps = r.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
if (!from || !to) continue
const type = r.type as TransportType
const isGeo = TYPE_META[type].geodesic
const arcs = isGeo
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
: [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
const primaryArc = arcs[primaryIdx] ?? []
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
const mainLabel = from.code && to.code ? `${from.code}${to.code}` : null
const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
out.push({ res: r, from, to, type, arcs, primaryArc, mainLabel, subLabel })
}
return out
}
// ── DOM helpers for HTML markers ──────────────────────────────────────────
function endpointMarkerHtml(type: TransportType, label: string | null): string {
const { icon: IconCmp } = TYPE_META[type]
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
const labelHtml = label ? `<span style="display:inline-flex;align-items:center;line-height:1">${label}</span>` : ''
return `<div style="
display:inline-flex;align-items:center;justify-content:center;gap:4px;
padding:0 8px;border-radius:999px;
background:${TRANSPORT_COLOR};box-shadow:0 2px 6px rgba(0,0,0,0.25);
border:1.5px solid #fff;color:#fff;
font-family:-apple-system,system-ui,sans-serif;font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
box-sizing:border-box;height:22px;white-space:nowrap;cursor:pointer;
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml}</div>`
}
function buildStatsHtml(mainLabel: string | null, subLabel: string | null): { html: string; width: number; height: number } {
const estWidth = Math.max(
mainLabel ? mainLabel.length * 6.5 : 0,
subLabel ? subLabel.length * 5.5 : 0,
) + 22
const hasBoth = !!mainLabel && !!subLabel
const height = hasBoth ? 36 : 22
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${mainLabel}</span>` : ''
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${subLabel}</span>` : ''
const html = `<div class="trek-stats-inner" style="
display:flex;flex-direction:column;align-items:center;justify-content:center;
width:100%;height:100%;
padding:0 11px;border-radius:999px;
background:rgba(17,24,39,0.92);color:#fff;
box-shadow:0 2px 6px rgba(0,0,0,0.25);
border:1px solid ${TRANSPORT_COLOR}aa;
font-family:-apple-system,system-ui,'SF Pro Text',sans-serif;
white-space:nowrap;box-sizing:border-box;pointer-events:none;
transform-origin:center;will-change:transform;
">${main}${sub}</div>`
return { html, width: estWidth, height }
}
// ── overlay manager ──────────────────────────────────────────────────────
export interface ReservationOverlayOptions {
showConnections: boolean
showStats: boolean
showEndpointLabels: boolean
onEndpointClick?: (reservationId: number) => void
}
export class ReservationMapboxOverlay {
private map: mapboxgl.Map
private items: TransportItem[] = []
private opts: ReservationOverlayOptions
private endpointMarkers: mapboxgl.Marker[] = []
private statsMarkers: { marker: mapboxgl.Marker; arc: [number, number][] }[] = []
private rerender: () => void
private destroyed = false
constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions) {
this.map = map
this.opts = opts
this.rerender = () => { if (!this.destroyed) this.render() }
this.setupLayer()
map.on('zoomend', this.rerender)
map.on('moveend', this.rerender)
map.on('render', this.updateStatsRotation)
}
update(reservations: Reservation[], opts: ReservationOverlayOptions) {
this.opts = opts
this.items = buildItems(reservations)
this.render()
}
destroy() {
this.destroyed = true
this.map.off('zoomend', this.rerender)
this.map.off('moveend', this.rerender)
this.map.off('render', this.updateStatsRotation)
this.endpointMarkers.forEach(m => m.remove())
this.endpointMarkers = []
this.statsMarkers.forEach(s => s.marker.remove())
this.statsMarkers = []
try {
if (this.map.getLayer(RESERVATION_LINE_LAYER_ID)) this.map.removeLayer(RESERVATION_LINE_LAYER_ID)
if (this.map.getSource(RESERVATION_SOURCE_ID)) this.map.removeSource(RESERVATION_SOURCE_ID)
} catch { /* map already gone */ }
}
private setupLayer() {
const map = this.map
if (map.getSource(RESERVATION_SOURCE_ID)) return
map.addSource(RESERVATION_SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addLayer({
id: RESERVATION_LINE_LAYER_ID,
type: 'line',
source: RESERVATION_SOURCE_ID,
paint: {
'line-color': TRANSPORT_COLOR,
'line-width': 2.5,
// Confirmed = solid + 0.75; pending = dashed + 0.55.
'line-opacity': ['case', ['==', ['get', 'status'], 'confirmed'], 0.75, 0.55] as any,
'line-dasharray': ['case', ['==', ['get', 'status'], 'confirmed'], ['literal', [1, 0]], ['literal', [3, 3]]] as any,
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
private render() {
const map = this.map
if (!this.map.getSource(RESERVATION_SOURCE_ID)) return
const show = this.opts.showConnections
// Visible filter: require the on-screen pixel distance between
// endpoints to exceed a type-specific minimum, same as the Leaflet
// overlay, so tiny no-op transport lines don't clutter the map.
const visibleItems = show ? this.items.filter(item => {
try {
const fromPx = map.project([item.from.lng, item.from.lat])
const toPx = map.project([item.to.lng, item.to.lat])
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
const dist = Math.sqrt(dx * dx + dy * dy)
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
return dist >= minPx
} catch { return true }
}) : []
// Label visibility threshold is higher than line visibility, to keep
// endpoint text from overlapping on very short lines.
const labelVisibleIds = new Set<number>()
if (show) {
for (const item of visibleItems) {
try {
const fromPx = map.project([item.from.lng, item.from.lat])
const toPx = map.project([item.to.lng, item.to.lat])
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
const dist = Math.sqrt(dx * dx + dy * dy)
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : 400
if (dist >= minPx) labelVisibleIds.add(item.res.id)
} catch { /* ignore */ }
}
}
// ── line features ───────────────────────────────────────────────
const features = visibleItems.flatMap(item => item.arcs.map(seg => ({
type: 'Feature' as const,
properties: {
resId: item.res.id,
type: item.type,
status: item.res.status ?? 'pending',
},
geometry: {
type: 'LineString' as const,
coordinates: seg.map(([lat, lng]) => [lng, lat]),
},
})))
const src = map.getSource(RESERVATION_SOURCE_ID) as mapboxgl.GeoJSONSource | undefined
src?.setData({ type: 'FeatureCollection', features })
// ── endpoint markers ────────────────────────────────────────────
this.endpointMarkers.forEach(m => m.remove())
this.endpointMarkers = []
if (show) {
for (const item of visibleItems) {
const showLabel = this.opts.showEndpointLabels && labelVisibleIds.has(item.res.id)
for (const ep of [item.from, item.to]) {
const label = showLabel ? (ep.code || cleanName(ep.name)) : null
const el = document.createElement('div')
el.innerHTML = endpointMarkerHtml(item.type, label)
const inner = el.firstElementChild as HTMLElement | null
const node = inner ?? el
node.title = ep.name || ''
if (this.opts.onEndpointClick) {
node.addEventListener('click', (ev) => {
ev.stopPropagation()
this.opts.onEndpointClick?.(item.res.id)
})
}
const marker = new mapboxgl.Marker({ element: node, anchor: 'center' })
.setLngLat([ep.lng, ep.lat])
.addTo(map)
this.endpointMarkers.push(marker)
}
}
}
// ── stats label (flights only) ──────────────────────────────────
this.statsMarkers.forEach(s => s.marker.remove())
this.statsMarkers = []
if (show && this.opts.showStats) {
for (const item of visibleItems) {
if (item.type !== 'flight') continue
if (!labelVisibleIds.has(item.res.id)) continue
if (!item.mainLabel && !item.subLabel) continue
const arc = item.primaryArc
if (arc.length < 2) continue
const mid = arc[Math.floor(arc.length / 2)]!
const { html, width, height } = buildStatsHtml(item.mainLabel, item.subLabel)
const el = document.createElement('div')
el.style.cssText = `width:${width}px;height:${height}px;pointer-events:none;`
el.innerHTML = html
const marker = new mapboxgl.Marker({ element: el, anchor: 'center' })
.setLngLat([mid[1], mid[0]])
.addTo(map)
this.statsMarkers.push({ marker, arc })
}
}
// Prime rotation once so labels don't flash horizontal on first paint.
this.updateStatsRotation()
}
// Match the Leaflet overlay's "rotate the label along the arc" look.
// We pick a short segment straddling the arc midpoint, measure the
// screen angle between those two projected points, and clamp it to
// [-90°, 90°] so text never renders upside-down.
private updateStatsRotation = () => {
if (this.destroyed) return
for (const entry of this.statsMarkers) {
const { marker, arc } = entry
if (arc.length < 2) continue
const midIdx = Math.floor(arc.length / 2)
const a = arc[Math.max(0, midIdx - 2)]!
const b = arc[Math.min(arc.length - 1, midIdx + 2)]!
try {
const pa = this.map.project([a[1], a[0]])
const pb = this.map.project([b[1], b[0]])
let angle = Math.atan2(pb.y - pa.y, pb.x - pa.x) * 180 / Math.PI
if (angle > 90) angle -= 180
if (angle < -90) angle += 180
const el = marker.getElement()
const inner = el.querySelector('.trek-stats-inner') as HTMLElement | null
if (inner) inner.style.transform = `rotate(${angle}deg)`
} catch { /* map not ready / projection failure */ }
}
}
}
@@ -582,8 +582,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
borderColor: !pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)', borderColor: !pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
color: !pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)', color: !pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
}}> }}>
<span className="hidden sm:inline">{t('memories.allPhotos')}</span> {t('memories.allPhotos')}
<span className="sm:hidden">{t('common.all')}</span>
</button> </button>
</div> </div>
{selectedIds.size > 0 && ( {selectedIds.size > 0 && (
+1 -1
View File
@@ -308,7 +308,7 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
const iframe = document.createElement('iframe') const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;' iframe.style.cssText = 'flex:1;width:100%;border:none;'
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts' iframe.sandbox = 'allow-same-origin allow-modals'
iframe.srcdoc = html iframe.srcdoc = html
card.appendChild(header) card.appendChild(header)
@@ -78,7 +78,6 @@ const transportReservation = {
id: 400, id: 400,
title: 'Flight to Rome', title: 'Flight to Rome',
type: 'flight', type: 'flight',
day_id: 10,
reservation_time: '2025-06-01T14:30:00', reservation_time: '2025-06-01T14:30:00',
confirmation_number: 'ABC123', confirmation_number: 'ABC123',
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }), metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
+13 -57
View File
@@ -4,7 +4,6 @@ import { getCategoryIcon } from '../shared/categoryIcons'
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react' import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react'
import { accommodationsApi, mapsApi } from '../../api/client' import { accommodationsApi, mapsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types' import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
function renderLucideIcon(icon:LucideIcon, props = {}) { function renderLucideIcon(icon:LucideIcon, props = {}) {
if (!_renderToStaticMarkup) return '' if (!_renderToStaticMarkup) return ''
@@ -97,12 +96,12 @@ async function fetchPlacePhotos(assignments) {
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean) const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()] const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
const toFetch = unique.filter(p => !p.image_url && (p.google_place_id || p.osm_id)) const toFetch = unique.filter(p => !p.image_url && p.google_place_id)
await Promise.allSettled( await Promise.allSettled(
toFetch.map(async (place) => { toFetch.map(async (place) => {
try { try {
const data = await mapsApi.placePhoto(place.google_place_id || place.osm_id, place.lat, place.lng, place.name) const data = await mapsApi.placePhoto(place.google_place_id)
if (data.photoUrl) photoMap[place.id] = data.photoUrl if (data.photoUrl) photoMap[place.id] = data.photoUrl
} catch {} } catch {}
}) })
@@ -141,58 +140,23 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const totalCost = Object.values(assignments || {}) const totalCost = Object.values(assignments || {})
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0) .flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
// Span helpers for multi-day transport (mirrors DayPlanSidebar logic)
const pdfGetDayOrder = (d: Day) => d.day_number
const pdfGetSpanPhase = (r: any, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
const startId = r.day_id
const endId = r.end_day_id ?? startId
if (!startId || startId === endId) return 'single'
if (dayId === startId) return 'start'
if (dayId === endId) return 'end'
return 'middle'
}
const pdfGetDisplayTime = (r: any, dayId: number): string | null => {
const phase = pdfGetSpanPhase(r, dayId)
if (phase === 'end') return r.reservation_end_time || null
if (phase === 'middle') return null
return r.reservation_time || null
}
const pdfGetSpanLabel = (r: any, phase: string): string | null => {
if (phase === 'single') return null
if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
}
const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => {
if (r.type === 'hotel') return false
const startId = r.day_id
const endId = r.end_day_id ?? startId
if (startId == null) return false
if (endId !== startId) {
const startDay = sorted.find(d => d.id === startId)
const endDay = sorted.find(d => d.id === endId)
const thisDay = sorted.find(d => d.id === dayId)
if (!startDay || !endDay || !thisDay) return false
return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay)
}
return startId === dayId
})
// Build day HTML // Build day HTML
const daysHtml = sorted.map((day, di) => { const daysHtml = sorted.map((day, di) => {
const assigned = assignments[String(day.id)] || [] const assigned = assignments[String(day.id)] || []
const notes = (dayNotes || []).filter(n => n.day_id === day.id) const notes = (dayNotes || []).filter(n => n.day_id === day.id)
const cost = dayCost(assignments, day.id, loc) const cost = dayCost(assignments, day.id, loc)
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only) // Reservations for this day (hotel rendered via accommodations block)
const dayReservations = pdfGetTransportForDay(day.id) const dayReservations = (reservations || []).filter(r => {
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle')) if (!r.reservation_time || r.type === 'hotel') return false
return day.date && r.reservation_time.split('T')[0] === day.date
})
const merged = [] const merged = []
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a })) assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n })) notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
dayReservations.forEach(r => { dayReservations.forEach(r => {
const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5) const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
merged.push({ type: 'reservation', k: pos, data: r }) merged.push({ type: 'reservation', k: pos, data: r })
}) })
merged.sort((a, b) => a.k - b.k) merged.sort((a, b) => a.k - b.k)
@@ -213,17 +177,13 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ') else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ') else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
const locationLine = r.location || meta.location || '' const locationLine = r.location || meta.location || ''
const phase = pdfGetSpanPhase(r, day.id) const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
const spanLabel = pdfGetSpanLabel(r, phase)
const displayTime = pdfGetDisplayTime(r, day.id)
const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
return ` return `
<div class="note-card" style="border-left: 3px solid ${color};"> <div class="note-card" style="border-left: 3px solid ${color};">
<div class="note-line" style="background: ${color};"></div> <div class="note-line" style="background: ${color};"></div>
<span class="note-icon">${icon}</span> <span class="note-icon">${icon}</span>
<div class="note-body"> <div class="note-body">
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div> <div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''} ${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''} ${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''} ${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
@@ -286,12 +246,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
}).join('') }).join('')
const accommodationsForDay = (accommodations.accommodations || []).filter(a => const accommodationsForDay = (accommodations.accommodations || []).filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
).sort((a, b) => { ).sort((a, b) => a.start_day_id - b.start_day_id)
const startA = days.find(d => d.id === a.start_day_id)
const startB = days.find(d => d.id === b.start_day_id)
return (startA ? getDayOrder(startA, days) : 0) - (startB ? getDayOrder(startB, days) : 0)
})
const accommodationDetails = accommodationsForDay.map(item => { const accommodationDetails = accommodationsForDay.map(item => {
const isCheckIn = day.id === item.start_day_id const isCheckIn = day.id === item.start_day_id
@@ -565,7 +521,7 @@ ${daysHtml}
const iframe = document.createElement('iframe') const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;' iframe.style.cssText = 'flex:1;width:100%;border:none;'
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts' iframe.sandbox = 'allow-same-origin allow-modals'
iframe.srcdoc = html iframe.srcdoc = html
card.appendChild(header) card.appendChild(header)
@@ -1,103 +0,0 @@
import React, { useEffect, useRef, useState } from 'react'
import { Package } from 'lucide-react'
import { adminApi, packingApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
interface Template {
id: number
name: string
item_count: number
}
interface ApplyTemplateButtonProps {
tripId: number
style: React.CSSProperties
className?: string
}
// Dropdown-Button um ein Packing-Template auf den aktuellen Trip anzuwenden.
// Rendert nichts wenn keine Templates existieren.
export default function ApplyTemplateButton({ tripId, style, className }: ApplyTemplateButtonProps): React.ReactElement | null {
const [templates, setTemplates] = useState<Template[]>([])
const [open, setOpen] = useState(false)
const [applying, setApplying] = useState(false)
const dropRef = useRef<HTMLDivElement>(null)
const toast = useToast()
const { t } = useTranslation()
useEffect(() => {
adminApi.packingTemplates().then(d => setTemplates(d.templates || [])).catch(() => {})
}, [tripId])
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (dropRef.current && !dropRef.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const handleApply = async (templateId: number) => {
setApplying(true)
try {
const data = await packingApi.applyTemplate(tripId, templateId)
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(data.items || [])] }))
toast.success(t('packing.templateApplied', { count: data.count }))
setOpen(false)
} catch {
toast.error(t('packing.templateError'))
} finally {
setApplying(false)
}
}
if (templates.length === 0) return null
return (
<div ref={dropRef} style={{ position: 'relative' }}>
<button
onClick={() => setOpen(v => !v)}
disabled={applying}
className={className ?? 'hover:opacity-[0.88]'}
style={style}
>
<Package size={14} strokeWidth={2.5} />
<span className="hidden sm:inline">{t('packing.applyTemplate')}</span>
</button>
{open && (
<div
className="trek-menu-enter"
style={{
position: 'absolute', right: 0, top: '100%', marginTop: 6, zIndex: 50,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220,
transformOrigin: 'top right',
}}
>
{templates.map(tmpl => (
<button key={tmpl.id} onClick={() => handleApply(tmpl.id)}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '8px 12px', borderRadius: 8, border: 'none', cursor: 'pointer',
background: 'transparent', fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Package size={13} style={{ color: 'var(--text-faint)' }} />
<div style={{ flex: 1, textAlign: 'left' }}>
<div style={{ fontWeight: 600 }}>{tmpl.name}</div>
<div style={{ fontSize: 10, color: 'var(--text-faint)' }}>
{tmpl.item_count} {t('admin.packingTemplates.items')}
</div>
</div>
</button>
))}
</div>
)}
</div>
)
}
@@ -208,14 +208,9 @@ interface ArtikelZeileProps {
canEdit?: boolean canEdit?: boolean
} }
// A category's first item is seeded with this sentinel because the server
// rejects empty names. Treat it as a placeholder in the UI.
const PACKING_PLACEHOLDER_NAME = '...'
function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) { function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name) const [editName, setEditName] = useState(item.name)
const [hovered, setHovered] = useState(false) const [hovered, setHovered] = useState(false)
const [showCatPicker, setShowCatPicker] = useState(false) const [showCatPicker, setShowCatPicker] = useState(false)
const [showBagPicker, setShowBagPicker] = useState(false) const [showBagPicker, setShowBagPicker] = useState(false)
@@ -228,7 +223,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked) const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
const handleSaveName = async () => { const handleSaveName = async () => {
if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return } if (!editName.trim()) { setEditing(false); setEditName(item.name); return }
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) } try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
catch { toast.error(t('packing.toast.saveError')) } catch { toast.error(t('packing.toast.saveError')) }
} }
@@ -258,32 +253,18 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
}} }}
> >
<button onClick={handleToggle} style={{ <button onClick={handleToggle} style={{
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, position: 'relative', flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex',
width: 18, height: 18, color: item.checked ? '#10b981' : 'var(--text-faint)', transition: 'color 0.15s',
color: item.checked ? '#10b981' : 'var(--text-faint)',
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
}}> }}>
<Square size={18} style={{ {item.checked ? <CheckSquare size={18} /> : <Square size={18} />}
position: 'absolute', inset: 0,
opacity: item.checked ? 0 : 1,
transform: item.checked ? 'scale(0.7)' : 'scale(1)',
transition: 'opacity 180ms cubic-bezier(0.23,1,0.32,1), transform 180ms cubic-bezier(0.23,1,0.32,1)',
}} />
<CheckSquare size={18} style={{
position: 'absolute', inset: 0,
opacity: item.checked ? 1 : 0,
transform: item.checked ? 'scale(1)' : 'scale(0.5)',
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 220ms cubic-bezier(0.34,1.56,0.64,1)',
}} />
</button> </button>
{editing && canEdit ? ( {editing && canEdit ? (
<input <input
type="text" value={editName} autoFocus type="text" value={editName} autoFocus
placeholder={isPlaceholder ? '...' : undefined}
onChange={e => setEditName(e.target.value)} onChange={e => setEditName(e.target.value)}
onBlur={handleSaveName} onBlur={handleSaveName}
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }} onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(item.name) } }}
style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }} style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
/> />
) : ( ) : (
@@ -292,8 +273,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
style={{ style={{
flex: 1, fontSize: 13.5, flex: 1, fontSize: 13.5,
cursor: !canEdit || item.checked ? 'default' : 'text', cursor: !canEdit || item.checked ? 'default' : 'text',
color: isPlaceholder ? 'var(--text-faint)' : (item.checked ? 'var(--text-faint)' : 'var(--text-primary)'), color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
textDecoration: item.checked ? 'line-through' : 'none', textDecoration: item.checked ? 'line-through' : 'none',
}} }}
> >
@@ -749,13 +729,9 @@ function MenuItem({ icon, label, onClick, danger }: MenuItemProps) {
interface PackingListPanelProps { interface PackingListPanelProps {
tripId: number tripId: number
items: PackingItem[] items: PackingItem[]
openImportSignal?: number
clearCheckedSignal?: number
saveTemplateSignal?: number
inlineHeader?: boolean
} }
export default function PackingListPanel({ tripId, items, openImportSignal = 0, clearCheckedSignal = 0, saveTemplateSignal = 0, inlineHeader = true }: PackingListPanelProps) { export default function PackingListPanel({ tripId, items }: PackingListPanelProps) {
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt' const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
const [addingCategory, setAddingCategory] = useState(false) const [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('') const [newCatName, setNewCatName] = useState('')
@@ -920,31 +896,6 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
const [saveTemplateName, setSaveTemplateName] = useState('') const [saveTemplateName, setSaveTemplateName] = useState('')
const [showImportModal, setShowImportModal] = useState(false) const [showImportModal, setShowImportModal] = useState(false)
const [importText, setImportText] = useState('') const [importText, setImportText] = useState('')
const lastHandledImportSignal = useRef(openImportSignal)
const lastHandledClearSignal = useRef(clearCheckedSignal)
const lastHandledSaveSignal = useRef(saveTemplateSignal)
useEffect(() => {
if (openImportSignal !== lastHandledImportSignal.current && openImportSignal > 0) {
setShowImportModal(true)
}
lastHandledImportSignal.current = openImportSignal
}, [openImportSignal])
useEffect(() => {
if (clearCheckedSignal !== lastHandledClearSignal.current && clearCheckedSignal > 0) {
handleClearChecked()
}
lastHandledClearSignal.current = clearCheckedSignal
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clearCheckedSignal])
useEffect(() => {
if (saveTemplateSignal !== lastHandledSaveSignal.current && saveTemplateSignal > 0) {
setShowSaveTemplate(true)
}
lastHandledSaveSignal.current = saveTemplateSignal
}, [saveTemplateSignal])
const csvInputRef = useRef<HTMLInputElement>(null) const csvInputRef = useRef<HTMLInputElement>(null)
const templateDropdownRef = useRef<HTMLDivElement>(null) const templateDropdownRef = useRef<HTMLDivElement>(null)
@@ -965,9 +916,10 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
setApplyingTemplate(true) setApplyingTemplate(true)
try { try {
const data = await packingApi.applyTemplate(tripId, templateId) const data = await packingApi.applyTemplate(tripId, templateId)
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(data.items || [])] }))
toast.success(t('packing.templateApplied', { count: data.count })) toast.success(t('packing.templateApplied', { count: data.count }))
setShowTemplateDropdown(false) setShowTemplateDropdown(false)
// Reload packing items
window.location.reload()
} catch { } catch {
toast.error(t('packing.templateError')) toast.error(t('packing.templateError'))
} finally { } finally {
@@ -1025,10 +977,10 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
if (parsed.length === 0) { toast.error(t('packing.importEmpty')); return } if (parsed.length === 0) { toast.error(t('packing.importEmpty')); return }
try { try {
const result = await packingApi.bulkImport(tripId, parsed) const result = await packingApi.bulkImport(tripId, parsed)
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(result.items || [])] }))
toast.success(t('packing.importSuccess', { count: result.count })) toast.success(t('packing.importSuccess', { count: result.count }))
setImportText('') setImportText('')
setShowImportModal(false) setShowImportModal(false)
window.location.reload()
} catch { toast.error(t('packing.importError')) } } catch { toast.error(t('packing.importError')) }
} }
@@ -1047,43 +999,16 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
{/* ── Header ── */} {/* ── Header ── */}
<div style={{ padding: inlineHeader ? '20px 24px 16px' : '0 0 16px', flexShrink: 0, borderBottom: inlineHeader ? '1px solid rgba(0,0,0,0.06)' : undefined }}> <div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: inlineHeader ? 'flex-start' : 'center', justifyContent: 'space-between', gap: 14 }}> <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 14 }}>
{inlineHeader ? ( <div>
<div> <h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2> <p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{items.length > 0 && ( {items.length === 0 ? t('packing.empty') : t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}> </p>
{t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })} </div>
</p> <div style={{ display: 'flex', gap: 6 }}>
)} {canEdit && abgehakt > 0 && (
</div>
) : <span />}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{canEdit && items.length > 0 && showSaveTemplate && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input
type="text" autoFocus
value={saveTemplateName}
onChange={e => setSaveTemplateName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }}
placeholder={t('packing.templateName')}
style={{ fontSize: 12, padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }}
/>
<button onClick={handleSaveAsTemplate} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#10b981' }}><Check size={14} /></button>
<button onClick={() => { setShowSaveTemplate(false); setSaveTemplateName('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}><X size={14} /></button>
</div>
)}
{inlineHeader && canEdit && (
<button onClick={() => setShowImportModal(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
}}>
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
</button>
)}
{inlineHeader && canEdit && abgehakt > 0 && (
<button onClick={handleClearChecked} style={{ <button onClick={handleClearChecked} style={{
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)', fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit', background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
@@ -1092,7 +1017,16 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span> <span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
</button> </button>
)} )}
{inlineHeader && canEdit && availableTemplates.length > 0 && ( {canEdit && (
<button onClick={() => setShowImportModal(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
}}>
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
</button>
)}
{canEdit && availableTemplates.length > 0 && (
<div ref={templateDropdownRef} style={{ position: 'relative' }}> <div ref={templateDropdownRef} style={{ position: 'relative' }}>
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{ <button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99, display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
@@ -1131,14 +1065,31 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
)} )}
</div> </div>
)} )}
{inlineHeader && canEdit && items.length > 0 && !showSaveTemplate && ( {canEdit && items.length > 0 && (
<button onClick={() => setShowSaveTemplate(true)} style={{ <div style={{ position: 'relative' }}>
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99, {showSaveTemplate ? (
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
background: 'var(--bg-card)', color: 'var(--text-muted)', <input
}}> type="text" autoFocus
<FolderPlus size={12} /> <span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span> value={saveTemplateName}
</button> onChange={e => setSaveTemplateName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }}
placeholder={t('packing.templateName')}
style={{ fontSize: 12, padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }}
/>
<button onClick={handleSaveAsTemplate} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#10b981' }}><Check size={14} /></button>
<button onClick={() => { setShowSaveTemplate(false); setSaveTemplateName('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}><X size={14} /></button>
</div>
) : (
<button onClick={() => setShowSaveTemplate(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
background: 'var(--bg-card)', color: 'var(--text-muted)',
}}>
<FolderPlus size={12} /> <span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span>
</button>
)}
</div>
)} )}
{bagTrackingEnabled && ( {bagTrackingEnabled && (
<button onClick={() => setShowBagModal(true)} className="xl:!hidden" <button onClick={() => setShowBagModal(true)} className="xl:!hidden"
@@ -1156,69 +1107,17 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
</div> </div>
{items.length > 0 && ( {items.length > 0 && (
<div className="hidden sm:block" style={{ marginTop: 14, marginBottom: 14 }}> <div style={{ marginBottom: 14 }}>
<div className="flex items-center" style={{ gap: 14 }}> <div style={{ height: 5, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
{fortschritt === 100 ? (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
fontSize: 16, fontWeight: 700, color: '#10b981',
letterSpacing: '-0.01em', flexShrink: 0,
}}>
<CheckCheck size={18} strokeWidth={2.5} />
<span>{t('packing.allPacked')}</span>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'baseline' }}>
<span style={{
fontSize: 22, fontWeight: 700, color: 'var(--text-primary)',
fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em',
lineHeight: 1,
}}>{abgehakt}</span>
<span style={{
fontSize: 14, fontWeight: 500, color: 'var(--text-faint)',
fontVariantNumeric: 'tabular-nums', lineHeight: 1, marginLeft: 1,
}}>/{items.length}</span>
</div>
<span style={{
fontSize: 11, fontWeight: 600, padding: '2px 7px',
borderRadius: 99, background: 'var(--bg-tertiary)',
color: 'var(--text-muted)',
fontVariantNumeric: 'tabular-nums',
lineHeight: 1.4,
}}>{fortschritt}%</span>
</div>
)}
<div style={{ <div style={{
flex: 1, height: '100%', borderRadius: 99, transition: 'width 0.4s ease',
height: 8, background: fortschritt === 100 ? '#10b981' : 'linear-gradient(90deg, var(--text-primary) 0%, var(--text-muted) 100%)',
background: 'var(--bg-tertiary)', width: `${fortschritt}%`,
borderRadius: 99, }} />
overflow: 'hidden',
position: 'relative',
width: '100%',
}}>
<div style={{
height: '100%',
borderRadius: 99,
transition: 'width 600ms cubic-bezier(0.23, 1, 0.32, 1), background 400ms ease, box-shadow 400ms ease',
background: fortschritt === 100
? 'linear-gradient(90deg, #10b981 0%, #34d399 100%)'
: 'var(--accent)',
width: `${fortschritt}%`,
boxShadow: fortschritt === 100 ? '0 0 14px rgba(16,185,129,0.45)' : 'none',
position: 'relative',
}}>
<div style={{
position: 'absolute', inset: 0,
background: 'linear-gradient(180deg, rgba(255,255,255,0.22) 0%, rgba(255,255,255,0) 55%)',
borderRadius: 99,
pointerEvents: 'none',
}} />
</div>
</div>
</div> </div>
{fortschritt === 100 && (
<p style={{ fontSize: 11.5, color: '#10b981', marginTop: 4, fontWeight: 600, margin: '4px 0 0' }}>{t('packing.allPacked')}</p>
)}
</div> </div>
)} )}
@@ -1252,7 +1151,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
{/* ── Filter-Tabs ── */} {/* ── Filter-Tabs ── */}
{items.length > 0 && ( {items.length > 0 && (
<div style={{ display: 'flex', gap: 4, padding: '10px 0 0', flexShrink: 0 }}> <div style={{ display: 'flex', gap: 4, padding: '10px 16px 0', flexShrink: 0 }}>
{[['alle', t('packing.filterAll')], ['offen', t('packing.filterOpen')], ['erledigt', t('packing.filterDone')]].map(([id, label]) => ( {[['alle', t('packing.filterAll')], ['offen', t('packing.filterOpen')], ['erledigt', t('packing.filterDone')]].map(([id, label]) => (
<button key={id} onClick={() => setFilter(id)} style={{ <button key={id} onClick={() => setFilter(id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer',
@@ -1266,7 +1165,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
{/* ── Liste + Bags Sidebar ── */} {/* ── Liste + Bags Sidebar ── */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}> <div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}> <div style={{ flex: 1, overflowY: 'auto', padding: '10px 12px 16px' }}>
{items.length === 0 ? ( {items.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}> <div style={{ textAlign: 'center', padding: '60px 20px' }}>
<Luggage size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 10px' }} /> <Luggage size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 10px' }} />
@@ -892,277 +892,6 @@ describe('DayDetailPanel', () => {
expect(screen.getByText(/June|15/i)).toBeInTheDocument(); expect(screen.getByText(/June|15/i)).toBeInTheDocument();
}); });
// ── Accommodation date-range picker — non-monotonic day IDs (issue #889) ─────
// Builds the reporter's exact ID layout: day_number 1-9 → IDs 17-25, day_number 10-16 → IDs 1-7.
// This happens after repeated trip-length changes via generateDays (no import/migration needed).
function buildNonMonotonicDays() {
return [
buildDay({ id: 17, trip_id: 1, date: '2026-04-30' }),
buildDay({ id: 18, trip_id: 1, date: '2026-05-01' }),
buildDay({ id: 19, trip_id: 1, date: '2026-05-02' }),
buildDay({ id: 20, trip_id: 1, date: '2026-05-03' }),
buildDay({ id: 21, trip_id: 1, date: '2026-05-04' }),
buildDay({ id: 22, trip_id: 1, date: '2026-05-05' }),
buildDay({ id: 23, trip_id: 1, date: '2026-05-06' }),
buildDay({ id: 24, trip_id: 1, date: '2026-05-07' }),
buildDay({ id: 25, trip_id: 1, date: '2026-05-08' }),
buildDay({ id: 1, trip_id: 1, date: '2026-05-09' }),
buildDay({ id: 2, trip_id: 1, date: '2026-05-10' }),
buildDay({ id: 3, trip_id: 1, date: '2026-05-11' }),
buildDay({ id: 4, trip_id: 1, date: '2026-05-12' }),
buildDay({ id: 5, trip_id: 1, date: '2026-05-13' }),
buildDay({ id: 6, trip_id: 1, date: '2026-05-14' }),
buildDay({ id: 7, trip_id: 1, date: '2026-05-15' }),
];
}
// Returns the two CustomSelect trigger buttons for start/end day pickers.
// When no dropdown is open, these are the only globally-visible buttons whose textContent
// matches /Day \d+/ (the main panel title is a div, not a button).
// [0] = start trigger, [1] = end trigger (DOM source order).
function getDayPickerTriggers() {
return screen.getAllByRole('button').filter(b => /Day \d+/.test(b.textContent ?? ''));
}
it('FE-PLANNER-DAYDETAIL-056: non-monotonic IDs — end picker does not clobber start-day', async () => {
const days = buildNonMonotonicDays();
const place = buildPlace({ id: 50, name: 'Range Hotel' });
let capturedBody: any;
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({
accommodation: {
id: 99, place_id: 50, place_name: 'Range Hotel', place_address: null,
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
check_in: null, check_out: null, confirmation: null,
},
});
}),
);
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Range Hotel/i }));
// Both triggers show "Day 1"; the second one is the end picker.
await userEvent.click(getDayPickerTriggers()[1]);
// Select "Day 16" (id=7) from the open dropdown — textContent starts with "Day 16".
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => {
// start must remain id 17 (day 1) — old code would clobber it to id 7 via Math.min
expect(capturedBody?.start_day_id).toBe(17);
expect(capturedBody?.end_day_id).toBe(7);
});
});
it('FE-PLANNER-DAYDETAIL-057: non-monotonic IDs — start picker does not collapse end when start has high ID', async () => {
const days = buildNonMonotonicDays();
const place = buildPlace({ id: 51, name: 'Span Hotel' });
let capturedBody: any;
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({
accommodation: {
id: 100, place_id: 51, place_name: 'Span Hotel', place_address: null,
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
check_in: null, check_out: null, confirmation: null,
},
});
}),
);
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Span Hotel/i }));
// Set end to day 16 (id=7, low ID but last day by position).
await userEvent.click(getDayPickerTriggers()[1]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
// Set start to day 9 (id=25, high ID, but earlier by position than day 16).
// Old code: Math.max(25, 7) = 25 → end collapses to day 9.
// New code: position(id=25)=8 < position(id=7)=15 → end stays at 7 (day 16).
await userEvent.click(getDayPickerTriggers()[0]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 9'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => {
expect(capturedBody?.start_day_id).toBe(25); // day 9
expect(capturedBody?.end_day_id).toBe(7); // day 16 — must NOT have collapsed
});
});
it('FE-PLANNER-DAYDETAIL-058: non-monotonic IDs — All days button sets correct first/last IDs', async () => {
const days = buildNonMonotonicDays();
const place = buildPlace({ id: 52, name: 'Full Trip Hotel' });
let capturedBody: any;
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({
accommodation: {
id: 101, place_id: 52, place_name: 'Full Trip Hotel', place_address: null,
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
check_in: null, check_out: null, confirmation: null,
},
});
}),
);
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Full Trip Hotel/i }));
// "All" is the day.allDays translation (en: "All") — the Apply-to-entire-trip button.
// When categories=[] the category-filter "All" button is not rendered, so this is unique.
await userEvent.click(screen.getByRole('button', { name: /^All$/i }));
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => {
// days[0].id=17 (first by position), days[15].id=7 (last by position)
expect(capturedBody?.start_day_id).toBe(17);
expect(capturedBody?.end_day_id).toBe(7);
});
});
it('FE-PLANNER-DAYDETAIL-059: sequential IDs — end picker clamping still works (regression guard)', async () => {
const seqDays = [
buildDay({ id: 101, trip_id: 1, date: '2026-06-01' }),
buildDay({ id: 102, trip_id: 1, date: '2026-06-02' }),
buildDay({ id: 103, trip_id: 1, date: '2026-06-03' }),
];
const place = buildPlace({ id: 53, name: 'Seq Hotel' });
let capturedBody: any;
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({
accommodation: {
id: 102, place_id: 53, place_name: 'Seq Hotel', place_address: null,
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
check_in: null, check_out: null, confirmation: null,
},
});
}),
);
render(<DayDetailPanel {...defaultProps} day={seqDays[0]} days={seqDays} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Seq Hotel/i }));
// Pick end = day 3 (id=103, position 2 > position 0 of start id=101).
await userEvent.click(getDayPickerTriggers()[1]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 3'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => {
expect(capturedBody?.start_day_id).toBe(101);
expect(capturedBody?.end_day_id).toBe(103);
});
});
// ── Post-save state filter — non-monotonic IDs (issue #889 follow-up) ────────
it('FE-PLANNER-DAYDETAIL-060: non-monotonic IDs — hotel stays visible after edit-save (issue #889 regression)', async () => {
const days = buildNonMonotonicDays();
let getCallCount = 0;
server.use(
http.get('/api/trips/1/accommodations', () => {
getCallCount++;
const acc = getCallCount === 1
// Initial load: single-day so old filter (17>=17 && 17<=17) passes — hotel visible, edit possible
? { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null, start_day_id: 17, end_day_id: 17, check_in: null, check_out: null, confirmation: null }
// Post-save relist: full span — old filter (17>=17 && 17<=7) would drop it, new code keeps it
: { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null, start_day_id: 17, end_day_id: 7, check_in: null, check_out: null, confirmation: null };
return HttpResponse.json({ accommodations: [acc] });
}),
http.put('/api/trips/1/accommodations/1', async ({ request }) => {
const body = await request.json() as any;
return HttpResponse.json({
accommodation: { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null,
start_day_id: body.start_day_id, end_day_id: body.end_day_id,
check_in: null, check_out: null, confirmation: null },
});
}),
);
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} />);
await screen.findByText('Span Hotel');
// Pencil = 3rd button (index 2): collapse, close, pencil, remove
const allButtons = screen.getAllByRole('button');
await userEvent.click(allButtons[2]);
// Extend end picker to Day 16 (id=7)
await userEvent.click(getDayPickerTriggers()[1]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
// Old code: 17>=17 && 17<=7 → false (hotel vanishes). New code: position 0 in [0,15] → visible.
await waitFor(() => {
expect(screen.getByText('Span Hotel')).toBeInTheDocument();
});
});
it('FE-PLANNER-DAYDETAIL-061: non-monotonic IDs — hotel appears after create-save on intermediate day', async () => {
const days = buildNonMonotonicDays();
const place = buildPlace({ id: 55, name: 'Created Hotel' });
// Current day: days[5] = id 22, position 5 (within any full-span range)
const currentDay = days[5];
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
const body = await request.json() as any;
return HttpResponse.json({
accommodation: { id: 200, place_id: 55, place_name: 'Created Hotel', place_address: null,
start_day_id: body.start_day_id, end_day_id: body.end_day_id,
check_in: null, check_out: null, confirmation: null },
});
}),
);
render(<DayDetailPanel {...defaultProps} day={currentDay} days={days} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Created Hotel/i }));
// Extend end to Day 16 (id=7) — start stays at current day id=22
await userEvent.click(getDayPickerTriggers()[1]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
// Old code: 22>=22 && 22<=7 → false (hotel vanishes). New code: position 5 in [5,15] → visible.
await waitFor(() => {
expect(screen.getByText('Created Hotel')).toBeInTheDocument();
});
});
it('FE-PLANNER-DAYDETAIL-062: non-monotonic IDs — hotel shown on initial load when it spans the full trip', async () => {
const days = buildNonMonotonicDays();
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{ id: 1, place_id: 60, place_name: 'Full Trip Hotel', place_address: null,
start_day_id: 17, end_day_id: 7, check_in: null, check_out: null, confirmation: null }],
})
),
);
// Day 1 (id=17): old filter: 17>=17 && 17<=7 → false. New: position 0 in [0,15] → visible.
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} />);
await screen.findByText('Full Trip Hotel');
// Intermediate day (id=1, position 9): old filter: 1>=17 → false. New: 9 in [0,15] → visible.
render(<DayDetailPanel {...defaultProps} day={days[9]} days={days} />);
await screen.findByText('Full Trip Hotel');
});
it('FE-PLANNER-DAYDETAIL-040: 12h time format renders reservation time with AM/PM', async () => { it('FE-PLANNER-DAYDETAIL-040: 12h time format renders reservation time with AM/PM', async () => {
seedStore(useSettingsStore, { seedStore(useSettingsStore, {
settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false }, settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false },
@@ -12,7 +12,6 @@ import CustomTimePicker from '../shared/CustomTimePicker'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { getLocaleForLanguage, useTranslation } from '../../i18n' import { getLocaleForLanguage, useTranslation } from '../../i18n'
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types' import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
import { isDayInAccommodationRange } from '../../utils/dayOrder'
const WEATHER_ICON_MAP = { const WEATHER_ICON_MAP = {
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle, Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
@@ -67,11 +66,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit' const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
const is12h = useSettingsStore(s => s.settings.time_format) === '12h' const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes) const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
const fmtTime = (v) => { const fmtTime = (v) => formatTime12(v, is12h)
if (!v) return v
if (v.includes('T')) return new Date(v).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
return formatTime12(v, is12h)
}
const unit = isFahrenheit ? '°F' : '°C' const unit = isFahrenheit ? '°F' : '°C'
const collapsed = collapsedProp const collapsed = collapsedProp
const toggleCollapse = () => onToggleCollapse?.() const toggleCollapse = () => onToggleCollapse?.()
@@ -100,7 +95,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
.then(data => { .then(data => {
setAccommodations(data.accommodations || []) setAccommodations(data.accommodations || [])
const allForDay = (data.accommodations || []).filter(a => const allForDay = (data.accommodations || []).filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
) )
setDayAccommodations(allForDay) setDayAccommodations(allForDay)
setAccommodation(allForDay[0] || null) setAccommodation(allForDay[0] || null)
@@ -131,7 +126,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
setAccommodations(updated) setAccommodations(updated)
setAccommodation(newAcc) setAccommodation(newAcc)
setDayAccommodations(updated.filter(a => setDayAccommodations(updated.filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
)) ))
setShowHotelPicker(false) setShowHotelPicker(false)
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null }) setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
@@ -155,7 +150,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const updated = accommodations.filter(a => a.id !== accommodation.id) const updated = accommodations.filter(a => a.id !== accommodation.id)
setAccommodations(updated) setAccommodations(updated)
setDayAccommodations(updated.filter(a => setDayAccommodations(updated.filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
)) ))
setAccommodation(null) setAccommodation(null)
onAccommodationChange?.() onAccommodationChange?.()
@@ -173,7 +168,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" } const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return ( return (
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}> <div className="fixed z-50 bottom-[96px] md:bottom-5" style={{ left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
<div style={{ <div style={{
background: 'var(--bg-elevated)', background: 'var(--bg-elevated)',
backdropFilter: 'blur(40px) saturate(180%)', backdropFilter: 'blur(40px) saturate(180%)',
@@ -464,13 +459,10 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<CustomSelect <CustomSelect
value={hotelDayRange.start} value={hotelDayRange.start}
onChange={v => setHotelDayRange(prev => ({ start: v, end: days.findIndex(d => d.id === v) > days.findIndex(d => d.id === prev.end) ? v : prev.end }))} onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
options={days.map((d, i) => ({ options={days.map((d, i) => ({
value: d.id, value: d.id,
label: d.title || t('planner.dayN', { n: i + 1 }), label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
badge: d.date
? new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
: (d.title ? t('planner.dayN', { n: i + 1 }) : undefined),
}))} }))}
size="sm" size="sm"
/> />
@@ -479,13 +471,10 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<CustomSelect <CustomSelect
value={hotelDayRange.end} value={hotelDayRange.end}
onChange={v => setHotelDayRange(prev => ({ start: days.findIndex(d => d.id === v) < days.findIndex(d => d.id === prev.start) ? v : prev.start, end: v }))} onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
options={days.map((d, i) => ({ options={days.map((d, i) => ({
value: d.id, value: d.id,
label: d.title || t('planner.dayN', { n: i + 1 }), label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
badge: d.date
? new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
: (d.title ? t('planner.dayN', { n: i + 1 }) : undefined),
}))} }))}
size="sm" size="sm"
/> />
@@ -599,9 +588,9 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const all = d.accommodations || [] const all = d.accommodations || []
setAccommodations(all) setAccommodations(all)
setDayAccommodations(all.filter(a => setDayAccommodations(all.filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id)
)) ))
const acc = all.find(a => day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false) const acc = all.find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
setAccommodation(acc || null) setAccommodation(acc || null)
}) })
onAccommodationChange?.() onAccommodationChange?.()
@@ -187,7 +187,7 @@ describe('DayPlanSidebar', () => {
const assignments = { '10': [assignment] } const assignments = { '10': [assignment] }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />) render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />)
// The chevron button immediately follows the "Add Note" button (which has a title attribute) // The chevron button immediately follows the "Add Note" button (which has a title attribute)
const addNoteBtn = screen.getByLabelText('Add Note') const addNoteBtn = screen.getByTitle('Add Note')
const chevron = addNoteBtn.nextElementSibling as HTMLButtonElement const chevron = addNoteBtn.nextElementSibling as HTMLButtonElement
expect(chevron).toBeTruthy() expect(chevron).toBeTruthy()
await user.click(chevron) await user.click(chevron)
@@ -201,7 +201,7 @@ describe('DayPlanSidebar', () => {
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
const assignments = { '10': [assignment] } const assignments = { '10': [assignment] }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />) render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />)
const getChevron = () => screen.getByLabelText('Add Note').nextElementSibling as HTMLButtonElement const getChevron = () => screen.getByTitle('Add Note').nextElementSibling as HTMLButtonElement
await user.click(getChevron()) // collapse await user.click(getChevron()) // collapse
expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument() expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument()
await user.click(getChevron()) // re-expand await user.click(getChevron()) // re-expand
@@ -362,14 +362,28 @@ describe('DayPlanSidebar', () => {
const user = userEvent.setup() const user = userEvent.setup()
const onUndo = vi.fn() const onUndo = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({ canUndo: true, lastActionLabel: 'Removed place', onUndo })} />) render(<DayPlanSidebar {...makeDefaultProps({ canUndo: true, lastActionLabel: 'Removed place', onUndo })} />)
const undoBtn = screen.getByLabelText('Undo') // Find the undo button — it has width 30, height 30 and is not disabled
await user.click(undoBtn) const buttons = screen.getAllByRole('button')
expect(onUndo).toHaveBeenCalled() // The undo button is the one with the Undo2 icon and is not disabled
const undoBtn = buttons.find(btn => {
const style = btn.getAttribute('style') || ''
return style.includes('width: 30px') || style.includes('width:30px') || (style.includes('30') && !btn.disabled)
})
if (undoBtn) {
await user.click(undoBtn)
expect(onUndo).toHaveBeenCalled()
}
}) })
it('FE-PLANNER-DAYPLAN-024: undo button not present when onUndo not provided', () => { it('FE-PLANNER-DAYPLAN-024: undo button not present when onUndo not provided', () => {
render(<DayPlanSidebar {...makeDefaultProps({ canUndo: false })} />) render(<DayPlanSidebar {...makeDefaultProps({ canUndo: false })} />)
expect(screen.queryByLabelText('Undo')).toBeNull() // When onUndo is not provided, the undo section is not rendered at all
const buttons = screen.getAllByRole('button')
const undoBtn = buttons.find(btn => {
const style = btn.getAttribute('style') || ''
return style.includes('width: 30px')
})
expect(undoBtn).toBeUndefined()
}) })
// ── PDF export ────────────────────────────────────────────────────────── // ── PDF export ──────────────────────────────────────────────────────────
@@ -426,27 +440,26 @@ describe('DayPlanSidebar', () => {
type: 'flight', type: 'flight',
title: 'Paris to London', title: 'Paris to London',
reservation_time: '2025-06-01T08:00:00', reservation_time: '2025-06-01T08:00:00',
day_id: 10,
}) })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation] })} />) render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation] })} />)
expect(screen.getByText('Paris to London')).toBeInTheDocument() expect(screen.getByText('Paris to London')).toBeInTheDocument()
}) })
it('FE-PLANNER-DAYPLAN-031: clicking transport item calls onEditTransport', async () => { it('FE-PLANNER-DAYPLAN-031: clicking transport item shows detail modal', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const onEditTransport = vi.fn()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Travel Day' }) const day = buildDay({ id: 10, date: '2025-06-01', title: 'Travel Day' })
const reservation = buildReservation({ const reservation = buildReservation({
id: 200, id: 200,
type: 'flight', type: 'flight',
title: 'Air France 123', title: 'Air France 123',
reservation_time: '2025-06-01T08:00:00', reservation_time: '2025-06-01T08:00:00',
day_id: 10,
}) })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation], onEditTransport })} />) render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation] })} />)
await user.click(screen.getByText('Air France 123')) await user.click(screen.getByText('Air France 123'))
// Detail modal should appear (shows the title again in the modal)
await waitFor(() => { await waitFor(() => {
expect(onEditTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 200 })) const titles = screen.getAllByText('Air France 123')
expect(titles.length).toBeGreaterThan(1)
}) })
}) })
@@ -651,7 +664,6 @@ describe('DayPlanSidebar', () => {
const reservation = buildReservation({ const reservation = buildReservation({
id: 200, type: 'flight', title: 'CDG to LHR', id: 200, type: 'flight', title: 'CDG to LHR',
reservation_time: '2025-06-01T08:00:00', reservation_time: '2025-06-01T08:00:00',
day_id: 10,
}) })
render(<DayPlanSidebar {...makeDefaultProps({ render(<DayPlanSidebar {...makeDefaultProps({
days: [day], days: [day],
@@ -672,8 +684,6 @@ describe('DayPlanSidebar', () => {
id: 201, type: 'flight', title: 'Transatlantic', id: 201, type: 'flight', title: 'Transatlantic',
reservation_time: '2025-06-01T22:00:00', reservation_time: '2025-06-01T22:00:00',
reservation_end_time: '2025-06-02T06:00:00', reservation_end_time: '2025-06-02T06:00:00',
day_id: 10,
end_day_id: 11,
} as any) } as any)
render(<DayPlanSidebar {...makeDefaultProps({ render(<DayPlanSidebar {...makeDefaultProps({
days: [day1, day2], days: [day1, day2],
@@ -694,8 +704,6 @@ describe('DayPlanSidebar', () => {
id: 300, type: 'car', title: 'Renault Rental', id: 300, type: 'car', title: 'Renault Rental',
reservation_time: '2025-06-01T09:00:00', reservation_time: '2025-06-01T09:00:00',
reservation_end_time: '2025-06-03T17:00:00', reservation_end_time: '2025-06-03T17:00:00',
day_id: 10,
end_day_id: 12,
} as any) } as any)
render(<DayPlanSidebar {...makeDefaultProps({ render(<DayPlanSidebar {...makeDefaultProps({
days: [day1, day2, day3], days: [day1, day2, day3],
@@ -778,22 +786,20 @@ describe('DayPlanSidebar', () => {
// ── Transport detail modal with metadata ─────────────────────────────── // ── Transport detail modal with metadata ───────────────────────────────
it('FE-PLANNER-DAYPLAN-051: clicking flight transport calls onEditTransport with reservation', async () => { it('FE-PLANNER-DAYPLAN-051: transport detail modal shows flight metadata', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const onEditTransport = vi.fn()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Travel' }) const day = buildDay({ id: 10, date: '2025-06-01', title: 'Travel' })
const reservation = { const reservation = {
...buildReservation({ ...buildReservation({
id: 202, type: 'flight', title: 'Paris to Berlin', id: 202, type: 'flight', title: 'Paris to Berlin',
reservation_time: '2025-06-01T07:30:00', reservation_time: '2025-06-01T07:30:00',
day_id: 10,
}), }),
metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH1234', departure_airport: 'CDG', arrival_airport: 'BER' }), metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH1234', departure_airport: 'CDG', arrival_airport: 'BER' }),
} }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation as any], onEditTransport })} />) render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation as any] })} />)
await user.click(screen.getByText('Paris to Berlin')) await user.click(screen.getByText('Paris to Berlin'))
await waitFor(() => { await waitFor(() => {
expect(onEditTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 202, type: 'flight' })) expect(screen.getByText('Lufthansa')).toBeInTheDocument()
}) })
}) })
@@ -917,7 +923,7 @@ describe('DayPlanSidebar', () => {
const user = userEvent.setup() const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
const addNoteBtn = screen.getByLabelText('Add Note') const addNoteBtn = screen.getByTitle('Add Note')
await user.click(addNoteBtn) await user.click(addNoteBtn)
expect(mockDayNotesState.openAddNote).toHaveBeenCalled() expect(mockDayNotesState.openAddNote).toHaveBeenCalled()
}) })
@@ -1118,7 +1124,6 @@ describe('DayPlanSidebar', () => {
const flight = buildReservation({ const flight = buildReservation({
id: 201, type: 'flight', title: 'Afternoon Flight', id: 201, type: 'flight', title: 'Afternoon Flight',
reservation_time: '2025-06-01T14:00:00', reservation_time: '2025-06-01T14:00:00',
day_id: 10,
}) })
render(<DayPlanSidebar {...makeDefaultProps({ render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [flight], days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [flight],
@@ -1678,42 +1683,4 @@ describe('DayPlanSidebar', () => {
// Optimize button should not be visible when no day is selected // Optimize button should not be visible when no day is selected
expect(screen.queryByRole('button', { name: /optimize/i })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /optimize/i })).not.toBeInTheDocument()
}) })
// ── Edit reservation pencil button ───────────────────────────────────────
it('FE-PLANNER-DAYPLAN-097: pencil button on non-transport reservation calls onEditReservation', async () => {
const user = userEvent.setup()
const place = buildPlace({ id: 1, name: 'Hotel du Lac' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
const res = buildReservation({ id: 77, trip_id: 1, type: 'hotel', status: 'pending', assignment_id: 99 } as any)
const onEditReservation = vi.fn()
const onEditTransport = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
onEditReservation, onEditTransport,
})} />)
const pencil = screen.getByTitle(/edit/i)
await user.click(pencil)
expect(onEditReservation).toHaveBeenCalledWith(res)
expect(onEditTransport).not.toHaveBeenCalled()
})
it('FE-PLANNER-DAYPLAN-098: pencil button on transport reservation calls onEditTransport', async () => {
const user = userEvent.setup()
const place = buildPlace({ id: 1, name: 'Geneva Airport' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
const res = buildReservation({ id: 88, trip_id: 1, type: 'flight', status: 'pending', assignment_id: 99 } as any)
const onEditReservation = vi.fn()
const onEditTransport = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
onEditReservation, onEditTransport,
})} />)
const pencil = screen.getByTitle(/edit/i)
await user.click(pencil)
expect(onEditTransport).toHaveBeenCalledWith(res)
expect(onEditReservation).not.toHaveBeenCalled()
})
}) })
+147 -420
View File
@@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */ /* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' } interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string }
declare global { interface Window { __dragData: DragDataPayload | null } } declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react' import React, { useState, useEffect, useRef, useMemo } from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react' import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react'
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText } const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
import { assignmentsApi, reservationsApi } from '../../api/client' import { assignmentsApi, reservationsApi } from '../../api/client'
@@ -14,7 +14,6 @@ import PlaceAvatar from '../shared/PlaceAvatar'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu' import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import Markdown from 'react-markdown' import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import WeatherWidget from '../Weather/WeatherWidget' import WeatherWidget from '../Weather/WeatherWidget'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { getCategoryIcon } from '../shared/categoryIcons' import { getCategoryIcon } from '../shared/categoryIcons'
@@ -22,10 +21,8 @@ import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { isDayInAccommodationRange } from '../../utils/dayOrder'
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters' import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
import { useDayNotes } from '../../hooks/useDayNotes' import { useDayNotes } from '../../hooks/useDayNotes'
import Tooltip from '../shared/Tooltip'
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types' import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
const NOTE_ICONS = [ const NOTE_ICONS = [
@@ -186,13 +183,6 @@ interface DayPlanSidebarProps {
canUndo?: boolean canUndo?: boolean
lastActionLabel?: string | null lastActionLabel?: string | null
onUndo?: () => void onUndo?: () => void
onRouteRefresh?: () => void
onAddTransport?: (dayId: number) => void
onEditTransport?: (reservation: Reservation) => void
onEditReservation?: (reservation: Reservation) => void
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
initialScrollTop?: number
onScrollTopChange?: (top: number) => void
} }
const DayPlanSidebar = React.memo(function DayPlanSidebar({ const DayPlanSidebar = React.memo(function DayPlanSidebar({
@@ -216,13 +206,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
canUndo = false, canUndo = false,
lastActionLabel = null, lastActionLabel = null,
onUndo, onUndo,
onRouteRefresh,
onAddTransport,
onEditTransport,
onEditReservation,
onAddBookingToAssignment,
initialScrollTop,
onScrollTopChange,
}: DayPlanSidebarProps) { }: DayPlanSidebarProps) {
const toast = useToast() const toast = useToast()
const { t, language, locale } = useTranslation() const { t, language, locale } = useTranslation()
@@ -252,11 +235,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const [undoHover, setUndoHover] = useState(false) const [undoHover, setUndoHover] = useState(false)
const [pdfHover, setPdfHover] = useState(false) const [pdfHover, setPdfHover] = useState(false)
const [icsHover, setIcsHover] = useState(false) const [icsHover, setIcsHover] = useState(false)
const [hoveredAssignmentId, setHoveredAssignmentId] = useState<number | null>(null)
const [dropTargetKey, _setDropTargetKey] = useState(null) const [dropTargetKey, _setDropTargetKey] = useState(null)
const dropTargetRef = useRef(null) const dropTargetRef = useRef(null)
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) } const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
const [dragOverDayId, setDragOverDayId] = useState(null) const [dragOverDayId, setDragOverDayId] = useState(null)
const [hoveredId, setHoveredId] = useState(null)
const [transportDetail, setTransportDetail] = useState(null) const [transportDetail, setTransportDetail] = useState(null)
const [transportPosVersion, setTransportPosVersion] = useState(0) const [transportPosVersion, setTransportPosVersion] = useState(0)
@@ -275,45 +258,26 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
} | null>(null) } | null>(null)
const inputRef = useRef(null) const inputRef = useRef(null)
const dragDataRef = useRef(null) const dragDataRef = useRef(null)
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => {
if (scrollContainerRef.current && initialScrollTop) {
scrollContainerRef.current.scrollTop = initialScrollTop
}
}, [])
const initedTransportIds = useRef(new Set<number>()) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren) 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' const currency = trip?.currency || 'EUR'
// Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren) // Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren)
const getDragData = (e) => { const getDragData = (e) => {
const dt = e?.dataTransfer const dt = e?.dataTransfer
// Interner Drag hat Vorrang (Ref wird nur bei assignmentId/noteId/reservationId gesetzt) // Interner Drag hat Vorrang (Ref wird nur bei assignmentId/noteId gesetzt)
if (dragDataRef.current) { if (dragDataRef.current) {
return { return {
placeId: '', placeId: '',
assignmentId: dragDataRef.current.assignmentId || '', assignmentId: dragDataRef.current.assignmentId || '',
noteId: dragDataRef.current.noteId || '', noteId: dragDataRef.current.noteId || '',
reservationId: dragDataRef.current.reservationId || '',
fromDayId: parseInt(dragDataRef.current.fromDayId) || 0, fromDayId: parseInt(dragDataRef.current.fromDayId) || 0,
phase: (dragDataRef.current.phase || 'single') as 'single' | 'start' | 'middle' | 'end',
} }
} }
// Externer Drag (aus PlacesSidebar) // Externer Drag (aus PlacesSidebar)
const ext = window.__dragData || {} const ext = window.__dragData || {}
const placeId = dt?.getData('placeId') || ext.placeId || '' const placeId = dt?.getData('placeId') || ext.placeId || ''
return { placeId, assignmentId: '', noteId: '', reservationId: '', fromDayId: 0, phase: 'single' as const } return { placeId, assignmentId: '', noteId: '', fromDayId: 0 }
} }
// Only auto-expand genuinely new days (not on initial load from storage) // Only auto-expand genuinely new days (not on initial load from storage)
@@ -348,10 +312,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return () => document.removeEventListener('dragend', cleanup) return () => document.removeEventListener('dragend', cleanup)
}, []) }, [])
// Initialize missing transport positions outside of render to avoid setState-during-render
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { days.forEach(day => initTransportPositions(day.id)) }, [days, reservations])
const toggleDay = (dayId, e) => { const toggleDay = (dayId, e) => {
e.stopPropagation() e.stopPropagation()
setExpandedDays(prev => { setExpandedDays(prev => {
@@ -364,19 +324,26 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise']) const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
// Get span phase: how a reservation relates to a specific day (by id) // Determine if a reservation's end_time represents a different date (multi-day)
const getSpanPhase = (r: Reservation, dayId: number): 'single' | 'start' | 'middle' | 'end' => { const getEndDate = (r: Reservation) => {
const startDayId = r.day_id const endStr = r.reservation_end_time || ''
const endDayId = r.end_day_id ?? startDayId return endStr.includes('T') ? endStr.split('T')[0] : null
if (!startDayId || startDayId === endDayId) return 'single' }
if (dayId === startDayId) return 'start'
if (dayId === endDayId) return 'end' // Get span phase: how a reservation relates to a specific day's date
const getSpanPhase = (r: Reservation, dayDate: string): 'single' | 'start' | 'middle' | 'end' => {
if (!r.reservation_time) return 'single'
const startDate = r.reservation_time.split('T')[0]
const endDate = getEndDate(r) || startDate
if (startDate === endDate) return 'single'
if (dayDate === startDate) return 'start'
if (dayDate === endDate) return 'end'
return 'middle' return 'middle'
} }
// Get the appropriate display time for a reservation on a specific day // Get the appropriate display time for a reservation on a specific day
const getDisplayTimeForDay = (r: Reservation, dayId: number): string | null => { const getDisplayTimeForDay = (r: Reservation, dayDate: string): string | null => {
const phase = getSpanPhase(r, dayId) const phase = getSpanPhase(r, dayDate)
if (phase === 'end') return r.reservation_end_time || null if (phase === 'end') return r.reservation_end_time || null
if (phase === 'middle') return null if (phase === 'middle') return null
return r.reservation_time || null return r.reservation_time || null
@@ -390,56 +357,36 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return t(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`) return t(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
} }
const getDayOrder = (day: (typeof days)[number]) => (day as any).day_number ?? days.indexOf(day)
const computeMultiDayMove = (r: Reservation, targetDayId: number, phase: 'single' | 'start' | 'middle' | 'end') => {
const startId = r.day_id ?? targetDayId
const endId = r.end_day_id ?? startId
const order = (id: number) => { const d = days.find(x => x.id === id); return d ? getDayOrder(d) : 0 }
if (phase === 'single' || startId === endId) return { day_id: targetDayId, end_day_id: targetDayId }
if (phase === 'start') {
if (order(targetDayId) > order(endId)) return { day_id: targetDayId, end_day_id: targetDayId }
return { day_id: targetDayId, end_day_id: endId }
}
// phase === 'end'
if (order(targetDayId) < order(startId)) return { day_id: targetDayId, end_day_id: targetDayId }
return { day_id: startId, end_day_id: targetDayId }
}
const getTransportForDay = (dayId: number) => { const getTransportForDay = (dayId: number) => {
const day = days.find(d => d.id === dayId)
if (!day?.date) return []
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id) const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
return reservations.filter(r => { return reservations.filter(r => {
if (!TRANSPORT_TYPES.has(r.type)) return false if (!r.reservation_time || r.type === 'hotel') return false
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
const startDate = r.reservation_time.split('T')[0]
const endDate = getEndDate(r)
const startDayId = r.day_id if (endDate && endDate !== startDate) {
const endDayId = r.end_day_id ?? startDayId // Multi-day: show on any day in range (car middle handled elsewhere)
return day.date >= startDate && day.date <= endDate
if (startDayId == null) return false } else {
// Single-day: show all non-hotel reservations that match this day's date
if (endDayId !== startDayId) { return startDate === day.date
const startDay = days.find(d => d.id === startDayId)
const endDay = days.find(d => d.id === endDayId)
const thisDay = days.find(d => d.id === dayId)
if (!startDay || !endDay || !thisDay) return false
return getDayOrder(thisDay) >= getDayOrder(startDay) && getDayOrder(thisDay) <= getDayOrder(endDay)
} }
return startDayId === dayId
}) })
} }
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline // Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
const getActiveRentalsForDay = (dayId: number) => { const getActiveRentalsForDay = (dayId: number) => {
const day = days.find(d => d.id === dayId)
if (!day?.date) return []
return reservations.filter(r => { return reservations.filter(r => {
if (r.type !== 'car') return false if (r.type !== 'car' || !r.reservation_time) return false
const startDayId = r.day_id const startDate = r.reservation_time.split('T')[0]
const endDayId = r.end_day_id const endDate = getEndDate(r)
if (!startDayId || !endDayId || endDayId === startDayId) return false if (!endDate || endDate === startDate) return false
const startDay = days.find(d => d.id === startDayId) return day.date > startDate && day.date < endDate
const endDay = days.find(d => d.id === endDayId)
const thisDay = days.find(d => d.id === dayId)
if (!startDay || !endDay || !thisDay) return false
return getDayOrder(thisDay) > getDayOrder(startDay) && getDayOrder(thisDay) < getDayOrder(endDay)
}) })
} }
@@ -488,15 +435,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
day_plan_position: computeTransportPosition(r, da) + idx * 0.01, day_plan_position: computeTransportPosition(r, da) + idx * 0.01,
})) }))
// Mark as initialized immediately to prevent re-entry // Mark as initialized immediately to prevent re-entry
for (const p of positions) initedTransportIds.current.add(p.id) for (const p of positions) {
// Update store so subscribers see the new positions initedTransportIds.current.add(p.id)
useTripStore.setState(state => ({ const res = reservations.find(x => x.id === p.id)
reservations: state.reservations.map(r => { if (res) res.day_plan_position = p.day_plan_position
const p = positions.find(x => x.id === r.id) }
if (!p) return r
return { ...r, day_plan_position: p.day_plan_position }
})
}))
// Persist to server (fire and forget) // Persist to server (fire and forget)
reservationsApi.updatePositions(tripId, positions).catch(() => {}) reservationsApi.updatePositions(tripId, positions).catch(() => {})
} }
@@ -505,6 +448,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const da = getDayAssignments(dayId) const da = getDayAssignments(dayId)
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order) const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
const transport = getTransportForDay(dayId) const transport = getTransportForDay(dayId)
const dayDate = days.find(d => d.id === dayId)?.date || ''
// Initialize positions for transports that don't have one yet
if (transport.some(r => r.day_plan_position == null)) {
initTransportPositions(dayId)
}
// All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set // All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
const baseItems = [ const baseItems = [
@@ -516,7 +465,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const timedTransports = transport.map(r => ({ const timedTransports = transport.map(r => ({
type: 'transport' as const, type: 'transport' as const,
data: r, data: r,
minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayId)) ?? 0, minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayDate)) ?? 0,
})).sort((a, b) => a.minutes - b.minutes) })).sort((a, b) => a.minutes - b.minutes)
if (timedTransports.length === 0) return baseItems if (timedTransports.length === 0) return baseItems
@@ -658,27 +607,23 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
} }
try { try {
// Update transport positions in store FIRST so the useEffect triggered by
// onReorder's optimistic assignment update reads the correct positions.
if (transportUpdates.length) {
useTripStore.setState(state => ({
reservations: state.reservations.map(r => {
const tu = transportUpdates.find(u => u.id === r.id)
if (!tu) return r
const day_positions = { ...(r.day_positions || {}), [dayId]: tu.day_plan_position }
return { ...r, day_plan_position: tu.day_plan_position, day_positions }
})
}))
setTransportPosVersion(v => v + 1)
}
if (assignmentIds.length) await onReorder(dayId, assignmentIds) if (assignmentIds.length) await onReorder(dayId, assignmentIds)
if (transportUpdates.length) {
onRouteRefresh?.()
await reservationsApi.updatePositions(tripId, transportUpdates, dayId)
}
for (const n of noteUpdates) { for (const n of noteUpdates) {
await tripActions.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order }) await tripActions.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
} }
if (transportUpdates.length) {
for (const tu of transportUpdates) {
const res = reservations.find(r => r.id === tu.id)
if (res) {
res.day_plan_position = tu.day_plan_position
// Update per-day position for multi-day reservations
if (!res.day_positions) res.day_positions = {}
res.day_positions[dayId] = tu.day_plan_position
}
}
setTransportPosVersion(v => v + 1)
await reservationsApi.updatePositions(tripId, transportUpdates, dayId)
}
if (prevAssignmentIds.length) { if (prevAssignmentIds.length) {
const capturedDayId = dayId const capturedDayId = dayId
const capturedPrevIds = prevAssignmentIds const capturedPrevIds = prevAssignmentIds
@@ -690,6 +635,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
} }
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => { const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
// Transport bookings themselves cannot be dragged
if (fromType === 'transport') {
toast.error(t('dayplan.cannotReorderTransport'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
return
}
const m = getMergedItems(dayId) const m = getMergedItems(dayId)
// Check if a timed place is being moved → would it break chronological order? // Check if a timed place is being moved → would it break chronological order?
@@ -902,12 +854,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
setDragOverDayId(null) setDragOverDayId(null)
const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
if (fromReservationId && fromDayId !== dayId) {
const r = reservations.find(x => x.id === Number(fromReservationId))
if (r) { const update = computeMultiDayMove(r, dayId, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return
}
if (placeId) { if (placeId) {
onAssignToDay?.(parseInt(placeId), dayId) onAssignToDay?.(parseInt(placeId), dayId)
} else if (assignmentId && fromDayId !== dayId) { } else if (assignmentId && fromDayId !== dayId) {
@@ -962,9 +909,18 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Toolbar */} {/* Reise-Titel */}
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}> <div style={{ padding: '16px 16px 12px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', lineHeight: '1.3' }}>{trip?.title}</div>
{(trip?.start_date || trip?.end_date) && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })).join(' ')}
{days.length > 0 && ` · ${days.length} ${t('dayplan.days')}`}
</div>
)}
</div>
<div style={{ position: 'relative', flexShrink: 0 }}> <div style={{ position: 'relative', flexShrink: 0 }}>
<button <button
onClick={async () => { onClick={async () => {
@@ -1046,57 +1002,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div> </div>
)} )}
</div> </div>
{(() => {
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
return (
<Tooltip label={label} placement="bottom">
<button
onClick={() => {
const next = allExpanded ? new Set() : new Set(days.map(d => d.id))
setExpandedDays(next)
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...next])) } catch {}
}}
aria-label={label}
aria-pressed={allExpanded}
style={{
position: 'relative', flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 30, height: 30, borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'none',
color: 'var(--text-primary)', cursor: 'pointer', fontFamily: 'inherit', padding: 0,
transition: 'color 0.15s, border-color 0.15s, background 0.15s',
overflow: 'hidden',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<span style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'opacity 0.2s ease, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)',
opacity: allExpanded ? 0 : 1,
transform: allExpanded ? 'translateY(-8px) scale(0.6)' : 'translateY(0) scale(1)',
}}>
<ChevronsUpDown size={14} strokeWidth={2} />
</span>
<span style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'opacity 0.2s ease, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)',
opacity: allExpanded ? 1 : 0,
transform: allExpanded ? 'translateY(0) scale(1)' : 'translateY(8px) scale(0.6)',
}}>
<ChevronsDownUp size={14} strokeWidth={2} />
</span>
</button>
</Tooltip>
)
})()}
{onUndo && ( {onUndo && (
<div style={{ position: 'relative', flexShrink: 0 }}> <div style={{ position: 'relative', flexShrink: 0 }}>
<button <button
onClick={onUndo} onClick={onUndo}
disabled={!canUndo} disabled={!canUndo}
aria-label={t('undo.button')}
onMouseEnter={() => setUndoHover(true)} onMouseEnter={() => setUndoHover(true)}
onMouseLeave={() => setUndoHover(false)} onMouseLeave={() => setUndoHover(false)}
style={{ style={{
@@ -1128,7 +1038,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div> </div>
{/* Tagesliste */} {/* Tagesliste */}
<div className={`scroll-container${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}> <div className="scroll-container" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{days.map((day, index) => { {days.map((day, index) => {
const isSelected = selectedDayId === day.id const isSelected = selectedDayId === day.id
const isExpanded = expandedDays.has(day.id) const isExpanded = expandedDays.has(day.id)
@@ -1146,14 +1056,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */} {/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
<div <div
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }} onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }} onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
onDrop={e => handleDropOnDay(e, day.id)} onDrop={e => handleDropOnDay(e, day.id)}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 10, display: 'flex', alignItems: 'center', gap: 10,
padding: '11px 14px 11px 16px', padding: '11px 14px 11px 16px',
cursor: 'pointer', 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', transition: 'background 0.12s',
userSelect: 'none', userSelect: 'none',
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none', outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
@@ -1202,31 +1112,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
> >
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" /> <Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
</button>} </button>}
{canEditDays && onAddTransport && (
<Tooltip label={t('transport.addTransport')} placement="top">
<button
onClick={e => { e.stopPropagation(); onAddTransport(day.id) }}
aria-label={t('transport.addTransport')}
style={{
flexShrink: 0,
background: 'none',
border: 'none',
padding: '4px',
cursor: 'pointer',
opacity: 0.45,
display: 'flex',
alignItems: 'center',
borderRadius: 4,
}}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.opacity = '1' }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = '0.45' }}
>
<Plus size={15} strokeWidth={1.8} color="var(--text-secondary)" />
</button>
</Tooltip>
)}
{(() => { {(() => {
const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days)) const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
// Sort: check-out first, then ongoing stays, then check-in last // Sort: check-out first, then ongoing stays, then check-in last
.sort((a, b) => { .sort((a, b) => {
const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id
@@ -1247,9 +1134,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const border = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.2)' : isCheckIn ? 'rgba(34,197,94,0.2)' : 'var(--border-primary)' const border = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.2)' : isCheckIn ? 'rgba(34,197,94,0.2)' : 'var(--border-primary)'
const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-muted)' const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-muted)'
return ( return (
<span key={acc.id} onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: bg, border: `1px solid ${border}`, flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: (acc as any).place_id ? 'pointer' : 'default' }}> <span key={acc.id} onClick={e => { e.stopPropagation(); onPlaceClick(acc.place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: bg, border: `1px solid ${border}`, flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}>
<Hotel size={8} style={{ color: iconColor, flexShrink: 0 }} /> <Hotel size={8} style={{ color: iconColor, flexShrink: 0 }} />
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(acc as any).place_name || (acc as any).reservation_title}</span> <span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</span>
</span> </span>
) )
}) })
@@ -1279,15 +1166,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div> </div>
</div> </div>
{canEditDays && <Tooltip label={t('dayplan.addNote')} placement="top"><button {canEditDays && <button
onClick={e => openAddNote(day.id, e)} onClick={e => openAddNote(day.id, e)}
aria-label={t('dayplan.addNote')} title={t('dayplan.addNote')}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }} style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
> >
<FileText size={16} strokeWidth={2} /> <FileText size={16} strokeWidth={2} />
</button></Tooltip>} </button>}
<button <button
onClick={e => toggleDay(day.id, e)} onClick={e => toggleDay(day.id, e)}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }} style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
@@ -1304,7 +1191,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onDrop={e => { onDrop={e => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
// Drop on transport card (detected via dropTargetRef for sync accuracy) // Drop on transport card (detected via dropTargetRef for sync accuracy)
if (dropTargetRef.current?.startsWith('transport-')) { if (dropTargetRef.current?.startsWith('transport-')) {
const isAfter = dropTargetRef.current.startsWith('transport-after-') const isAfter = dropTargetRef.current.startsWith('transport-after-')
@@ -1313,11 +1200,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
if (placeId) { if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id) onAssignToDay?.(parseInt(placeId), day.id)
} else if (fromReservationId && fromDayId !== day.id) {
const r = reservations.find(x => x.id === Number(fromReservationId))
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
} else if (fromReservationId) {
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter)
} else if (assignmentId && fromDayId !== day.id) { } else if (assignmentId && fromDayId !== day.id) {
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (assignmentId) { } else if (assignmentId) {
@@ -1331,11 +1213,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return return
} }
if (fromReservationId && fromDayId !== day.id) {
const r = reservations.find(x => x.id === Number(fromReservationId))
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
if (!assignmentId && !noteId && !placeId) { dragDataRef.current = null; window.__dragData = null; return } if (!assignmentId && !noteId && !placeId) { dragDataRef.current = null; window.__dragData = null; return }
if (placeId) { if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id) onAssignToDay?.(parseInt(placeId), day.id)
@@ -1360,7 +1237,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
> >
{merged.length === 0 && !dayNoteUi ? ( {merged.length === 0 && !dayNoteUi ? (
<div <div
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }} onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
onDrop={e => handleDropOnDay(e, day.id)} onDrop={e => handleDropOnDay(e, day.id)}
style={{ padding: '16px', textAlign: 'center', borderRadius: 8, style={{ padding: '16px', textAlign: 'center', borderRadius: 8,
background: dragOverDayId === day.id ? 'rgba(17,24,39,0.05)' : 'transparent', background: dragOverDayId === day.id ? 'rgba(17,24,39,0.05)' : 'transparent',
@@ -1382,6 +1259,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const cat = categories.find(c => c.id === place.category_id) const cat = categories.find(c => c.id === place.category_id)
const isPlaceSelected = selectedAssignmentId ? assignment.id === selectedAssignmentId : place.id === selectedPlaceId const isPlaceSelected = selectedAssignmentId ? assignment.id === selectedAssignmentId : place.id === selectedPlaceId
const isDraggingThis = draggingId === assignment.id const isDraggingThis = draggingId === assignment.id
const isHovered = hoveredId === assignment.id
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id) const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
const arrowMove = (direction: 'up' | 'down') => { const arrowMove = (direction: 'up' | 'down') => {
@@ -1420,6 +1298,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return ( return (
<React.Fragment key={`place-${assignment.id}`}> <React.Fragment key={`place-${assignment.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div <div
draggable={canEditDays} draggable={canEditDays}
onDragStart={e => { onDragStart={e => {
@@ -1433,17 +1312,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); if (dropTargetKey !== `place-${assignment.id}`) setDropTargetKey(`place-${assignment.id}`) }} onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); if (dropTargetKey !== `place-${assignment.id}`) setDropTargetKey(`place-${assignment.id}`) }}
onDrop={e => { onDrop={e => {
e.preventDefault(); e.stopPropagation() e.preventDefault(); e.stopPropagation()
const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
if (placeId) { if (placeId) {
const pos = placeItems.findIndex(i => i.data.id === assignment.id) const pos = placeItems.findIndex(i => i.data.id === assignment.id)
onAssignToDay?.(parseInt(placeId), day.id, pos >= 0 ? pos : undefined) onAssignToDay?.(parseInt(placeId), day.id, pos >= 0 ? pos : undefined)
setDropTargetKey(null); window.__dragData = null setDropTargetKey(null); window.__dragData = null
} else if (fromReservationId && fromDayId !== day.id) {
const r = reservations.find(x => x.id === Number(fromReservationId))
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (fromReservationId) {
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'place', assignment.id)
} else if (fromAssignmentId && fromDayId !== day.id) { } else if (fromAssignmentId && fromDayId !== day.id) {
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id) const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
@@ -1460,21 +1333,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id) 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 }} 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) }} onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
onContextMenu={e => ctxMenu.open(e, [ onContextMenu={e => ctxMenu.open(e, [
@@ -1485,36 +1343,23 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{ divider: true }, { divider: true },
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])} ])}
onMouseEnter={e => { onMouseEnter={() => setHoveredId(assignment.id)}
if (!isPlaceSelected && !lockedIds.has(assignment.id)) onMouseLeave={() => setHoveredId(null)}
e.currentTarget.style.background = 'var(--bg-hover)'
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
if (grip) grip.style.opacity = '1'
setHoveredAssignmentId(assignment.id)
}}
onMouseLeave={e => {
if (!isPlaceSelected && !lockedIds.has(assignment.id))
e.currentTarget.style.background = 'transparent'
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
if (grip) grip.style.opacity = '0.3'
setHoveredAssignmentId(null)
}}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px 7px 10px', padding: '7px 8px 7px 10px',
cursor: 'pointer', cursor: 'pointer',
background: lockedIds.has(assignment.id) background: lockedIds.has(assignment.id)
? 'rgba(220,38,38,0.08)' ? 'rgba(220,38,38,0.08)'
: isPlaceSelected ? 'var(--bg-selected)' : 'transparent', : isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
borderLeft: lockedIds.has(assignment.id) borderLeft: lockedIds.has(assignment.id)
? '3px solid #dc2626' ? '3px solid #dc2626'
: '3px solid transparent', : '3px solid transparent',
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
transition: 'background 0.15s, border-color 0.15s', transition: 'background 0.15s, border-color 0.15s',
opacity: isDraggingThis ? 0.4 : 1, opacity: isDraggingThis ? 0.4 : 1,
}} }}
> >
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}> {canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} /> <GripVertical size={13} strokeWidth={1.8} />
</div>} </div>}
<div <div
@@ -1575,77 +1420,26 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const res = reservations.find(r => r.assignment_id === assignment.id) const res = reservations.find(r => r.assignment_id === assignment.id)
if (!res) return null if (!res) return null
const confirmed = res.status === 'confirmed' const confirmed = res.status === 'confirmed'
const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2
const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false
return ( return (
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 4 }}> <div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 9, fontWeight: 600,
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 9, fontWeight: 600, background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)',
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)', color: confirmed ? '#16a34a' : '#d97706',
color: confirmed ? '#16a34a' : '#d97706', }}>
}}> {(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()} <span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span> {res.reservation_time?.includes('T') && (
{res.reservation_time?.includes('T') && ( <span style={{ fontWeight: 400 }}>
<span style={{ fontWeight: 400 }}> {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} {res.reservation_end_time && ` ${res.reservation_end_time}`}
{res.reservation_end_time && ` ${(() => { </span>
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time)
return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
})()}`}
</span>
)}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta) return null
if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
if (meta.train_number) return <span style={{ fontWeight: 400 }}>{meta.train_number}</span>
return null
})()}
</div>
{hasEndpoints && (
<button
type="button"
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
background: active ? '#3b82f6' : 'transparent',
color: active ? '#fff' : 'var(--text-faint)',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
>
<RouteIcon size={11} />
</button>
)} )}
{canEditDays && (() => { {(() => {
const isTransport = ['flight','train','car','cruise','bus'].includes(res.type) const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
const handler = isTransport ? onEditTransport : onEditReservation if (!meta) return null
if (!handler) return null if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
return ( if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
<button if (meta.train_number) return <span style={{ fontWeight: 400 }}>{meta.train_number}</span>
type="button" return null
onClick={e => { e.stopPropagation(); handler(res) }}
title={t('common.edit')}
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none', background: 'transparent',
color: 'var(--text-faint)',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Pencil size={11} />
</button>
)
})()} })()}
</div> </div>
) )
@@ -1668,7 +1462,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div> </div>
)} )}
</div> </div>
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}> {canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
<button onClick={moveUp} disabled={idx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', color: idx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}> <button onClick={moveUp} disabled={idx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', color: idx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
<ChevronUp size={12} strokeWidth={2} /> <ChevronUp size={12} strokeWidth={2} />
</button> </button>
@@ -1676,32 +1470,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
<ChevronDown size={12} strokeWidth={2} /> <ChevronDown size={12} strokeWidth={2} />
</button> </button>
</div>} </div>}
{canEditDays && onAddBookingToAssignment && hoveredAssignmentId === assignment.id && (
<button
onClick={e => {
e.stopPropagation()
onAddBookingToAssignment(day.id, assignment.id)
}}
title={t('reservations.addBooking')}
style={{
flexShrink: 0,
background: 'none',
border: '1px solid var(--border-primary)',
borderRadius: 5,
padding: '2px 6px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 3,
fontSize: 10,
fontWeight: 500,
color: 'var(--text-muted)',
fontFamily: 'inherit',
}}
>
<Plus size={11} strokeWidth={2} />
</button>
)}
</div> </div>
</React.Fragment> </React.Fragment>
) )
@@ -1710,7 +1478,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
// Transport booking (flight, train, bus, car, cruise) // Transport booking (flight, train, bus, car, cruise)
if (item.type === 'transport') { if (item.type === 'transport') {
const res = item.data const res = item.data
const spanPhase = getSpanPhase(res, day.id) const spanPhase = getSpanPhase(res, day.date)
// Car "active" (middle) days are shown in the day header, skip here // Car "active" (middle) days are shown in the day header, skip here
if (res.type === 'car' && spanPhase === 'middle') return null if (res.type === 'car' && spanPhase === 'middle') return null
@@ -1718,6 +1486,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const TransportIcon = RES_ICONS[res.type] || Ticket const TransportIcon = RES_ICONS[res.type] || Ticket
const color = '#3b82f6' const color = '#3b82f6'
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
const isTransportHovered = hoveredId === `transport-${res.id}`
// Subtitle aus Metadaten zusammensetzen // Subtitle aus Metadaten zusammensetzen
let subtitle = '' let subtitle = ''
@@ -1732,16 +1501,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
// Multi-day span phase // Multi-day span phase
const spanLabel = getSpanLabel(res, spanPhase) const spanLabel = getSpanLabel(res, spanPhase)
const displayTime = getDisplayTimeForDay(res, day.id) const displayTime = getDisplayTimeForDay(res, day.date)
return ( return (
<React.Fragment key={`transport-${res.id}-${day.id}`}> <React.Fragment key={`transport-${res.id}-${day.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div <div
onClick={() => { onClick={() => setTransportDetail(res)}
if (!canEditDays) return
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(res)
else onEditReservation?.(res)
}}
onDragOver={e => { onDragOver={e => {
e.preventDefault(); e.stopPropagation() e.preventDefault(); e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect() const rect = e.currentTarget.getBoundingClientRect()
@@ -1749,26 +1515,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}` const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}`
if (dropTargetRef.current !== key) setDropTargetKey(key) if (dropTargetRef.current !== key) setDropTargetKey(key)
}} }}
draggable={canEditDays && spanPhase !== 'middle'}
onDragStart={e => {
if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return }
e.dataTransfer.effectAllowed = 'move'
dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase }
setDraggingId(res.id)
}}
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
onDrop={e => { onDrop={e => {
e.preventDefault(); e.stopPropagation() e.preventDefault(); e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect() const rect = e.currentTarget.getBoundingClientRect()
const insertAfter = e.clientY > rect.top + rect.height / 2 const insertAfter = e.clientY > rect.top + rect.height / 2
const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
if (placeId) { if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id) onAssignToDay?.(parseInt(placeId), day.id)
} else if (fromReservationId && fromDayId !== day.id) {
const r2 = reservations.find(x => x.id === Number(fromReservationId))
if (r2) { const update = computeMultiDayMove(r2, day.id, phase); tripActions.updateReservation(tripId, r2.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
} else if (fromReservationId) {
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter)
} else if (fromAssignmentId && fromDayId !== day.id) { } else if (fromAssignmentId && fromDayId !== day.id) {
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (fromAssignmentId) { } else if (fromAssignmentId) {
@@ -1780,27 +1533,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
} }
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
}} }}
onMouseEnter={e => { e.currentTarget.style.background = `${color}12` }} onMouseEnter={() => setHoveredId(`transport-${res.id}`)}
onMouseLeave={e => { e.currentTarget.style.background = `${color}08` }} onMouseLeave={() => setHoveredId(null)}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px 7px 10px', padding: '7px 8px 7px 10px',
margin: '1px 8px', margin: '1px 8px',
borderRadius: 6, borderRadius: 6,
border: `1px solid ${color}33`, border: `1px solid ${color}33`,
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined, background: isTransportHovered ? `${color}12` : `${color}08`,
borderBottom: showDropLineAfter ? '2px solid var(--text-primary)' : undefined, cursor: 'pointer', userSelect: 'none',
background: `${color}08`,
cursor: canEditDays && onEditTransport ? 'pointer' : 'default', userSelect: 'none',
transition: 'background 0.1s', transition: 'background 0.1s',
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1, opacity: spanPhase === 'middle' ? 0.65 : 1,
}} }}
> >
{canEditDays && spanPhase !== 'middle' && (
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>
)}
<div style={{ <div style={{
width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: '50%', background: `${color}18`, borderRadius: '50%', background: `${color}18`,
@@ -1853,7 +1599,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
border: 'none', border: 'none',
background: active ? color : 'transparent', background: active ? color : 'transparent',
color: active ? '#fff' : 'var(--text-faint)', color: active ? '#fff' : 'var(--text-faint)',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)', transition: 'all 0.12s',
}} }}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }} onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }} onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
@@ -1863,16 +1609,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
) )
})()} })()}
</div> </div>
{showDropLineAfter && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
</React.Fragment> </React.Fragment>
) )
} }
// Notizkarte // Notizkarte
const note = item.data const note = item.data
const isNoteHovered = hoveredId === `note-${note.id}`
const NoteIcon = getNoteIcon(note.icon) const NoteIcon = getNoteIcon(note.icon)
const noteIdx = idx const noteIdx = idx
return ( return (
<React.Fragment key={`note-${note.id}`}> <React.Fragment key={`note-${note.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div <div
draggable={canEditDays} draggable={canEditDays}
onDragStart={e => { if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }} onDragStart={e => { if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
@@ -1880,14 +1629,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }} onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
onDrop={e => { onDrop={e => {
e.preventDefault(); e.stopPropagation() e.preventDefault(); e.stopPropagation()
const { noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) const { noteId: fromNoteId, assignmentId: fromAssignmentId, fromDayId } = getDragData(e)
if (fromReservationId && fromDayId !== day.id) { if (fromNoteId && fromDayId !== day.id) {
const r = reservations.find(x => x.id === Number(fromReservationId))
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (fromReservationId) {
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'note', note.id)
} else if (fromNoteId && fromDayId !== day.id) {
const tm = getMergedItems(day.id) const tm = getMergedItems(day.id)
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id) const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2 const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
@@ -1910,31 +1653,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{ divider: true }, { divider: true },
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) }, { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
]) : undefined} ]) : undefined}
onMouseEnter={e => { onMouseEnter={() => setHoveredId(`note-${note.id}`)}
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null onMouseLeave={() => setHoveredId(null)}
if (grip) grip.style.opacity = '1'
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
if (editBtns) editBtns.style.opacity = '1'
}}
onMouseLeave={e => {
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
if (grip) grip.style.opacity = '0.3'
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
if (editBtns) editBtns.style.opacity = '0'
}}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px 7px 2px', padding: '7px 8px 7px 2px',
margin: '1px 8px', margin: '1px 8px',
borderRadius: 6, borderRadius: 6,
border: '1px solid var(--border-faint)', border: '1px solid var(--border-faint)',
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined, background: isNoteHovered ? 'var(--bg-hover)' : 'var(--bg-hover)',
background: 'var(--bg-hover)',
opacity: draggingId === `note-${note.id}` ? 0.4 : 1, opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none', transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
}} }}
> >
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}> {canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} /> <GripVertical size={13} strokeWidth={1.8} />
</div>} </div>}
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}> <div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
@@ -1948,11 +1680,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
<div className="collab-note-md" style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{note.time}</Markdown></div> <div className="collab-note-md" style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{note.time}</Markdown></div>
)} )}
</div> </div>
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: 0, transition: 'opacity 0.15s' }}> {canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
<button onClick={e => openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Pencil size={10} /></button> <button onClick={e => openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Pencil size={10} /></button>
<button onClick={e => deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Trash2 size={10} /></button> <button onClick={e => deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Trash2 size={10} /></button>
</div>} </div>}
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}> {canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button> <button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button> <button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
</div>} </div>}
@@ -1967,17 +1699,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `end-${day.id}`) setDropTargetKey(`end-${day.id}`) }} onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `end-${day.id}`) setDropTargetKey(`end-${day.id}`) }}
onDrop={e => { onDrop={e => {
e.preventDefault(); e.stopPropagation() e.preventDefault(); e.stopPropagation()
const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
// Neuer Ort von der Orte-Liste // Neuer Ort von der Orte-Liste
if (placeId) { if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id) onAssignToDay?.(parseInt(placeId), day.id)
setDropTargetKey(null); window.__dragData = null; return setDropTargetKey(null); window.__dragData = null; return
} }
if (fromReservationId && fromDayId !== day.id) {
const r = reservations.find(x => x.id === Number(fromReservationId))
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return
}
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return } if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
if (assignmentId && fromDayId !== day.id) { if (assignmentId && fromDayId !== day.id) {
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
@@ -2239,7 +1966,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{res.notes && ( {res.notes && (
<div style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}> <div style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div> <div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
<div className="collab-note-md" style={{ fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div> <div className="collab-note-md" style={{ fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>
</div> </div>
)} )}
@@ -36,8 +36,6 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [summary, setSummary] = useState<PlacesImportSummary | null>(null) const [summary, setSummary] = useState<PlacesImportSummary | null>(null)
const [gpxOpts, setGpxOpts] = useState({ waypoints: true, routes: true, tracks: true })
const [kmlOpts, setKmlOpts] = useState({ points: true, paths: true })
const validateFile = (f: File): string | null => { const validateFile = (f: File): string | null => {
const ext = f.name.toLowerCase().split('.').pop() const ext = f.name.toLowerCase().split('.').pop()
@@ -129,7 +127,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
try { try {
if (ext === 'gpx') { if (ext === 'gpx') {
const result = await placesApi.importGpx(tripId, file, gpxOpts) const result = await placesApi.importGpx(tripId, file)
await loadTrip(tripId) await loadTrip(tripId)
if (result.count === 0 && result.skipped > 0) { if (result.count === 0 && result.skipped > 0) {
toast.warning(t('places.importAllSkipped')) toast.warning(t('places.importAllSkipped'))
@@ -139,13 +137,15 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
if (result.places?.length > 0) { if (result.places?.length > 0) {
const importedIds: number[] = result.places.map((p: { id: number }) => p.id) const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
pushUndo?.(t('undo.importGpx'), async () => { pushUndo?.(t('undo.importGpx'), async () => {
try { await placesApi.bulkDelete(tripId, importedIds) } catch {} for (const id of importedIds) {
try { await placesApi.delete(tripId, id) } catch {}
}
await loadTrip(tripId) await loadTrip(tripId)
}) })
} }
handleClose() handleClose()
} else { } else {
const result = await placesApi.importMapFile(tripId, file, kmlOpts) const result = await placesApi.importMapFile(tripId, file)
await loadTrip(tripId) await loadTrip(tripId)
setSummary(result.summary || null) setSummary(result.summary || null)
if (result.count === 0 && (result.summary?.skippedCount ?? 0) > 0) { if (result.count === 0 && (result.summary?.skippedCount ?? 0) > 0) {
@@ -159,7 +159,9 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
if (result.places?.length > 0) { if (result.places?.length > 0) {
const importedIds: number[] = result.places.map((p: { id: number }) => p.id) const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
pushUndo?.(t('undo.importKeyholeMarkup'), async () => { pushUndo?.(t('undo.importKeyholeMarkup'), async () => {
try { await placesApi.bulkDelete(tripId, importedIds) } catch {} for (const id of importedIds) {
try { await placesApi.delete(tripId, id) } catch {}
}
await loadTrip(tripId) await loadTrip(tripId)
}) })
} }
@@ -175,12 +177,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
} }
} }
const fileExt = file?.name.toLowerCase().split('.').pop() ?? '' const canImport = !!file && !loading
const isGpx = fileExt === 'gpx'
const isKml = fileExt === 'kml' || fileExt === 'kmz'
const gpxNoneSelected = isGpx && !gpxOpts.waypoints && !gpxOpts.routes && !gpxOpts.tracks
const kmlNoneSelected = isKml && !kmlOpts.points && !kmlOpts.paths
const canImport = !!file && !loading && !gpxNoneSelected && !kmlNoneSelected
if (!isOpen) return null if (!isOpen) return null
@@ -245,58 +242,6 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
)} )}
</div> </div>
{isGpx && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t('places.gpxImportTypes')}
</div>
{(['waypoints', 'routes', 'tracks'] as const).map(key => (
<label key={key} onClick={() => setGpxOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer' }}>
<div style={{
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
border: gpxOpts[key] ? 'none' : '1.5px solid var(--border-primary)',
background: gpxOpts[key] ? 'var(--accent)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{gpxOpts[key] && <svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2" stroke="white" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
</div>
<span style={{ fontSize: 12, color: 'var(--text-primary)', userSelect: 'none' }}>
{t(key === 'waypoints' ? 'places.gpxImportWaypoints' : key === 'routes' ? 'places.gpxImportRoutes' : 'places.gpxImportTracks')}
</span>
</label>
))}
{gpxNoneSelected && (
<div style={{ fontSize: 11, color: '#b45309', marginTop: 4 }}>{t('places.gpxImportNoneSelected')}</div>
)}
</div>
)}
{isKml && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t('places.kmlImportTypes')}
</div>
{(['points', 'paths'] as const).map(key => (
<label key={key} onClick={() => setKmlOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer' }}>
<div style={{
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
border: kmlOpts[key] ? 'none' : '1.5px solid var(--border-primary)',
background: kmlOpts[key] ? 'var(--accent)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{kmlOpts[key] && <svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2" stroke="white" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
</div>
<span style={{ fontSize: 12, color: 'var(--text-primary)', userSelect: 'none' }}>
{t(key === 'points' ? 'places.kmlImportPoints' : 'places.kmlImportPaths')}
</span>
</label>
))}
{kmlNoneSelected && (
<div style={{ fontSize: 11, color: '#b45309', marginTop: 4 }}>{t('places.kmlImportNoneSelected')}</div>
)}
</div>
)}
{summary && ( {summary && (
<div style={{ <div style={{
border: '1px solid var(--border-primary)', borderRadius: 10, border: '1px solid var(--border-primary)', borderRadius: 10,
@@ -360,25 +360,6 @@ export default function PlaceFormModal({
onClose={onClose} onClose={onClose}
title={place ? t('places.editPlace') : t('places.addPlace')} title={place ? t('places.editPlace') : t('places.addPlace')}
size="lg" size="lg"
footer={
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-lg hover:bg-gray-50"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isSaving || hasTimeError}
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
>
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
</button>
</div>
}
> >
<form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}> <form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}>
{/* Place Search */} {/* Place Search */}
@@ -632,6 +613,23 @@ export default function PlaceFormModal({
</div> </div>
)} )}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-lg hover:bg-gray-50"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={isSaving || hasTimeError}
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
>
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
</button>
</div>
</form> </form>
</Modal> </Modal>
) )
@@ -2,7 +2,6 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { openFile } from '../../utils/fileDownload' import { openFile } from '../../utils/fileDownload'
import Markdown from 'react-markdown' import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react' import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar' import PlaceAvatar from '../shared/PlaceAvatar'
import { mapsApi } from '../../api/client' import { mapsApi } from '../../api/client'
@@ -350,8 +349,8 @@ export default function PlaceInspector({
{/* Notes */} {/* Notes */}
{place.notes && ( {place.notes && (
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}> <div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown> <Markdown remarkPlugins={[remarkGfm]}>{place.notes}</Markdown>
</div> </div>
)} )}
@@ -400,7 +399,7 @@ export default function PlaceInspector({
</div> </div>
)} )}
</div> </div>
{res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div>} {res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>}
{(() => { {(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null if (!meta || Object.keys(meta).length === 0) return null
@@ -195,7 +195,7 @@ describe('Filter tabs', () => {
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] }; const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />); render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
await user.click(screen.getByRole('button', { name: /Unplanned/i })); await user.click(screen.getByRole('button', { name: /Unplanned/i }));
await user.click(screen.getByRole('button', { name: /^All/i })); await user.click(screen.getByRole('button', { name: /^All$/i }));
expect(screen.getByText('Planned Place')).toBeInTheDocument(); expect(screen.getByText('Planned Place')).toBeInTheDocument();
expect(screen.getByText('Unplanned Place')).toBeInTheDocument(); expect(screen.getByText('Unplanned Place')).toBeInTheDocument();
}); });
+97 -348
View File
@@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { useState, useMemo, useEffect, useLayoutEffect, useRef, useCallback } from 'react' import { useState, useMemo, useEffect, useRef } from 'react'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye, Route } from 'lucide-react' import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar' import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons' import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
@@ -12,8 +12,6 @@ import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import type { Place, Category, Day, AssignmentsMap } from '../../types' import type { Place, Category, Day, AssignmentsMap } from '../../types'
import FileImportModal from './FileImportModal' import FileImportModal from './FileImportModal'
import ConfirmDialog from '../shared/ConfirmDialog'
import Tooltip from '../shared/Tooltip'
interface PlacesSidebarProps { interface PlacesSidebarProps {
tripId: number tripId: number
@@ -27,127 +25,16 @@ interface PlacesSidebarProps {
onAssignToDay: (placeId: number, dayId: number) => void onAssignToDay: (placeId: number, dayId: number) => void
onEditPlace: (place: Place) => void onEditPlace: (place: Place) => void
onDeletePlace: (placeId: number) => void onDeletePlace: (placeId: number) => void
onBulkDeletePlaces?: (ids: number[]) => void
onBulkDeleteConfirm?: (ids: number[]) => void
days: Day[] days: Day[]
isMobile: boolean isMobile: boolean
onCategoryFilterChange?: (categoryIds: Set<string>) => void onCategoryFilterChange?: (categoryIds: Set<string>) => void
onPlacesFilterChange?: (filter: string) => void onPlacesFilterChange?: (filter: string) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
initialScrollTop?: number
onScrollTopChange?: (top: number) => void
} }
interface MemoPlaceRowProps {
place: Place
category: Category | undefined
isSelected: boolean
isPlanned: boolean
inDay: boolean
isChecked: boolean
selectMode: boolean
selectedDayId: number | null
canEditPlaces: boolean
isMobile: boolean
t: (key: string, params?: Record<string, any>) => string
onPlaceClick: (id: number | null) => void
onContextMenu: (e: React.MouseEvent, place: Place) => void
onAssignToDay: (placeId: number, dayId?: number) => void
toggleSelected: (id: number) => void
setDayPickerPlace: (place: any) => void
}
const MemoPlaceRow = React.memo(function MemoPlaceRow({
place, category: cat, isSelected, isPlanned, inDay, isChecked,
selectMode, selectedDayId, canEditPlaces, isMobile, t,
onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace,
}: MemoPlaceRowProps) {
const hasGeometry = Boolean(place.route_geometry)
return (
<div
key={place.id}
draggable={!selectMode}
onDragStart={e => {
e.dataTransfer.setData('placeId', String(place.id))
e.dataTransfer.effectAllowed = 'copy'
window.__dragData = { placeId: String(place.id) }
}}
onClick={() => {
if (selectMode) {
toggleSelected(place.id)
} else if (isMobile) {
setDayPickerPlace(place)
} else {
onPlaceClick(isSelected ? null : place.id)
}
}}
onContextMenu={selectMode ? undefined : e => onContextMenu(e, place)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 14px 9px 16px',
cursor: selectMode ? 'pointer' : 'grab',
background: isChecked ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : isSelected ? 'var(--border-faint)' : 'transparent',
borderBottom: '1px solid var(--border-faint)',
transition: 'background 0.1s',
contentVisibility: 'auto',
containIntrinsicSize: '0 52px',
}}
onMouseEnter={e => { if (!isSelected && !isChecked) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!isSelected && !isChecked) e.currentTarget.style.background = 'transparent' }}
>
{selectMode && (
<div style={{
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
border: isChecked ? 'none' : '1.5px solid var(--border-primary)',
background: isChecked ? 'var(--accent)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{isChecked && <Check size={10} strokeWidth={3} color="white" />}
</div>
)}
<PlaceAvatar place={place} category={cat} size={34} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden' }}>
{hasGeometry && <Route size={11} strokeWidth={2} color="var(--text-faint)" style={{ flexShrink: 0 }} title="Track / Route" />}
{cat && (() => {
const CatIcon = getCategoryIcon(cat.icon)
return <CatIcon size={11} strokeWidth={2} color={cat.color || '#6366f1'} style={{ flexShrink: 0 }} title={cat.name} />
})()}
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
{place.name}
</span>
</div>
{(place.description || place.address || cat?.name) && (
<div style={{ marginTop: 2 }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
{place.description || place.address || cat?.name}
</span>
</div>
)}
</div>
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{!selectMode && !inDay && selectedDayId && (
<button
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 20, height: 20, borderRadius: 6,
background: 'var(--bg-hover)', border: 'none', cursor: 'pointer',
color: 'var(--text-faint)', padding: 0, transition: 'background 0.15s, color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-faint)' }}
><Plus size={12} strokeWidth={2.5} /></button>
)}
</div>
</div>
)
})
const PlacesSidebar = React.memo(function PlacesSidebar({ const PlacesSidebar = React.memo(function PlacesSidebar({
tripId, places, categories, assignments, selectedDayId, selectedPlaceId, tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, onBulkDeletePlaces, onBulkDeleteConfirm, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo, onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo,
initialScrollTop, onScrollTopChange,
}: PlacesSidebarProps) { }: PlacesSidebarProps) {
const { t } = useTranslation() const { t } = useTranslation()
const toast = useToast() const toast = useToast()
@@ -162,12 +49,6 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null) const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null)
const [sidebarDragOver, setSidebarDragOver] = useState(false) const [sidebarDragOver, setSidebarDragOver] = useState(false)
const sidebarDragCounter = useRef(0) const sidebarDragCounter = useRef(0)
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => {
if (scrollContainerRef.current && initialScrollTop) {
scrollContainerRef.current.scrollTop = initialScrollTop
}
}, [])
const handleSidebarDragEnter = (e: React.DragEvent) => { const handleSidebarDragEnter = (e: React.DragEvent) => {
if (!canEditPlaces) return if (!canEditPlaces) return
@@ -229,7 +110,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
if (result.places?.length > 0) { if (result.places?.length > 0) {
const importedIds: number[] = result.places.map((p: { id: number }) => p.id) const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
pushUndo?.(t(provider === 'google' ? 'undo.importGoogleList' : 'undo.importNaverList'), async () => { pushUndo?.(t(provider === 'google' ? 'undo.importGoogleList' : 'undo.importNaverList'), async () => {
try { await placesApi.bulkDelete(tripId, importedIds) } catch {} for (const id of importedIds) {
try { await placesApi.delete(tripId, id) } catch {}
}
await loadTrip(tripId) await loadTrip(tripId)
}) })
} }
@@ -243,28 +126,6 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [filter, setFilter] = useState('all') const [filter, setFilter] = useState('all')
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set()) const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
const [selectMode, setSelectMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
const exitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()) }
// Auto-exit when all selected places have been removed from the store (e.g. after bulk delete)
useEffect(() => {
if (!selectMode || selectedIds.size === 0) return
const placeIdSet = new Set(places.map(p => p.id))
if ([...selectedIds].every(id => !placeIdSet.has(id))) {
setSelectMode(false)
setSelectedIds(new Set())
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [places])
const toggleSelected = useCallback((id: number) => setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
}), [])
const toggleCategoryFilter = (catId: string) => { const toggleCategoryFilter = (catId: string) => {
setCategoryFiltersLocal(prev => { setCategoryFiltersLocal(prev => {
@@ -279,16 +140,12 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
const [mobileShowDays, setMobileShowDays] = useState(false) const [mobileShowDays, setMobileShowDays] = useState(false)
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen) // Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
const hasTracks = useMemo(() => places.some(p => p.route_geometry), [places])
useEffect(() => { if (filter === 'tracks' && !hasTracks) setFilter('all') }, [hasTracks, filter])
const plannedIds = useMemo(() => new Set( const plannedIds = useMemo(() => new Set(
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean)) Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
), [assignments]) ), [assignments])
const filtered = useMemo(() => places.filter(p => { const filtered = useMemo(() => places.filter(p => {
if (filter === 'unplanned' && plannedIds.has(p.id)) return false if (filter === 'unplanned' && plannedIds.has(p.id)) return false
if (filter === 'tracks' && !p.route_geometry) return false
if (categoryFilters.size > 0) { if (categoryFilters.size > 0) {
if (p.category_id == null) { if (p.category_id == null) {
if (!categoryFilters.has('uncategorized')) return false if (!categoryFilters.has('uncategorized')) return false
@@ -302,26 +159,6 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
const isAssignedToSelectedDay = (placeId) => const isAssignedToSelectedDay = (placeId) =>
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId) selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
const selectedDayIdRef = useRef<number | null>(selectedDayId)
useEffect(() => { selectedDayIdRef.current = selectedDayId }, [selectedDayId])
const inDaySet = useMemo(() => {
if (!selectedDayId) return new Set<number>()
return new Set<number>((assignments[String(selectedDayId)] || []).map((a: any) => a.place?.id).filter(Boolean))
}, [assignments, selectedDayId])
const openContextMenu = useCallback((e: React.MouseEvent, place: Place) => {
const selDayId = selectedDayIdRef.current
ctxMenu.open(e, [
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selDayId) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${(place as any).google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + (place as any).google_place_id : place.lat + ',' + place.lng}`, '_blank') },
{ divider: true },
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])
}, [ctxMenu.open, canEditPlaces, t, onEditPlace, onAssignToDay, onDeletePlace])
return ( return (
<div <div
onDragEnter={handleSidebarDragEnter} onDragEnter={handleSidebarDragEnter}
@@ -383,65 +220,19 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')} <MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
</button> </button>
</div> </div>
<div style={{ height: 1, background: 'var(--border-primary)', margin: '2px 0 10px' }} />
</>} </>}
{/* Filter-Tabs */} {/* Filter-Tabs */}
{(() => { <div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
const baseFiltered = places.filter(p => { {[{ id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }].map(f => (
if (categoryFilters.size > 0) { <button key={f.id} onClick={() => { setFilter(f.id); onPlacesFilterChange?.(f.id) }} style={{
if (p.category_id == null) { padding: '4px 10px', borderRadius: 20, border: 'none', cursor: 'pointer',
if (!categoryFilters.has('uncategorized')) return false fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
} else if (!categoryFilters.has(String(p.category_id))) return false background: filter === f.id ? 'var(--accent)' : 'var(--bg-tertiary)',
} color: filter === f.id ? 'var(--accent-text)' : 'var(--text-muted)',
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) && }}>{f.label}</button>
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false ))}
return true </div>
})
const counts = {
all: baseFiltered.length,
unplanned: baseFiltered.filter(p => !plannedIds.has(p.id)).length,
tracks: baseFiltered.filter(p => p.route_geometry).length,
}
const tabs = ([
{ id: 'all', label: t('places.all') },
{ id: 'unplanned', label: t('places.unplanned') },
hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null,
] as const).filter(Boolean) as Array<{ id: 'all' | 'unplanned' | 'tracks'; label: string }>
return (
<div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'wrap' }}>
{tabs.map(f => {
const active = filter === f.id
return (
<button
key={f.id}
onClick={() => { setFilter(f.id); onPlacesFilterChange?.(f.id); setSelectedIds(new Set()) }}
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: '4px 9px', borderRadius: 99,
fontSize: 11, fontWeight: 500, whiteSpace: 'nowrap',
background: active ? 'var(--accent)' : 'var(--bg-card)',
color: active ? 'var(--accent-text)' : 'var(--text-primary)',
boxShadow: active ? 'none' : '0 1px 2px rgba(0,0,0,0.06)',
transition: 'background 0.15s, color 0.15s, box-shadow 0.15s',
}}
>
{f.label}
<span style={{
fontSize: 9, fontWeight: 600, lineHeight: 1,
background: active ? 'color-mix(in srgb, var(--accent-text) 22%, transparent)' : 'var(--bg-tertiary)',
color: active ? 'var(--accent-text)' : 'var(--text-faint)',
padding: '1px 5px', borderRadius: 99, minWidth: 14, textAlign: 'center',
}}>
{counts[f.id]}
</span>
</button>
)
})}
</div>
)
})()}
{/* Suchfeld */} {/* Suchfeld */}
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
@@ -449,7 +240,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
<input <input
type="text" type="text"
value={search} value={search}
onChange={e => { setSearch(e.target.value); if (selectMode) setSelectedIds(new Set()) }} onChange={e => setSearch(e.target.value)}
placeholder={t('places.search')} placeholder={t('places.search')}
style={{ style={{
width: '100%', padding: '7px 30px 7px 30px', borderRadius: 10, width: '100%', padding: '7px 30px 7px 30px', borderRadius: 10,
@@ -472,9 +263,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
? (categoryFilters.has('uncategorized') ? t('places.noCategory') : categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories')) ? (categoryFilters.has('uncategorized') ? t('places.noCategory') : categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories'))
: `${categoryFilters.size} ${t('places.categoriesSelected')}` : `${categoryFilters.size} ${t('places.categoriesSelected')}`
return ( return (
<div style={{ marginTop: 6, position: 'relative', display: 'flex', gap: 6, alignItems: 'stretch' }}> <div style={{ marginTop: 6, position: 'relative' }}>
<button onClick={() => setCatDropOpen(v => !v)} style={{ <button onClick={() => setCatDropOpen(v => !v)} style={{
flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
background: 'var(--bg-card)', fontSize: 12, color: 'var(--text-primary)', background: 'var(--bg-card)', fontSize: 12, color: 'var(--text-primary)',
cursor: 'pointer', fontFamily: 'inherit', cursor: 'pointer', fontFamily: 'inherit',
@@ -482,41 +273,6 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<ChevronDown size={12} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} /> <ChevronDown size={12} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
</button> </button>
{canEditPlaces && (
<Tooltip label={t('common.select')} placement="bottom">
<button
onClick={() => { setSelectMode(v => !v); setSelectedIds(new Set()) }}
aria-label={t('common.select')}
aria-pressed={selectMode}
style={{
position: 'relative', width: 30, flexShrink: 0, borderRadius: 8,
border: `1px solid ${selectMode ? 'var(--accent)' : 'var(--border-primary)'}`,
background: selectMode ? 'color-mix(in srgb, var(--accent) 14%, transparent)' : 'var(--bg-card)',
color: selectMode ? 'var(--accent)' : 'var(--text-faint)',
cursor: 'pointer', fontFamily: 'inherit', padding: 0,
transition: 'background 0.18s, color 0.18s, border-color 0.18s',
overflow: 'hidden',
}}
>
<span style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'opacity 0.18s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)',
opacity: selectMode ? 0 : 1,
transform: selectMode ? 'rotate(-90deg) scale(0.6)' : 'rotate(0) scale(1)',
}}>
<Check size={13} strokeWidth={2.4} />
</span>
<span style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'opacity 0.18s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)',
opacity: selectMode ? 1 : 0,
transform: selectMode ? 'rotate(0) scale(1)' : 'rotate(90deg) scale(0.6)',
}}>
<X size={13} strokeWidth={2.4} />
</span>
</button>
</Tooltip>
)}
{catDropOpen && ( {catDropOpen && (
<div style={{ <div style={{
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4, position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4,
@@ -587,65 +343,13 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
})()} })()}
</div> </div>
{/* Anzahl / Auswahl-Leiste */} {/* Anzahl */}
{selectMode ? ( <div style={{ padding: '6px 16px', flexShrink: 0 }}>
<div style={{ <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
margin: '6px 16px', padding: '5px 8px 5px 10px', borderRadius: 8, </div>
background: 'color-mix(in srgb, var(--accent) 10%, transparent)',
display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0, fontSize: 11,
}}>
<span style={{ flex: 1, color: 'var(--accent)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{t('places.selectionCount', { count: selectedIds.size })}
</span>
<Tooltip label={selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')} placement="bottom">
<button
onClick={() => {
if (selectedIds.size === filtered.length) setSelectedIds(new Set())
else setSelectedIds(new Set(filtered.map(p => p.id)))
}}
aria-label={selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 24, height: 24, borderRadius: 6, border: 'none',
background: 'transparent', color: 'var(--text-muted)', cursor: 'pointer', padding: 0,
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Check size={13} strokeWidth={2.2} />
</button>
</Tooltip>
<Tooltip label={t('places.deleteSelected')} placement="bottom">
<button
onClick={() => {
if (selectedIds.size === 0) return
if (isMobile) setPendingDeleteIds(Array.from(selectedIds))
else onBulkDeletePlaces?.(Array.from(selectedIds))
}}
disabled={selectedIds.size === 0}
aria-label={t('places.deleteSelected')}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 24, height: 24, borderRadius: 6, border: 'none',
background: 'transparent',
color: selectedIds.size > 0 ? '#ef4444' : 'var(--text-faint)',
cursor: selectedIds.size > 0 ? 'pointer' : 'default', padding: 0,
}}
onMouseEnter={e => { if (selectedIds.size > 0) e.currentTarget.style.background = 'color-mix(in srgb, #ef4444 14%, transparent)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Trash2 size={13} strokeWidth={2} />
</button>
</Tooltip>
</div>
) : (
<div style={{ padding: '6px 16px', flexShrink: 0 }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
</div>
)}
{/* Liste */} {/* Liste */}
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}> <div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}>
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}> <span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
@@ -659,29 +363,82 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
filtered.map(place => { filtered.map(place => {
const cat = categories.find(c => c.id === place.category_id) const cat = categories.find(c => c.id === place.category_id)
const isSelected = place.id === selectedPlaceId const isSelected = place.id === selectedPlaceId
const inDay = isAssignedToSelectedDay(place.id)
const isPlanned = plannedIds.has(place.id) const isPlanned = plannedIds.has(place.id)
const inDay = inDaySet.has(place.id)
const isChecked = selectedIds.has(place.id)
return ( return (
<MemoPlaceRow <div
key={place.id} key={place.id}
place={place} draggable
category={cat} onDragStart={e => {
isSelected={isSelected} e.dataTransfer.setData('placeId', String(place.id))
isPlanned={isPlanned} e.dataTransfer.effectAllowed = 'copy'
inDay={inDay} // Backup in window für Cross-Component Drag (dataTransfer geht bei Re-Render verloren)
isChecked={isChecked} window.__dragData = { placeId: String(place.id) }
selectMode={selectMode} }}
selectedDayId={selectedDayId} onClick={() => {
canEditPlaces={canEditPlaces} if (isMobile) {
isMobile={isMobile} setDayPickerPlace(place)
t={t} } else {
onPlaceClick={onPlaceClick} onPlaceClick(isSelected ? null : place.id)
onContextMenu={openContextMenu} }
onAssignToDay={onAssignToDay} }}
toggleSelected={toggleSelected} onContextMenu={e => ctxMenu.open(e, [
setDayPickerPlace={setDayPickerPlace} canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
/> selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') },
{ divider: true },
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 14px 9px 16px',
cursor: 'grab',
background: isSelected ? 'var(--border-faint)' : 'transparent',
borderBottom: '1px solid var(--border-faint)',
transition: 'background 0.1s',
contentVisibility: 'auto',
containIntrinsicSize: '0 52px',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
>
<PlaceAvatar place={place} category={cat} size={34} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden' }}>
{cat && (() => {
const CatIcon = getCategoryIcon(cat.icon)
return <CatIcon size={11} strokeWidth={2} color={cat.color || '#6366f1'} style={{ flexShrink: 0 }} title={cat.name} />
})()}
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
{place.name}
</span>
</div>
{(place.description || place.address || cat?.name) && (
<div style={{ marginTop: 2 }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
{place.description || place.address || cat?.name}
</span>
</div>
)}
</div>
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{!inDay && selectedDayId && (
<button
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 20, height: 20, borderRadius: 6,
background: 'var(--bg-hover)', border: 'none', cursor: 'pointer',
color: 'var(--text-faint)', padding: 0, transition: 'background 0.15s, color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-faint)' }}
><Plus size={12} strokeWidth={2.5} /></button>
)}
</div>
</div>
) )
}) })
)} )}
@@ -845,14 +602,6 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
initialFile={sidebarDropFile} initialFile={sidebarDropFile}
/> />
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} /> <ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
{isMobile && (
<ConfirmDialog
isOpen={!!pendingDeleteIds?.length}
onClose={() => setPendingDeleteIds(null)}
onConfirm={() => { onBulkDeleteConfirm?.(pendingDeleteIds!); setPendingDeleteIds(null) }}
message={t('trip.confirm.deletePlaces', { count: pendingDeleteIds?.length ?? 0 })}
/>
)}
</div> </div>
) )
}) })
@@ -1,4 +1,4 @@
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-052 // FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-035
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'; import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
@@ -87,7 +87,7 @@ describe('ReservationModal', () => {
}); });
it('FE-PLANNER-RESMODAL-003: shows "Edit Reservation" title when editing', () => { it('FE-PLANNER-RESMODAL-003: shows "Edit Reservation" title when editing', () => {
const res = buildReservation({ title: 'Nice Dinner', type: 'restaurant' }); const res = buildReservation({ title: 'Flight NY', type: 'flight' });
render(<ReservationModal {...defaultProps} reservation={res} />); render(<ReservationModal {...defaultProps} reservation={res} />);
expect(screen.getByText(/Edit Reservation/i)).toBeInTheDocument(); expect(screen.getByText(/Edit Reservation/i)).toBeInTheDocument();
}); });
@@ -101,26 +101,34 @@ describe('ReservationModal', () => {
expect(onSave).not.toHaveBeenCalled(); expect(onSave).not.toHaveBeenCalled();
}); });
it('FE-PLANNER-RESMODAL-005: all 5 type buttons are visible (transport types removed)', () => { it('FE-PLANNER-RESMODAL-005: all 9 type buttons are visible', () => {
render(<ReservationModal {...defaultProps} />); render(<ReservationModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /Flight/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Accommodation/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Accommodation/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Restaurant/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Restaurant/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Train/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Rental Car/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Cruise/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Event/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Event/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Tour/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Tour/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Other/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Other/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^Flight$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^Train$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^Car$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^Cruise$/i })).not.toBeInTheDocument();
}); });
// ── Type selection ────────────────────────────────────────────────────────── // ── Type selection ──────────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-006: clicking Event type button activates it', async () => { it('FE-PLANNER-RESMODAL-006: clicking Flight type button shows flight-specific fields', async () => {
render(<ReservationModal {...defaultProps} />); render(<ReservationModal {...defaultProps} />);
const eventBtn = screen.getByRole('button', { name: /Event/i }); await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
await userEvent.click(eventBtn); // Flight-specific airline field has placeholder="Lufthansa" (exact, not the title placeholder)
expect(eventBtn).toHaveStyle({ background: 'var(--text-primary)' }); expect(screen.getByPlaceholderText('Lufthansa')).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-007: flight type shows airline/airport fields', async () => {
render(<ReservationModal {...defaultProps} />);
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
expect(screen.getByText(/Airline/i)).toBeInTheDocument();
expect(screen.getByText(/^From$/i)).toBeInTheDocument();
expect(screen.getByText(/^To$/i)).toBeInTheDocument();
}); });
it('FE-PLANNER-RESMODAL-008: hotel type shows check-in/check-out time fields', async () => { it('FE-PLANNER-RESMODAL-008: hotel type shows check-in/check-out time fields', async () => {
@@ -131,10 +139,12 @@ describe('ReservationModal', () => {
expect(screen.getByText(/Check-out/i)).toBeInTheDocument(); expect(screen.getByText(/Check-out/i)).toBeInTheDocument();
}); });
it('FE-PLANNER-RESMODAL-009: restaurant type shows location field', async () => { it('FE-PLANNER-RESMODAL-009: train type shows train number/platform/seat fields', async () => {
render(<ReservationModal {...defaultProps} />); render(<ReservationModal {...defaultProps} />);
await userEvent.click(screen.getByRole('button', { name: /Restaurant/i })); await userEvent.click(screen.getByRole('button', { name: /Train/i }));
expect(screen.getByPlaceholderText(/Address, Airport/i)).toBeInTheDocument(); expect(screen.getByText(/Train No\./i)).toBeInTheDocument();
expect(screen.getByText(/Platform/i)).toBeInTheDocument();
expect(screen.getByText(/Seat/i)).toBeInTheDocument();
}); });
it('FE-PLANNER-RESMODAL-010: hotel type hides assignment picker', async () => { it('FE-PLANNER-RESMODAL-010: hotel type hides assignment picker', async () => {
@@ -173,10 +183,13 @@ describe('ReservationModal', () => {
expect(screen.getByDisplayValue('Breakfast included')).toBeInTheDocument(); expect(screen.getByDisplayValue('Breakfast included')).toBeInTheDocument();
}); });
it('FE-PLANNER-RESMODAL-014: editing pre-fills type — restaurant type shows location field', () => { it('FE-PLANNER-RESMODAL-014: editing pre-fills type — train type does not show flight fields', () => {
const res = buildReservation({ type: 'restaurant', location: 'Via Roma 1' }); const res = buildReservation({ type: 'train' });
render(<ReservationModal {...defaultProps} reservation={res} />); render(<ReservationModal {...defaultProps} reservation={res} />);
expect(screen.getByDisplayValue('Via Roma 1')).toBeInTheDocument(); // Flight-specific airline input has placeholder="Lufthansa" (exact) — should NOT appear for train type
expect(screen.queryByPlaceholderText('Lufthansa')).not.toBeInTheDocument();
// Train fields should appear
expect(screen.getByText(/Train No\./i)).toBeInTheDocument();
}); });
// ── Validation ────────────────────────────────────────────────────────────── // ── Validation ──────────────────────────────────────────────────────────────
@@ -203,10 +216,8 @@ describe('ReservationModal', () => {
fireEvent.change(datePickers[1], { target: { value: '2025-06-09' } }); fireEvent.change(datePickers[1], { target: { value: '2025-06-09' } });
fireEvent.change(timePickers[1], { target: { value: '09:00' } }); fireEvent.change(timePickers[1], { target: { value: '09:00' } });
// When isEndBeforeStart=true the submit button is disabled, so fire submit on the form directly. // When isEndBeforeStart=true the submit button is disabled, so submit the form directly
// The Save button now lives in the Modal's sticky footer (outside the <form>), so we query const form = screen.getByRole('button', { name: /^Add$/i }).closest('form')!;
// the form by tag instead of walking up from the button.
const form = document.querySelector('form')!;
fireEvent.submit(form); fireEvent.submit(form);
expect(onSave).not.toHaveBeenCalled(); expect(onSave).not.toHaveBeenCalled();
@@ -221,18 +232,18 @@ describe('ReservationModal', () => {
// ── Submit flow ───────────────────────────────────────────────────────────── // ── Submit flow ─────────────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-016: submitting valid restaurant booking calls onSave with correct shape', async () => { it('FE-PLANNER-RESMODAL-016: submitting valid flight calls onSave with correct shape', async () => {
const onSave = vi.fn().mockResolvedValue(undefined); const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />); render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /Restaurant/i })); await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Le Jules Verne'); await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Air France 777');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled()); await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith( expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Le Jules Verne', type: 'restaurant' }) expect.objectContaining({ title: 'Air France 777', type: 'flight' })
); );
}); });
@@ -428,17 +439,17 @@ describe('ReservationModal', () => {
); );
}); });
it('FE-PLANNER-RESMODAL-031: event type — saving calls onSave with event type', async () => { it('FE-PLANNER-RESMODAL-031: train type — saving calls onSave with train type', async () => {
const onSave = vi.fn().mockResolvedValue(undefined); const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />); render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /Event/i })); await userEvent.click(screen.getByRole('button', { name: /Train/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Louvre Museum'); await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Eurostar Paris');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled()); await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith( expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Louvre Museum', type: 'event' }) expect.objectContaining({ title: 'Eurostar Paris', type: 'train' })
); );
}); });
@@ -462,7 +473,7 @@ describe('ReservationModal', () => {
it('FE-PLANNER-RESMODAL-036: file upload to existing reservation calls onFileUpload', async () => { it('FE-PLANNER-RESMODAL-036: file upload to existing reservation calls onFileUpload', async () => {
const onFileUpload = vi.fn().mockResolvedValue(undefined); const onFileUpload = vi.fn().mockResolvedValue(undefined);
const res = buildReservation({ id: 10, title: 'My Trip', type: 'other' }); const res = buildReservation({ id: 10, title: 'My Trip', type: 'flight' });
render( render(
<ReservationModal <ReservationModal
{...defaultProps} {...defaultProps}
@@ -564,18 +575,26 @@ describe('ReservationModal', () => {
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument(); expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
}); });
it('FE-PLANNER-RESMODAL-042: hotel type metadata saved with check-in time', async () => { it('FE-PLANNER-RESMODAL-042: flight type metadata saved with airline and flight number', async () => {
const onSave = vi.fn().mockResolvedValue(undefined); const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />); render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /Accommodation/i })); await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Grand Hotel'); await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'AF 447 CDG → JFK');
await userEvent.type(screen.getByPlaceholderText('Lufthansa'), 'Air France');
await userEvent.type(screen.getByPlaceholderText('LH 123'), 'AF 447');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled()); await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith( expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Grand Hotel', type: 'hotel' }) expect.objectContaining({
type: 'flight',
metadata: expect.objectContaining({
airline: 'Air France',
flight_number: 'AF 447',
}),
})
); );
}); });
@@ -615,21 +634,22 @@ describe('ReservationModal', () => {
expect(screen.getByText(/Budget category/i)).toBeInTheDocument(); expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
}); });
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => { it('FE-PLANNER-RESMODAL-045: car type shows date/time section', async () => {
render(<ReservationModal {...defaultProps} />); render(<ReservationModal {...defaultProps} />);
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i })); await userEvent.click(screen.getByRole('button', { name: /Rental Car/i }));
// Car type still shows date fields (not hotel which hides them)
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThan(0); expect(screen.getAllByTestId('date-picker').length).toBeGreaterThan(0);
}); });
}); });
it('FE-PLANNER-RESMODAL-046: other type renders and saves correctly', async () => { it('FE-PLANNER-RESMODAL-046: cruise type renders and saves correctly', async () => {
const onSave = vi.fn().mockResolvedValue(undefined); const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />); render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Other$/i })); await userEvent.click(screen.getByRole('button', { name: /Cruise/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Misc item'); await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Caribbean Cruise');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' }))); await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'cruise' })));
}); });
it('FE-PLANNER-RESMODAL-047: clicking budget category select changes the value', async () => { it('FE-PLANNER-RESMODAL-047: clicking budget category select changes the value', async () => {
@@ -710,116 +730,23 @@ describe('ReservationModal', () => {
}); });
}); });
it('FE-PLANNER-RESMODAL-035: hotel type saves correctly', async () => { it('FE-PLANNER-RESMODAL-035: flight with train number metadata saved correctly', async () => {
const onSave = vi.fn().mockResolvedValue(undefined); const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />); render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i })); await userEvent.click(screen.getByRole('button', { name: /Train/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Test'); await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE 792');
await userEvent.type(screen.getByPlaceholderText(/ICE 123/i), 'ICE 792');
await userEvent.type(screen.getByPlaceholderText(/^12$/i), '5');
await userEvent.type(screen.getByPlaceholderText(/42A/i), '14B');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled()); await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith( expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ type: 'hotel' }) expect.objectContaining({
type: 'train',
metadata: expect.objectContaining({ train_number: 'ICE 792', platform: '5', seat: '14B' }),
})
); );
}); });
// ── Hotel day-range picker — non-monotonic IDs (issue #929) ───────────────
// Mirrors DayDetailPanel-056/057 for the ReservationModal path.
// ID layout: day_number 1-9 → IDs 17-25, day_number 10-16 → IDs 1-7.
function buildNonMonotonicDaysRM() {
return [
buildDay({ id: 17, trip_id: 1, date: '2026-04-30', day_number: 1 }),
buildDay({ id: 18, trip_id: 1, date: '2026-05-01', day_number: 2 }),
buildDay({ id: 19, trip_id: 1, date: '2026-05-02', day_number: 3 }),
buildDay({ id: 20, trip_id: 1, date: '2026-05-03', day_number: 4 }),
buildDay({ id: 21, trip_id: 1, date: '2026-05-04', day_number: 5 }),
buildDay({ id: 22, trip_id: 1, date: '2026-05-05', day_number: 6 }),
buildDay({ id: 23, trip_id: 1, date: '2026-05-06', day_number: 7 }),
buildDay({ id: 24, trip_id: 1, date: '2026-05-07', day_number: 8 }),
buildDay({ id: 25, trip_id: 1, date: '2026-05-08', day_number: 9 }),
buildDay({ id: 1, trip_id: 1, date: '2026-05-09', day_number: 10 }),
buildDay({ id: 2, trip_id: 1, date: '2026-05-10', day_number: 11 }),
buildDay({ id: 3, trip_id: 1, date: '2026-05-11', day_number: 12 }),
buildDay({ id: 4, trip_id: 1, date: '2026-05-12', day_number: 13 }),
buildDay({ id: 5, trip_id: 1, date: '2026-05-13', day_number: 14 }),
buildDay({ id: 6, trip_id: 1, date: '2026-05-14', day_number: 15 }),
buildDay({ id: 7, trip_id: 1, date: '2026-05-15', day_number: 16 }),
] as any[];
}
it('FE-PLANNER-RESMODAL-050: non-monotonic IDs — end picker with low ID does not clobber start', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const days = buildNonMonotonicDaysRM();
render(<ReservationModal {...defaultProps} onSave={onSave} days={days} />);
// Switch to hotel type
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Overlap Hotel');
// Open start picker (first "Select day" trigger) and select Day 1 (id=17)
const startTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || b.textContent?.startsWith('Day '))[0];
await userEvent.click(startTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 1') && !b.textContent?.startsWith('Day 1 ') || b.textContent?.trim() === 'Day 1')!);
// Open end picker and select Day 16 (id=7, low ID but last positionally)
const endTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[1];
await userEvent.click(endTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const saved = onSave.mock.calls[0][0];
// start must stay id=17 (Day 1) — old Math.max would clobber it to id=7
expect(saved.create_accommodation?.start_day_id).toBe(17);
expect(saved.create_accommodation?.end_day_id).toBe(7);
});
it('FE-PLANNER-RESMODAL-051: non-monotonic IDs — start picker does not collapse end when start has high ID', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const days = buildNonMonotonicDaysRM();
render(<ReservationModal {...defaultProps} onSave={onSave} days={days} />);
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Span Hotel');
// Set end to Day 16 (id=7) first
const endTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[1];
await userEvent.click(endTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
// Set start to Day 9 (id=25, high ID but earlier by position than Day 16)
// Old code: Math.max(25, 7) = 25 → end collapses to Day 9.
// New code: position(id=25)=8 < position(id=7)=15 → end stays id=7.
const startTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[0];
await userEvent.click(startTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 9'))!);
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const saved = onSave.mock.calls[0][0];
expect(saved.create_accommodation?.start_day_id).toBe(25); // Day 9
expect(saved.create_accommodation?.end_day_id).toBe(7); // Day 16 — must NOT have collapsed
});
it('FE-PLANNER-RESMODAL-052: hotel with no accommodation_id sends assignment_id as null (issue #934)', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
// Hotel reservation with assignment_id set but no accommodation
const res = buildReservation({
id: 10, title: 'Stale Hotel', type: 'hotel', status: 'confirmed',
accommodation_id: null, assignment_id: 99,
} as any);
render(<ReservationModal {...defaultProps} onSave={onSave} reservation={res} />);
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0].assignment_id).toBeNull();
});
}); });
+324 -149
View File
@@ -5,17 +5,72 @@ import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { Hotel, Utensils, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react' import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker' import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker' import CustomTimePicker from '../shared/CustomTimePicker'
import { openFile } from '../../utils/fileDownload' import { openFile } from '../../utils/fileDownload'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types' import AirportSelect, { type Airport } from './AirportSelect'
import LocationSelect, { type LocationPoint } from './LocationSelect'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, ReservationEndpoint } from '../../types'
const TRANSPORT_TYPES = ['flight', 'train', 'cruise', 'car'] as const
type TransportType = typeof TRANSPORT_TYPES[number]
const isTransport = (t: string): t is TransportType => (TRANSPORT_TYPES as readonly string[]).includes(t)
interface EndpointPick {
airport?: Airport
location?: LocationPoint
}
function endpointFromAirport(a: Airport, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
return {
role, sequence,
name: a.city ? `${a.city} (${a.iata})` : a.name,
code: a.iata,
lat: a.lat, lng: a.lng,
timezone: a.tz,
local_date: date,
local_time: time,
}
}
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
return {
role, sequence,
name: l.name,
code: null,
lat: l.lat, lng: l.lng,
timezone: null,
local_date: date,
local_time: time,
}
}
function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null {
if (!e || !e.code) return null
return {
iata: e.code, icao: null,
name: e.name, city: e.name.replace(/\s*\([A-Z]{3}\)\s*$/, ''),
country: '',
lat: e.lat, lng: e.lng,
tz: e.timezone || '',
}
}
function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint | null {
if (!e) return null
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
}
const TYPE_OPTIONS = [ const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel }, { value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils }, { value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils },
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket }, { value: 'event', labelKey: 'reservations.type.event', Icon: Ticket },
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users }, { value: 'tour', labelKey: 'reservations.type.tour', Icon: Users },
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText }, { value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
@@ -29,6 +84,7 @@ function buildAssignmentOptions(days, assignments, t, locale) {
const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number }) const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number })
const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : '' const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : ''
const groupLabel = `${dayLabel}${dateStr}` const groupLabel = `${dayLabel}${dateStr}`
// Group header (non-selectable)
options.push({ value: `_header_${day.id}`, label: groupLabel, disabled: true, isHeader: true }) options.push({ value: `_header_${day.id}`, label: groupLabel, disabled: true, isHeader: true })
for (let i = 0; i < da.length; i++) { for (let i = 0; i < da.length; i++) {
const place = da[i].place const place = da[i].place
@@ -59,10 +115,9 @@ interface ReservationModalProps {
onFileUpload?: (fd: FormData) => Promise<void> onFileUpload?: (fd: FormData) => Promise<void>
onFileDelete: (fileId: number) => Promise<void> onFileDelete: (fileId: number) => Promise<void>
accommodations?: Accommodation[] accommodations?: Accommodation[]
defaultAssignmentId?: number | null
} }
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null }: ReservationModalProps) { export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [] }: ReservationModalProps) {
const { id: tripId } = useParams<{ id: string }>() const { id: tripId } = useParams<{ id: string }>()
const loadFiles = useTripStore(s => s.loadFiles) const loadFiles = useTripStore(s => s.loadFiles)
const toast = useToast() const toast = useToast()
@@ -80,16 +135,22 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const [form, setForm] = useState({ const [form, setForm] = useState({
title: '', type: 'other', status: 'pending', title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '', reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number, notes: '', assignment_id: '', accommodation_id: '',
price: '', budget_category: '', price: '', budget_category: '',
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
meta_departure_timezone: '', meta_arrival_timezone: '',
meta_train_number: '', meta_platform: '', meta_seat: '',
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '', meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number, hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
}) })
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [uploadingFile, setUploadingFile] = useState(false) const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState([]) const [pendingFiles, setPendingFiles] = useState([])
const [showFilePicker, setShowFilePicker] = useState(false) const [showFilePicker, setShowFilePicker] = useState(false)
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([]) const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
const [unlinkedFileIds, setUnlinkedFileIds] = useState<number[]>([])
const [fromPick, setFromPick] = useState<EndpointPick>({})
const [toPick, setToPick] = useState<EndpointPick>({})
const assignmentOptions = useMemo( const assignmentOptions = useMemo(
() => buildAssignmentOptions(days, assignments, t, locale), () => buildAssignmentOptions(days, assignments, t, locale),
@@ -99,6 +160,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
useEffect(() => { useEffect(() => {
if (reservation) { if (reservation) {
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {}) const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
// Parse end_date from reservation_end_time if it's a full ISO datetime
const rawEnd = reservation.reservation_end_time || '' const rawEnd = reservation.reservation_end_time || ''
let endDate = '' let endDate = ''
let endTime = rawEnd let endTime = rawEnd
@@ -121,6 +183,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
notes: reservation.notes || '', notes: reservation.notes || '',
assignment_id: reservation.assignment_id || '', assignment_id: reservation.assignment_id || '',
accommodation_id: reservation.accommodation_id || '', accommodation_id: reservation.accommodation_id || '',
meta_airline: meta.airline || '',
meta_flight_number: meta.flight_number || '',
meta_departure_airport: meta.departure_airport || '',
meta_arrival_airport: meta.arrival_airport || '',
meta_departure_timezone: meta.departure_timezone || '',
meta_arrival_timezone: meta.arrival_timezone || '',
meta_train_number: meta.train_number || '',
meta_platform: meta.platform || '',
meta_seat: meta.seat || '',
meta_check_in_time: meta.check_in_time || '', meta_check_in_time: meta.check_in_time || '',
meta_check_in_end_time: meta.check_in_end_time || '', meta_check_in_end_time: meta.check_in_end_time || '',
meta_check_out_time: meta.check_out_time || '', meta_check_out_time: meta.check_out_time || '',
@@ -130,38 +201,61 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
price: meta.price || '', price: meta.price || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '', budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
}) })
const eps = reservation.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
if (reservation.type === 'flight') {
setFromPick({ airport: airportFromEndpoint(from) || undefined })
setToPick({ airport: airportFromEndpoint(to) || undefined })
} else if (isTransport(reservation.type)) {
setFromPick({ location: locationFromEndpoint(from) || undefined })
setToPick({ location: locationFromEndpoint(to) || undefined })
} else {
setFromPick({})
setToPick({})
}
} else { } else {
setForm({ setForm({
title: '', type: 'other', status: 'pending', title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '', reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '', notes: '', assignment_id: '', accommodation_id: '',
price: '', budget_category: '', price: '', budget_category: '',
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
meta_departure_timezone: '', meta_arrival_timezone: '',
meta_train_number: '', meta_platform: '', meta_seat: '',
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '', meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
}) })
setPendingFiles([]) setPendingFiles([])
setFromPick({})
setToPick({})
} }
}, [reservation, isOpen, selectedDayId, defaultAssignmentId]) }, [reservation, isOpen, selectedDayId])
// Re-hydrate hotel day range when the accommodations prop arrives after the modal opens
// (race: tripAccommodations fetch may complete after isOpen fires, leaving hotel fields empty)
useEffect(() => {
if (!isOpen || !reservation || reservation.type !== 'hotel' || !reservation.accommodation_id) return
const acc = accommodations.find(a => a.id == reservation.accommodation_id)
if (!acc) return
setForm(prev => {
if (prev.hotel_place_id !== '' || prev.hotel_start_day !== '' || prev.hotel_end_day !== '') return prev
return { ...prev, hotel_place_id: acc.place_id, hotel_start_day: acc.start_day_id, hotel_end_day: acc.end_day_id }
})
}, [accommodations, isOpen, reservation])
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value })) const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
// Validate that end datetime is after start datetime
const isEndBeforeStart = (() => { const isEndBeforeStart = (() => {
if (!form.end_date || !form.reservation_time) return false if (!form.end_date || !form.reservation_time) return false
const startDate = form.reservation_time.split('T')[0] const startDate = form.reservation_time.split('T')[0]
const startTime = form.reservation_time.split('T')[1] || '00:00' const startTime = form.reservation_time.split('T')[1] || '00:00'
const endTime = form.reservation_end_time || '00:00' const endTime = form.reservation_end_time || '00:00'
// For flights, compare in UTC using timezone offsets
if (form.type === 'flight') {
const parseOffset = (tz: string): number | null => {
if (!tz) return null
const m = tz.trim().match(/^(?:UTC|GMT)?\s*([+-])(\d{1,2})(?::(\d{2}))?$/i)
if (!m) return null
const sign = m[1] === '+' ? 1 : -1
return sign * (parseInt(m[2]) * 60 + parseInt(m[3] || '0'))
}
const depOffset = parseOffset(form.meta_departure_timezone)
const arrOffset = parseOffset(form.meta_arrival_timezone)
if (depOffset === null || arrOffset === null) return false
const depMinutes = new Date(`${startDate}T${startTime}`).getTime() - depOffset * 60000
const arrMinutes = new Date(`${form.end_date}T${endTime}`).getTime() - arrOffset * 60000
return arrMinutes <= depMinutes
}
const startFull = `${startDate}T${startTime}` const startFull = `${startDate}T${startTime}`
const endFull = `${form.end_date}T${endTime}` const endFull = `${form.end_date}T${endTime}`
return endFull <= startFull return endFull <= startFull
@@ -174,42 +268,72 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
setIsSaving(true) setIsSaving(true)
try { try {
const metadata: Record<string, string> = {} const metadata: Record<string, string> = {}
if (form.type === 'hotel') { if (form.type === 'flight') {
if (form.meta_airline) metadata.airline = form.meta_airline
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
if (fromPick.airport) {
metadata.departure_airport = fromPick.airport.iata
metadata.departure_timezone = fromPick.airport.tz
}
if (toPick.airport) {
metadata.arrival_airport = toPick.airport.iata
metadata.arrival_timezone = toPick.airport.tz
}
} else if (form.type === 'hotel') {
if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time
if (form.meta_check_in_end_time) metadata.check_in_end_time = form.meta_check_in_end_time if (form.meta_check_in_end_time) metadata.check_in_end_time = form.meta_check_in_end_time
if (form.meta_check_out_time) metadata.check_out_time = form.meta_check_out_time if (form.meta_check_out_time) metadata.check_out_time = form.meta_check_out_time
} else if (form.type === 'train') {
if (form.meta_train_number) metadata.train_number = form.meta_train_number
if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat
} }
// Combine end_date + end_time into reservation_end_time
let combinedEndTime = form.reservation_end_time let combinedEndTime = form.reservation_end_time
if (form.end_date) { if (form.end_date) {
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
} else if (form.reservation_end_time && form.reservation_time) {
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
} }
if (isBudgetEnabled) { if (isBudgetEnabled) {
if (form.price) metadata.price = form.price if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category if (form.budget_category) metadata.budget_category = form.budget_category
} }
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
if (isTransport(form.type)) {
const startDate = (form.reservation_time || '').split('T')[0] || null
const startTime = (form.reservation_time || '').split('T')[1]?.slice(0, 5) || null
const endDate = form.end_date || null
const endTime = form.reservation_end_time || null
if (form.type === 'flight') {
if (fromPick.airport) endpoints.push(endpointFromAirport(fromPick.airport, 'from', 0, startDate, startTime))
if (toPick.airport) endpoints.push(endpointFromAirport(toPick.airport, 'to', 1, endDate, endTime))
} else {
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, startTime))
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, endTime))
}
}
const saveData: Record<string, any> = { const saveData: Record<string, any> = {
title: form.title, type: form.type, status: form.status, title: form.title, type: form.type, status: form.status,
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null), reservation_time: form.type === 'hotel' ? null : form.reservation_time,
reservation_end_time: form.type === 'hotel' ? null : (combinedEndTime || null), reservation_end_time: form.type === 'hotel' ? null : combinedEndTime,
location: form.location, confirmation_number: form.confirmation_number, location: form.location, confirmation_number: form.confirmation_number,
notes: form.notes, notes: form.notes,
assignment_id: (form.type === 'hotel' && !form.accommodation_id) ? null : (form.assignment_id || null), assignment_id: form.assignment_id || null,
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null, accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
metadata: Object.keys(metadata).length > 0 ? metadata : null, metadata: Object.keys(metadata).length > 0 ? metadata : null,
endpoints: [], endpoints: isTransport(form.type) ? endpoints : [],
needs_review: false, needs_review: false,
} }
// Auto-create/update budget entry if price is set, or signal removal if cleared
if (isBudgetEnabled) { if (isBudgetEnabled) {
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0 saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' } ? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 } : { total_price: 0 }
} }
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) { // If hotel with place + days, pass hotel data for auto-creation or update
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
saveData.create_accommodation = { saveData.create_accommodation = {
place_id: form.hotel_place_id || null, place_id: form.hotel_place_id,
start_day_id: form.hotel_start_day, start_day_id: form.hotel_start_day,
end_day_id: form.hotel_end_day, end_day_id: form.hotel_end_day,
check_in: form.meta_check_in_time || null, check_in: form.meta_check_in_time || null,
@@ -273,22 +397,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' } const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' }
return ( return (
<Modal <Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="2xl">
isOpen={isOpen}
onClose={onClose}
title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')}
size="2xl"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim() || isEndBeforeStart} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() || isEndBeforeStart ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
}
>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Type selector */} {/* Type selector */}
@@ -319,7 +428,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
{/* Assignment Picker (hidden for hotels) */} {/* Assignment Picker (hidden for hotels) */}
{form.type !== 'hotel' && assignmentOptions.length > 0 && ( {form.type !== 'hotel' && assignmentOptions.length > 0 && (
<div> <div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}> <label style={labelStyle}>
<Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} /> <Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
@@ -346,105 +455,151 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
size="sm" size="sm"
/> />
</div> </div>
</div> </div>
)} )}
{/* Start Date/Time + End Date/Time + Status (hidden for hotels) */} {/* Start Date/Time + End Date/Time + Status (hidden for hotels) */}
{form.type !== 'hotel' && ( {form.type !== 'hotel' && (
<> <>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.date')}</label> <label style={labelStyle}>{form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}</label>
<CustomDatePicker <CustomDatePicker
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()} value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
onChange={d => { onChange={d => {
const [, tm] = (form.reservation_time || '').split('T') const [, t] = (form.reservation_time || '').split('T')
set('reservation_time', d ? (tm ? `${d}T${tm}` : d) : '') set('reservation_time', d ? (t ? `${d}T${t}` : d) : '')
}} }}
/> />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.startTime')}</label>
<CustomTimePicker
value={(() => { const [, tm] = (form.reservation_time || '').split('T'); return tm || '' })()}
onChange={tm => {
const [d] = (form.reservation_time || '').split('T')
const selectedDay = days.find(dy => dy.id === selectedDayId)
const date = d || selectedDay?.date || new Date().toISOString().split('T')[0]
set('reservation_time', tm ? `${date}T${tm}` : date)
}}
/>
</div>
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}> <label style={labelStyle}>{form.type === 'flight' ? t('reservations.departureTime') : form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}</label>
<label style={labelStyle}>{t('reservations.endDate')}</label> <CustomTimePicker
<CustomDatePicker value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
value={form.end_date} onChange={t => {
onChange={d => set('end_date', d || '')} const [d] = (form.reservation_time || '').split('T')
/> const selectedDay = days.find(dy => dy.id === selectedDayId)
</div> const date = d || selectedDay?.date || new Date().toISOString().split('T')[0]
<div style={{ flex: 1, minWidth: 0 }}> set('reservation_time', t ? `${date}T${t}` : date)
<label style={labelStyle}>{t('reservations.endTime')}</label> }}
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} /> />
</div>
</div> </div>
{isEndBeforeStart && ( {form.type === 'flight' && fromPick.airport && (
<div style={{ fontSize: 11, color: '#ef4444', marginTop: -6 }}>{t('reservations.validation.endBeforeStart')}</div> <div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.meta.departureTimezone')}</label>
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
{fromPick.airport.tz}
</div>
</div>
)} )}
</> </div>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}</label>
<CustomDatePicker
value={form.end_date}
onChange={d => set('end_date', d || '')}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}</label>
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
</div>
{form.type === 'flight' && toPick.airport && (
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.meta.arrivalTimezone')}</label>
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
{toPick.airport.tz}
</div>
</div>
)}
</div>
{isEndBeforeStart && (
<div style={{ fontSize: 11, color: '#ef4444', marginTop: -6 }}>{t('reservations.validation.endBeforeStart')}</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
</div>
</div>
</>
)} )}
{/* Location */} {/* Location + Booking Code */}
{form.type !== 'hotel' && ( <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<label style={labelStyle}>{t('reservations.locationAddress')}</label> <label style={labelStyle}>{t('reservations.locationAddress')}</label>
<input type="text" value={form.location} onChange={e => set('location', e.target.value)} <input type="text" value={form.location} onChange={e => set('location', e.target.value)}
placeholder={t('reservations.locationPlaceholder')} style={inputStyle} /> placeholder={t('reservations.locationPlaceholder')} style={inputStyle} />
</div> </div>
)}
{/* Booking Code + Status */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<label style={labelStyle}>{t('reservations.confirmationCode')}</label> <label style={labelStyle}>{t('reservations.confirmationCode')}</label>
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)} <input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} /> placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
</div> </div>
<div>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
</div>
</div> </div>
{/* Hotel fields */} {/* From / To endpoints for transport bookings */}
{isTransport(form.type) && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.from')}</label>
{form.type === 'flight' ? (
<AirportSelect value={fromPick.airport || null} onChange={a => setFromPick({ airport: a || undefined })} />
) : (
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
)}
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.to')}</label>
{form.type === 'flight' ? (
<AirportSelect value={toPick.airport || null} onChange={a => setToPick({ airport: a || undefined })} />
) : (
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
)}
</div>
</div>
)}
{form.type === 'flight' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.airline') || 'Airline'}</label>
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
placeholder="Lufthansa" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.flightNumber') || 'Flight No.'}</label>
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
placeholder="LH 123" style={inputStyle} />
</div>
</div>
)}
{form.type === 'hotel' && ( {form.type === 'hotel' && (
<> <>
{/* Hotel place + day range */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div> <div>
<label style={labelStyle}>{t('reservations.meta.hotelPlace')}</label> <label style={labelStyle}>{t('reservations.meta.hotelPlace')}</label>
<CustomSelect <CustomSelect
value={form.hotel_place_id} value={form.hotel_place_id}
onChange={value => { onChange={value => {
set('hotel_place_id', value)
const p = places.find(pl => pl.id === value) const p = places.find(pl => pl.id === value)
setForm(prev => { if (p) {
const next = { ...prev, hotel_place_id: value } if (!form.title) set('title', p.name)
if (!value) { if (!form.location && p.address) set('location', p.address)
next.location = '' }
} else if (p) {
if (!prev.title) next.title = p.name
if (!prev.location && p.address) next.location = p.address
}
return next
})
}} }}
placeholder={t('reservations.meta.pickHotel')} placeholder={t('reservations.meta.pickHotel')}
options={[ options={[
@@ -459,22 +614,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<label style={labelStyle}>{t('reservations.meta.fromDay')}</label> <label style={labelStyle}>{t('reservations.meta.fromDay')}</label>
<CustomSelect <CustomSelect
value={form.hotel_start_day} value={form.hotel_start_day}
onChange={value => setForm(prev => ({ onChange={value => set('hotel_start_day', value)}
...prev,
hotel_start_day: value,
hotel_end_day: days.findIndex(d => d.id === value) > days.findIndex(d => d.id === prev.hotel_end_day)
? value : prev.hotel_end_day,
}))}
placeholder={t('reservations.meta.selectDay')} placeholder={t('reservations.meta.selectDay')}
options={days.map(d => { options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))}
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
const dayBadge = d.title ? t('dayplan.dayN', { n: d.day_number }) : undefined
return {
value: d.id,
label: d.title || t('dayplan.dayN', { n: d.day_number }),
badge: dateBadge ?? dayBadge,
}
})}
size="sm" size="sm"
/> />
</div> </div>
@@ -482,27 +624,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<label style={labelStyle}>{t('reservations.meta.toDay')}</label> <label style={labelStyle}>{t('reservations.meta.toDay')}</label>
<CustomSelect <CustomSelect
value={form.hotel_end_day} value={form.hotel_end_day}
onChange={value => setForm(prev => ({ onChange={value => set('hotel_end_day', value)}
...prev,
hotel_start_day: days.findIndex(d => d.id === value) < days.findIndex(d => d.id === prev.hotel_start_day)
? value : prev.hotel_start_day,
hotel_end_day: value,
}))}
placeholder={t('reservations.meta.selectDay')} placeholder={t('reservations.meta.selectDay')}
options={days.map(d => { options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))}
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
const dayBadge = d.title ? t('dayplan.dayN', { n: d.day_number }) : undefined
return {
value: d.id,
label: d.title || t('dayplan.dayN', { n: d.day_number }),
badge: dateBadge ?? dayBadge,
}
})}
size="sm" size="sm"
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> {/* Check-in/out times + Status */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div> <div>
<label style={labelStyle}>{t('reservations.meta.checkIn')}</label> <label style={labelStyle}>{t('reservations.meta.checkIn')}</label>
<CustomTimePicker value={form.meta_check_in_time} onChange={v => set('meta_check_in_time', v)} /> <CustomTimePicker value={form.meta_check_in_time} onChange={v => set('meta_check_in_time', v)} />
@@ -515,10 +645,42 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<label style={labelStyle}>{t('reservations.meta.checkOut')}</label> <label style={labelStyle}>{t('reservations.meta.checkOut')}</label>
<CustomTimePicker value={form.meta_check_out_time} onChange={v => set('meta_check_out_time', v)} /> <CustomTimePicker value={form.meta_check_out_time} onChange={v => set('meta_check_out_time', v)} />
</div> </div>
<div>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
</div>
</div> </div>
</> </>
)} )}
{form.type === 'train' && (
<div className="grid grid-cols-3 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.trainNumber') || 'Train No.'}</label>
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
placeholder="ICE 123" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.platform') || 'Platform'}</label>
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
placeholder="12" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.seat') || 'Seat'}</label>
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
placeholder="42A" style={inputStyle} />
</div>
</div>
)}
{/* Notes */} {/* Notes */}
<div> <div>
<label style={labelStyle}>{t('reservations.notes')}</label> <label style={labelStyle}>{t('reservations.notes')}</label>
@@ -537,9 +699,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span> <span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a> <a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
<button type="button" onClick={async () => { <button type="button" onClick={async () => {
// Always unlink, never delete the file
// Clear primary reservation_id if it points to this reservation
if (f.reservation_id === reservation?.id) { if (f.reservation_id === reservation?.id) {
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {} try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
} }
// Remove from file_links if linked there
try { try {
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`) const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id) const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
@@ -572,6 +737,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<Paperclip size={11} /> <Paperclip size={11} />
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')} {uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
</button>} </button>}
{/* Link existing file picker */}
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && ( {reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{ <button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
@@ -615,7 +781,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div> </div>
</div> </div>
{/* Price + Budget Category */} {/* Price + Budget Category — only shown when budget addon is enabled */}
{isBudgetEnabled && ( {isBudgetEnabled && (
<> <>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
@@ -623,7 +789,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<label style={labelStyle}>{t('reservations.price')}</label> <label style={labelStyle}>{t('reservations.price')}</label>
<input type="text" inputMode="decimal" value={form.price} <input type="text" inputMode="decimal" value={form.price}
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }} onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }} onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } set('price', t) }}
placeholder="0.00" placeholder="0.00"
style={inputStyle} /> style={inputStyle} />
</div> </div>
@@ -649,6 +815,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</> </>
)} )}
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="submit" disabled={isSaving || !form.title.trim() || isEndBeforeStart} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() || isEndBeforeStart ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
</form> </form>
</Modal> </Modal>
) )
@@ -1,4 +1,4 @@
import { useState, useMemo, useEffect } from 'react' import { useState, useMemo } from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
@@ -11,9 +11,6 @@ import {
ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle, ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle,
} from 'lucide-react' } from 'lucide-react'
import { openFile } from '../../utils/fileDownload' import { openFile } from '../../utils/fileDownload'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types' import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
interface AssignmentLookupEntry { interface AssignmentLookupEntry {
@@ -72,10 +69,9 @@ interface ReservationCardProps {
onNavigateToFiles: () => void onNavigateToFiles: () => void
assignmentLookup: Record<number, AssignmentLookupEntry> assignmentLookup: Record<number, AssignmentLookupEntry>
canEdit: boolean canEdit: boolean
days?: Day[]
} }
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit, days = [] }: ReservationCardProps) { function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit }: ReservationCardProps) {
const { toggleReservationStatus } = useTripStore() const { toggleReservationStatus } = useTripStore()
const toast = useToast() const toast = useToast()
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
@@ -113,34 +109,6 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
const hasCode = !!r.confirmation_number const hasCode = !!r.confirmation_number
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
const isHotel = r.type === 'hotel'
const startDay = r.day_id ? days.find(d => d.id === r.day_id)
: (isHotel && r.accommodation_start_day_id) ? days.find(d => d.id === r.accommodation_start_day_id)
: undefined
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id)
: (isHotel && r.accommodation_end_day_id) ? days.find(d => d.id === r.accommodation_end_day_id)
: undefined
const DayLabel = ({ day }: { day: typeof startDay }) => {
if (!day) return null
const name = day.title || t('dayplan.dayN', { n: day.day_number })
const badge = day.date
? new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
: null
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<span>{name}</span>
{badge && (
<span style={{
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
background: 'var(--bg-secondary)', padding: '1px 6px', borderRadius: 999,
}}>{badge}</span>
)}
</span>
)
}
return ( return (
<div style={{ <div style={{
borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column',
@@ -151,15 +119,13 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'} onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'} onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
> >
{/* Header wraps to a second row on narrow screens so the status/category chips {/* Header */}
never collide with the title. */}
<div style={{ <div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
flexWrap: 'wrap',
padding: '12px 14px', padding: '12px 14px',
background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)', background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)',
}}> }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0, flexWrap: 'wrap' }}> <div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<span style={{ <span style={{
display: 'inline-flex', alignItems: 'center', gap: 6, display: 'inline-flex', alignItems: 'center', gap: 6,
fontSize: 12, fontWeight: 600, color: confirmed ? '#16a34a' : '#d97706', fontSize: 12, fontWeight: 600, color: confirmed ? '#16a34a' : '#d97706',
@@ -220,18 +186,6 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{/* Body */} {/* Body */}
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}> <div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}>
{/* Day label for transport/hotel reservations linked to days */}
{(isTransportType || isHotel) && startDay && (
<div>
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
<div style={{ ...fieldValueStyle, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, flexWrap: 'wrap' }}>
<DayLabel day={startDay} />
{endDay && endDay.id !== startDay.id && (
<><span style={{ color: 'var(--text-faint)' }}></span><DayLabel day={endDay} /></>
)}
</div>
</div>
)}
{/* Date / Time row */} {/* Date / Time row */}
{hasDate && ( {hasDate && (
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasTime ? '1fr 1fr' : '1fr' }}> <div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasTime ? '1fr 1fr' : '1fr' }}>
@@ -239,16 +193,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
<div style={fieldLabelStyle}>{t('reservations.date')}</div> <div style={fieldLabelStyle}>{t('reservations.date')}</div>
<div style={{ ...fieldValueStyle, textAlign: 'center' }}> <div style={{ ...fieldValueStyle, textAlign: 'center' }}>
{fmtDate(r.reservation_time)} {fmtDate(r.reservation_time)}
{(() => { {r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
const endDatePart = r.reservation_end_time
? r.reservation_end_time.includes('T')
? r.reservation_end_time.split('T')[0]
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time)
? r.reservation_end_time
: null
: null
return endDatePart && endDatePart !== r.reservation_time.split('T')[0]
})() && (
<> {fmtDate(r.reservation_end_time)}</> <> {fmtDate(r.reservation_end_time)}</>
)} )}
</div> </div>
@@ -367,9 +312,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{r.notes && ( {r.notes && (
<div> <div>
<div style={fieldLabelStyle}>{t('reservations.notes')}</div> <div style={fieldLabelStyle}>{t('reservations.notes')}</div>
<div className="collab-note-md" style={{ ...fieldValueStyle, fontWeight: 400, lineHeight: 1.5, wordBreak: 'break-word', overflowWrap: 'anywhere' }}> <div style={{ ...fieldValueStyle, fontWeight: 400, lineHeight: 1.5 }}>{r.notes}</div>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{r.notes}</Markdown>
</div>
</div> </div>
)} )}
@@ -439,20 +382,10 @@ interface SectionProps {
children: React.ReactNode children: React.ReactNode
defaultOpen?: boolean defaultOpen?: boolean
accent: 'green' | string accent: 'green' | string
storageKey?: string
} }
function Section({ title, count, children, defaultOpen = true, accent, storageKey }: SectionProps) { function Section({ title, count, children, defaultOpen = true, accent }: SectionProps) {
const [open, setOpen] = useState(() => { const [open, setOpen] = useState(defaultOpen)
if (!storageKey || typeof window === 'undefined') return defaultOpen
const stored = window.localStorage.getItem(storageKey)
if (stored === null) return defaultOpen
return stored === '1'
})
useEffect(() => {
if (!storageKey || typeof window === 'undefined') return
window.localStorage.setItem(storageKey, open ? '1' : '0')
}, [open, storageKey])
return ( return (
<div style={{ marginBottom: 28 }}> <div style={{ marginBottom: 28 }}>
<button onClick={() => setOpen(o => !o)} style={{ <button onClick={() => setOpen(o => !o)} style={{
@@ -487,11 +420,9 @@ interface ReservationsPanelProps {
onEdit: (reservation: Reservation) => void onEdit: (reservation: Reservation) => void
onDelete: (id: number) => void onDelete: (id: number) => void
onNavigateToFiles: () => void onNavigateToFiles: () => void
titleKey?: string
addManualKey?: string
} }
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) { export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const can = useCanDo() const can = useCanDo()
const trip = useTripStore((s) => s.trip) const trip = useTripStore((s) => s.trip)
@@ -542,7 +473,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}> }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}> <h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
{t(titleKey)} {t('reservations.title')}
</h2> </h2>
{reservations.length > 0 && ( {reservations.length > 0 && (
@@ -616,7 +547,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
onMouseLeave={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '1'}
> >
<Plus size={14} strokeWidth={2.5} /> <Plus size={14} strokeWidth={2.5} />
<span className="hidden sm:inline">{t(addManualKey)}</span> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
</button> </button>
)} )}
</div> </div>
@@ -637,13 +568,13 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
) : ( ) : (
<> <>
{allPending.length > 0 && ( {allPending.length > 0 && (
<Section title={t('reservations.pending')} count={allPending.length} accent="gray" storageKey={`trek:bookings-pending-open:${tripId}`}> <Section title={t('reservations.pending')} count={allPending.length} accent="gray">
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} days={days} />)} {allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
</Section> </Section>
)} )}
{allConfirmed.length > 0 && ( {allConfirmed.length > 0 && (
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green" storageKey={`trek:bookings-confirmed-open:${tripId}`}> <Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} days={days} />)} {allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
</Section> </Section>
)} )}
</> </>
@@ -1,324 +0,0 @@
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-021
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import {
buildUser,
buildTrip,
buildDay,
buildReservation,
buildTripFile,
} from '../../../tests/helpers/factories';
import { TransportModal } from './TransportModal';
vi.mock('react-router-dom', async (importActual) => {
const actual = await importActual<typeof import('react-router-dom')>();
return { ...actual, useParams: () => ({ id: '1' }) };
});
vi.mock('../shared/CustomTimePicker', () => ({
default: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
<input data-testid="time-picker" type="text" value={value} onChange={e => onChange(e.target.value)} />
),
}));
vi.mock('./AirportSelect', () => ({
default: ({ onChange }: { onChange: (a: any) => void }) => (
<input data-testid="airport-select" type="text" onChange={e => onChange({ iata: e.target.value, name: e.target.value, city: '', country: '', lat: 0, lng: 0, tz: 'UTC', icao: null })} />
),
}));
vi.mock('./LocationSelect', () => ({
default: ({ onChange }: { onChange: (l: any) => void }) => (
<input data-testid="location-select" type="text" onChange={e => onChange({ name: e.target.value, lat: 0, lng: 0, address: null })} />
),
}));
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
onSave: vi.fn().mockResolvedValue(undefined),
reservation: null,
days: [],
selectedDayId: null,
files: [],
onFileUpload: vi.fn().mockResolvedValue(undefined),
onFileDelete: vi.fn().mockResolvedValue(undefined),
};
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }), budgetItems: [] });
vi.clearAllMocks();
});
describe('TransportModal', () => {
// ── Rendering ──────────────────────────────────────────────────────────────
it('FE-PLANNER-TRANSMODAL-001: renders without crashing', () => {
render(<TransportModal {...defaultProps} />);
expect(document.body).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-002: shows "Add transport" title for new transport', () => {
render(<TransportModal {...defaultProps} reservation={null} />);
expect(screen.getByText(/Add transport/i)).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-003: shows "Edit transport" title when editing', () => {
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
render(<TransportModal {...defaultProps} reservation={res} />);
expect(screen.getByText(/Edit transport/i)).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-004: title input is required — onSave not called with empty title', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
expect(onSave).not.toHaveBeenCalled();
});
it('FE-PLANNER-TRANSMODAL-005: all 4 transport type buttons are visible', () => {
render(<TransportModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /^Flight$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Train$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Car$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Cruise$/i })).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-006: editing pre-fills title', () => {
const res = buildReservation({ title: 'LH123 Frankfurt', type: 'flight' });
render(<TransportModal {...defaultProps} reservation={res} />);
expect(screen.getByDisplayValue('LH123 Frankfurt')).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-007: edit mode save button shows "Update"', () => {
const res = buildReservation({ title: 'My Train', type: 'train' });
render(<TransportModal {...defaultProps} reservation={res} />);
expect(screen.getByRole('button', { name: /^Update$/i })).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-008: Cancel button calls onClose', async () => {
const onClose = vi.fn();
render(<TransportModal {...defaultProps} onClose={onClose} />);
await userEvent.click(screen.getByRole('button', { name: /Cancel/i }));
expect(onClose).toHaveBeenCalled();
});
it('FE-PLANNER-TRANSMODAL-009: submitting valid flight calls onSave with correct type', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH456');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ title: 'LH456', type: 'flight' }));
});
it('FE-PLANNER-TRANSMODAL-010: switching to train type calls onSave with train type', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Eurostar');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'train' }));
});
// ── Budget addon ─────────────────────────────────────────────────────────────
it('FE-PLANNER-TRANSMODAL-011: budget section visible when addon is enabled', () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
render(<TransportModal {...defaultProps} />);
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-012: budget section not shown when addon is disabled', () => {
render(<TransportModal {...defaultProps} />);
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-013: budget fields included in onSave when price is set', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train');
await userEvent.type(screen.getByPlaceholderText('0.00'), '85');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 85 }) })
);
});
// ── File attachment ───────────────────────────────────────────────────────────
it('FE-PLANNER-TRANSMODAL-014: attach file button rendered when onFileUpload provided', () => {
render(<TransportModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-015: attach file button absent when onFileUpload is undefined', () => {
render(<TransportModal {...defaultProps} onFileUpload={undefined} />);
expect(screen.queryByRole('button', { name: /Attach file/i })).not.toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-016: attached files shown for existing transport', () => {
const res = buildReservation({ id: 5, type: 'flight' });
const file = buildTripFile({ id: 1, trip_id: 1, original_name: 'boarding-pass.pdf' });
(file as any).reservation_id = 5;
render(<TransportModal {...defaultProps} reservation={res} files={[file]} />);
expect(screen.getByText('boarding-pass.pdf')).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-017: pending file added for new transport on file input change', async () => {
render(<TransportModal {...defaultProps} reservation={null} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const testFile = new File(['content'], 'itinerary.pdf', { type: 'application/pdf' });
fireEvent.change(fileInput, { target: { files: [testFile] } });
await waitFor(() => expect(screen.getByText('itinerary.pdf')).toBeInTheDocument());
});
it('FE-PLANNER-TRANSMODAL-018: file upload to existing transport calls onFileUpload with correct FormData', async () => {
const onFileUpload = vi.fn().mockResolvedValue(undefined);
const res = buildReservation({ id: 10, type: 'train', title: 'Eurostar' });
render(<TransportModal {...defaultProps} reservation={res} onFileUpload={onFileUpload} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const testFile = new File(['content'], 'ticket.pdf', { type: 'application/pdf' });
fireEvent.change(fileInput, { target: { files: [testFile] } });
await waitFor(() => expect(onFileUpload).toHaveBeenCalled());
const [fd] = onFileUpload.mock.calls[0] as [FormData];
expect(fd.get('file')).toBeTruthy();
expect(fd.get('reservation_id')).toBe('10');
});
it('FE-PLANNER-TRANSMODAL-019: link existing file button appears when unattached files exist', () => {
const res = buildReservation({ id: 5, type: 'flight' });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-020: clicking "link existing file" shows file picker dropdown', async () => {
const res = buildReservation({ id: 5, type: 'flight' });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
expect(screen.getByText('invoice.pdf')).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-021: clicking file in picker links it and closes picker', async () => {
server.use(
http.post('/api/trips/1/files/99/link', () => HttpResponse.json({ success: true })),
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
);
const res = buildReservation({ id: 5, type: 'flight' });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
await userEvent.click(screen.getByText('invoice.pdf'));
await waitFor(() => {
expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument();
});
});
it('FE-PLANNER-TRANSMODAL-022: removing pending file removes it from list', async () => {
render(<TransportModal {...defaultProps} reservation={null} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const testFile = new File(['content'], 'draft.pdf', { type: 'application/pdf' });
fireEvent.change(fileInput, { target: { files: [testFile] } });
await waitFor(() => expect(screen.getByText('draft.pdf')).toBeInTheDocument());
const pendingFileRow = screen.getByText('draft.pdf').closest('div')!;
const removeBtn = pendingFileRow.querySelector('button')!;
await userEvent.click(removeBtn);
await waitFor(() => expect(screen.queryByText('draft.pdf')).not.toBeInTheDocument());
});
it('FE-PLANNER-TRANSMODAL-023: clicking attach file button triggers file input click', async () => {
render(<TransportModal {...defaultProps} />);
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {});
await userEvent.click(attachBtn);
expect(clickSpy).toHaveBeenCalled();
clickSpy.mockRestore();
});
it('FE-PLANNER-TRANSMODAL-024: unlinking a linked file removes it from attached list', async () => {
server.use(
http.post('/api/trips/1/files/42/link', () => HttpResponse.json({ success: true })),
http.get('/api/trips/1/files/42/links', () => HttpResponse.json({ links: [{ id: 1, reservation_id: 7 }] })),
http.delete('/api/trips/1/files/42/link/1', () => HttpResponse.json({ success: true })),
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
);
const res = buildReservation({ id: 7, type: 'car' });
const looseFile = buildTripFile({ id: 42, original_name: 'rental-agreement.pdf' });
render(<TransportModal {...defaultProps} reservation={res} files={[looseFile]} />);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
await waitFor(() => expect(screen.getByText('rental-agreement.pdf')).toBeInTheDocument());
await userEvent.click(screen.getByText('rental-agreement.pdf'));
await waitFor(() =>
expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument()
);
const fileRow = screen.getByText('rental-agreement.pdf').closest('div')!;
const unlinkBtn = fileRow.querySelector('button[type="button"]')!;
await userEvent.click(unlinkBtn);
await waitFor(() => {
expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument();
});
});
it('FE-PLANNER-TRANSMODAL-025: pending files flushed after saving new transport', async () => {
const savedReservation = buildReservation({ id: 99, type: 'flight' });
const onSave = vi.fn().mockResolvedValue(savedReservation);
const onFileUpload = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} onFileUpload={onFileUpload} reservation={null} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const testFile = new File(['content'], 'boarding.pdf', { type: 'application/pdf' });
fireEvent.change(fileInput, { target: { files: [testFile] } });
await waitFor(() => expect(screen.getByText('boarding.pdf')).toBeInTheDocument());
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH001');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onFileUpload).toHaveBeenCalled());
const [fd] = onFileUpload.mock.calls[0] as [FormData];
expect(fd.get('reservation_id')).toBe('99');
expect(fd.get('file')).toBeTruthy();
});
});
@@ -1,626 +0,0 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { Plane, Train, Car, Ship, Paperclip, FileText, X, ExternalLink, Link2 } from 'lucide-react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker'
import AirportSelect, { type Airport } from './AirportSelect'
import LocationSelect, { type LocationPoint } from './LocationSelect'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import { formatDate } from '../../utils/formatters'
import { openFile } from '../../utils/fileDownload'
import apiClient from '../../api/client'
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const
type TransportType = typeof TRANSPORT_TYPES[number]
interface EndpointPick {
airport?: Airport
location?: LocationPoint
}
function endpointFromAirport(a: Airport, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
return {
role, sequence,
name: a.city ? `${a.city} (${a.iata})` : a.name,
code: a.iata,
lat: a.lat, lng: a.lng,
timezone: a.tz,
local_date: date,
local_time: time,
}
}
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
return {
role, sequence,
name: l.name,
code: null,
lat: l.lat, lng: l.lng,
timezone: null,
local_date: date,
local_time: time,
}
}
function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null {
if (!e || !e.code) return null
return {
iata: e.code, icao: null,
name: e.name, city: e.name.replace(/\s*\([A-Z]{3}\)\s*$/, ''),
country: '',
lat: e.lat, lng: e.lng,
tz: e.timezone || '',
}
}
function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint | null {
if (!e) return null
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
}
const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
]
const defaultForm = {
title: '',
type: 'flight' as TransportType,
status: 'pending' as 'pending' | 'confirmed',
start_day_id: '' as string | number,
end_day_id: '' as string | number,
departure_time: '',
arrival_time: '',
confirmation_number: '',
notes: '',
price: '',
budget_category: '',
meta_airline: '',
meta_flight_number: '',
meta_train_number: '',
meta_platform: '',
meta_seat: '',
}
interface TransportModalProps {
isOpen: boolean
onClose: () => void
onSave: (data: Record<string, any>) => Promise<Reservation | undefined>
reservation: Reservation | null
days: Day[]
selectedDayId: number | null
files?: TripFile[]
onFileUpload?: (fd: FormData) => Promise<void>
onFileDelete?: (fileId: number) => Promise<void>
}
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete }: TransportModalProps) {
const { t, locale } = useTranslation()
const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
const loadFiles = useTripStore(s => s.loadFiles)
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [budgetItems])
const { id: tripId } = useParams<{ id: string }>()
const [form, setForm] = useState({ ...defaultForm })
const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({})
const [toPick, setToPick] = useState<EndpointPick>({})
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [showFilePicker, setShowFilePicker] = useState(false)
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!isOpen) return
if (reservation) {
const meta = typeof reservation.metadata === 'string'
? JSON.parse(reservation.metadata || '{}')
: (reservation.metadata || {})
const eps = reservation.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
const type = (TRANSPORT_TYPES as readonly string[]).includes(reservation.type)
? reservation.type as TransportType
: 'flight'
setForm({
title: reservation.title || '',
type,
status: reservation.status || 'pending',
start_day_id: reservation.day_id ?? '',
end_day_id: reservation.end_day_id ?? '',
departure_time: reservation.reservation_time?.split('T')[1]?.slice(0, 5) ?? '',
arrival_time: reservation.reservation_end_time?.split('T')[1]?.slice(0, 5) ?? '',
confirmation_number: reservation.confirmation_number || '',
notes: reservation.notes || '',
meta_airline: meta.airline || '',
meta_flight_number: meta.flight_number || '',
meta_train_number: meta.train_number || '',
meta_platform: meta.platform || '',
meta_seat: meta.seat || '',
price: meta.price || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
})
if (type === 'flight') {
setFromPick({ airport: airportFromEndpoint(from) || undefined })
setToPick({ airport: airportFromEndpoint(to) || undefined })
} else {
setFromPick({ location: locationFromEndpoint(from) || undefined })
setToPick({ location: locationFromEndpoint(to) || undefined })
}
} else {
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
setFromPick({})
setToPick({})
}
}, [isOpen, reservation, selectedDayId, budgetItems])
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.title.trim()) return
setIsSaving(true)
try {
const startDay = days.find(d => d.id === Number(form.start_day_id))
const endDay = days.find(d => d.id === Number(form.end_day_id))
const buildTime = (day: Day | undefined, time: string): string | null => {
if (!time) return null
return day?.date ? `${day.date}T${time}` : `T${time}`
}
const metadata: Record<string, string> = {}
if (form.type === 'flight') {
if (form.meta_airline) metadata.airline = form.meta_airline
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
if (fromPick.airport) {
metadata.departure_airport = fromPick.airport.iata
metadata.departure_timezone = fromPick.airport.tz
}
if (toPick.airport) {
metadata.arrival_airport = toPick.airport.iata
metadata.arrival_timezone = toPick.airport.tz
}
} else if (form.type === 'train') {
if (form.meta_train_number) metadata.train_number = form.meta_train_number
if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat
}
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const startDate = startDay?.date ?? null
const endDate = (endDay ?? startDay)?.date ?? null
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
if (form.type === 'flight') {
if (fromPick.airport) endpoints.push(endpointFromAirport(fromPick.airport, 'from', 0, startDate, form.departure_time || null))
if (toPick.airport) endpoints.push(endpointFromAirport(toPick.airport, 'to', 1, endDate, form.arrival_time || null))
} else {
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, form.arrival_time || null))
}
const payload = {
title: form.title,
type: form.type,
status: form.status,
day_id: form.start_day_id ? Number(form.start_day_id) : null,
end_day_id: form.end_day_id ? Number(form.end_day_id) : null,
reservation_time: buildTime(startDay, form.departure_time),
reservation_end_time: buildTime(endDay ?? startDay, form.arrival_time),
location: null,
confirmation_number: form.confirmation_number || null,
notes: form.notes || null,
metadata: Object.keys(metadata).length > 0 ? metadata : null,
endpoints,
needs_review: false,
}
if (isBudgetEnabled) {
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
const saved = await onSave(payload)
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', String(saved.id))
fd.append('description', form.title)
await onFileUpload(fd)
}
}
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
} finally {
setIsSaving(false)
}
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (reservation?.id) {
setUploadingFile(true)
try {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', String(reservation.id))
fd.append('description', reservation.title)
await onFileUpload!(fd)
toast.success(t('reservations.toast.fileUploaded'))
} catch {
toast.error(t('reservations.toast.uploadError'))
} finally {
setUploadingFile(false)
e.target.value = ''
}
} else {
setPendingFiles(prev => [...prev, file])
e.target.value = ''
}
}
const attachedFiles = reservation?.id
? files.filter(f =>
f.reservation_id === reservation.id ||
linkedFileIds.includes(f.id) ||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(reservation.id))
)
: []
const inputStyle = {
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
outline: 'none', boxSizing: 'border-box' as const, color: 'var(--text-primary)', background: 'var(--bg-input)',
}
const labelStyle = {
display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)',
marginBottom: 5, textTransform: 'uppercase' as const, letterSpacing: '0.03em',
}
const dayOptions = [
{ value: '', label: '—' },
...days.map(d => {
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
const dayBadge = d.title ? t('dayplan.dayN', { n: d.day_number }) : undefined
return {
value: d.id,
label: d.title || t('dayplan.dayN', { n: d.day_number }),
badge: dateBadge ?? dayBadge,
}
}),
]
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
size="2xl"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
}
>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Type selector */}
<div>
<label style={labelStyle}>{t('reservations.bookingType')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => (
<button key={value} type="button" onClick={() => set('type', value)} style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '5px 10px', borderRadius: 99, border: '1px solid',
fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
background: form.type === value ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: form.type === value ? 'var(--text-primary)' : 'var(--border-primary)',
color: form.type === value ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
<Icon size={11} /> {t(labelKey)}
</button>
))}
</div>
</div>
{/* Title */}
<div>
<label style={labelStyle}>{t('reservations.titleLabel')} *</label>
<input type="text" value={form.title} onChange={e => set('title', e.target.value)} required
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
</div>
{/* From / To endpoints */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.from')}</label>
{form.type === 'flight' ? (
<AirportSelect value={fromPick.airport || null} onChange={a => setFromPick({ airport: a || undefined })} />
) : (
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
)}
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.to')}</label>
{form.type === 'flight' ? (
<AirportSelect value={toPick.airport || null} onChange={a => setToPick({ airport: a || undefined })} />
) : (
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
)}
</div>
</div>
{/* Departure row */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>
{form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}
</label>
<CustomSelect
value={form.start_day_id}
onChange={value => set('start_day_id', value)}
placeholder={t('dayplan.dayN', { n: '?' })}
options={dayOptions}
size="sm"
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>
{form.type === 'flight' ? t('reservations.departureTime') : form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}
</label>
<CustomTimePicker value={form.departure_time} onChange={v => set('departure_time', v)} />
</div>
{form.type === 'flight' && fromPick.airport && (
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.meta.departureTimezone')}</label>
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
{fromPick.airport.tz}
</div>
</div>
)}
</div>
{/* Arrival row */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>
{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}
</label>
<CustomSelect
value={form.end_day_id}
onChange={value => set('end_day_id', value)}
placeholder={t('dayplan.dayN', { n: '?' })}
options={dayOptions}
size="sm"
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>
{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}
</label>
<CustomTimePicker value={form.arrival_time} onChange={v => set('arrival_time', v)} />
</div>
{form.type === 'flight' && toPick.airport && (
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.meta.arrivalTimezone')}</label>
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
{toPick.airport.tz}
</div>
</div>
)}
</div>
{/* Flight-specific fields */}
{form.type === 'flight' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.airline')}</label>
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
placeholder="Lufthansa" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.flightNumber')}</label>
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
placeholder="LH 123" style={inputStyle} />
</div>
</div>
)}
{/* Train-specific fields */}
{form.type === 'train' && (
<div className="grid grid-cols-3 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.trainNumber')}</label>
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
placeholder="ICE 123" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.platform')}</label>
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
placeholder="12" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.seat')}</label>
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
placeholder="42A" style={inputStyle} />
</div>
</div>
)}
{/* Booking Code + Status */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.confirmationCode')}</label>
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
</div>
</div>
{/* Notes */}
<div>
<label style={labelStyle}>{t('reservations.notes')}</label>
<textarea value={form.notes} onChange={e => set('notes', e.target.value)} rows={2}
placeholder={t('reservations.notesPlaceholder')}
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div>
{/* Files */}
<div>
<label style={labelStyle}>{t('files.title')}</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{attachedFiles.map(f => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
<button type="button" onClick={async () => {
if (f.reservation_id === reservation?.id) {
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
}
try {
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
} catch {}
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
if (tripId) loadFiles(tripId)
}} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
<X size={11} />
</button>
</div>
))}
{pendingFiles.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
<X size={11} />
</button>
</div>
))}
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
}}>
<Paperclip size={11} />
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
</button>}
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
<div style={{ position: 'relative' }}>
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
}}>
<Link2 size={11} /> {t('reservations.linkExisting')}
</button>
{showFilePicker && (
<div style={{
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, zIndex: 50,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220, maxHeight: 200, overflowY: 'auto',
}}>
{files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => (
<button key={f.id} type="button" onClick={async () => {
try {
await apiClient.post(`/trips/${tripId}/files/${f.id}/link`, { reservation_id: reservation.id })
setLinkedFileIds(prev => [...prev, f.id])
setShowFilePicker(false)
if (tripId) loadFiles(tripId)
} catch {}
}}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit',
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<FileText size={12} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* Price + Budget Category */}
{isBudgetEnabled && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.price')}</label>
<input type="text" inputMode="decimal" value={form.price}
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
placeholder="0.00"
style={inputStyle} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
<CustomSelect
value={form.budget_category}
onChange={v => set('budget_category', v)}
options={[
{ value: '', label: t('reservations.budgetCategoryAuto') },
...budgetCategories.map(c => ({ value: c, label: c })),
]}
placeholder={t('reservations.budgetCategoryAuto')}
size="sm"
/>
</div>
</div>
{form.price && parseFloat(form.price) > 0 && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
{t('reservations.budgetHint')}
</div>
)}
</>
)}
</form>
</Modal>
)
}
+8 -231
View File
@@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import { Info, Coffee, Heart, ExternalLink, Bug, Lightbulb, BookOpen, Tent, Compass, Plane, Crown, Infinity as InfinityIcon } from 'lucide-react' import { Info, Coffee, Heart, ExternalLink, Bug, Lightbulb, BookOpen } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import Section from './Section' import Section from './Section'
@@ -7,229 +7,8 @@ interface Props {
appVersion: string appVersion: string
} }
type SupporterTierId = 'no_return_ticket' | 'lost_luggage_vip' | 'business_class_dreamer' | 'budget_traveller' | 'hostel_bunkmate'
interface SupporterTier {
id: SupporterTierId
labelKey: string
price: string
gradient: string
glow: string
icon: typeof Tent
}
const SUPPORTER_TIERS: SupporterTier[] = [
{ id: 'no_return_ticket', labelKey: 'settings.about.supporter.tier.noReturnTicket', price: '∞', gradient: 'linear-gradient(135deg, #fbbf24, #ec4899 55%, #6366f1)', glow: 'rgba(236,72,153,0.45)', icon: InfinityIcon },
{ id: 'lost_luggage_vip', labelKey: 'settings.about.supporter.tier.lostLuggageVip', price: '$30', gradient: 'linear-gradient(135deg, #a855f7, #ec4899)', glow: 'rgba(168,85,247,0.35)', icon: Crown },
{ id: 'business_class_dreamer', labelKey: 'settings.about.supporter.tier.businessClassDreamer', price: '$15', gradient: 'linear-gradient(135deg, #6366f1, #0ea5e9)', glow: 'rgba(99,102,241,0.35)', icon: Plane },
{ id: 'budget_traveller', labelKey: 'settings.about.supporter.tier.budgetTraveller', price: '$10', gradient: 'linear-gradient(135deg, #14b8a6, #06b6d4)', glow: 'rgba(20,184,166,0.3)', icon: Compass },
{ id: 'hostel_bunkmate', labelKey: 'settings.about.supporter.tier.hostelBunkmate', price: '$5', gradient: 'linear-gradient(135deg, #64748b, #94a3b8)', glow: 'rgba(100,116,139,0.25)', icon: Tent },
]
interface Supporter {
username: string
tier: SupporterTierId
since: string
link?: string
}
const SUPPORTERS: Supporter[] = [
{ username: 'Someone', tier: 'hostel_bunkmate', since: '2026-04' },
]
function SupporterSection({ t, locale }: { t: (key: string, vars?: Record<string, string | number>) => string; locale: string }) {
if (SUPPORTERS.length === 0) return null
const formatSince = (yearMonth: string): string => {
const [y, m] = yearMonth.split('-').map(Number)
if (!y || !m) return yearMonth
try {
return new Date(y, m - 1, 1).toLocaleDateString(locale, { year: 'numeric', month: 'long' })
} catch { return yearMonth }
}
return (
<div className="supporter-section">
<style>{`
.supporter-section { margin-top: 20px; }
.supporter-card {
position: relative;
border-radius: 20px;
padding: 22px 22px 18px;
background: linear-gradient(180deg, rgba(99,102,241,0.06) 0%, rgba(236,72,153,0.04) 100%);
border: 1px solid rgba(99,102,241,0.18);
overflow: hidden;
}
.supporter-glow {
position: absolute; inset: -60px; z-index: 0; pointer-events: none;
background: radial-gradient(500px 240px at 15% -10%, rgba(99,102,241,0.18), transparent 60%), radial-gradient(400px 200px at 90% 110%, rgba(236,72,153,0.12), transparent 60%);
animation: supporterGlow 6s ease-in-out infinite;
}
.supporter-header {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin-bottom: 6px;
}
.supporter-badge {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px;
background: linear-gradient(90deg, #6366f1, #ec4899, #fbbf24);
background-size: 200% 100%;
animation: supporterShimmer 4s ease-in-out infinite;
color: #fff; font-weight: 700; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase;
box-shadow: 0 4px 16px rgba(236,72,153,0.25);
white-space: nowrap;
}
.supporter-title {
margin: 0; font-size: 16px; font-weight: 700;
color: var(--text-primary); letter-spacing: -0.01em;
}
.supporter-subtitle {
position: relative; z-index: 1;
margin: 0 0 16px; font-size: 12.5px;
color: var(--text-secondary); line-height: 1.55;
}
.supporter-tiers {
position: relative; z-index: 1;
display: flex; flex-direction: column; gap: 10px;
}
.supporter-tier {
display: flex; align-items: flex-start; gap: 12px;
padding: 10px 12px; border-radius: 14px;
background: var(--bg-card);
border: 1px solid var(--border-primary);
}
.supporter-tier-icon {
width: 38px; height: 38px; border-radius: 11px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
color: #fff;
}
.supporter-tier-body { flex: 1; min-width: 0; }
.supporter-tier-head {
display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
}
.supporter-tier-label {
font-size: 13.5px; font-weight: 700; color: var(--text-primary);
}
.supporter-tier-price {
font-size: 11px; font-weight: 600; color: var(--text-faint);
padding: 1px 7px; border-radius: 6px; background: var(--bg-tertiary);
}
.supporter-tier-chips {
display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px;
}
.supporter-tier-empty {
font-size: 11.5px; font-style: italic; color: var(--text-faint);
}
.supporter-chip {
display: inline-flex; align-items: center; gap: 7px;
padding: 4px 10px; border-radius: 999px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
text-decoration: none;
transition: border-color 0.15s, box-shadow 0.15s;
max-width: 100%;
}
.supporter-chip-name {
font-size: 12px; font-weight: 600; color: var(--text-primary);
white-space: nowrap;
}
.supporter-chip-since {
font-size: 10.5px; font-weight: 500; color: var(--text-faint);
white-space: nowrap;
}
.supporter-chip-since-short { display: none; }
@keyframes supporterShimmer {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
@keyframes supporterGlow {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.75; }
}
@media (max-width: 640px) {
.supporter-card { border-radius: 16px; padding: 16px 14px 14px; }
.supporter-glow { inset: -40px; }
.supporter-header { gap: 8px; }
.supporter-badge { font-size: 10px; padding: 3px 9px; letter-spacing: 0.03em; }
.supporter-title { font-size: 15px; flex-basis: 100%; }
.supporter-subtitle { font-size: 12px; margin-bottom: 14px; }
.supporter-tier { padding: 10px; gap: 10px; border-radius: 12px; }
.supporter-tier-icon { width: 34px; height: 34px; border-radius: 10px; }
.supporter-tier-label { font-size: 13px; }
.supporter-tier-chips { gap: 5px; margin-top: 7px; }
.supporter-chip { padding: 3px 9px; }
.supporter-chip-since { font-size: 10px; }
.supporter-chip-since-full { display: none; }
.supporter-chip-since-short { display: inline; }
}
`}</style>
<div className="supporter-card">
<div className="supporter-glow" />
<div className="supporter-header">
<span className="supporter-badge">{t('settings.about.supporters.badge')}</span>
<h3 className="supporter-title">{t('settings.about.supporters.title')}</h3>
</div>
<p className="supporter-subtitle">{t('settings.about.supporters.subtitle')}</p>
<div className="supporter-tiers">
{SUPPORTER_TIERS.map(tier => {
const members = SUPPORTERS.filter(s => s.tier === tier.id)
const empty = members.length === 0
const TierIcon = tier.icon
return (
<div key={tier.id} className="supporter-tier" style={{ opacity: empty ? 0.55 : 1 }}>
<div className="supporter-tier-icon" style={{ background: tier.gradient, boxShadow: `0 6px 18px ${tier.glow}` }}>
<TierIcon size={18} strokeWidth={2.2} />
</div>
<div className="supporter-tier-body">
<div className="supporter-tier-head">
<span className="supporter-tier-label">{t(tier.labelKey)}</span>
<span className="supporter-tier-price">{tier.price}</span>
</div>
<div className="supporter-tier-chips">
{empty && (
<span className="supporter-tier-empty">
{t('settings.about.supporters.tierEmpty')}
</span>
)}
{members.map(m => {
const chipContent = (
<>
<span className="supporter-chip-name">{m.username}</span>
<span className="supporter-chip-since supporter-chip-since-full">
· {t('settings.about.supporters.since', { date: formatSince(m.since) })}
</span>
<span className="supporter-chip-since supporter-chip-since-short">
· {formatSince(m.since)}
</span>
</>
)
return m.link ? (
<a key={m.username} href={m.link} target="_blank" rel="noopener noreferrer" className="supporter-chip"
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.boxShadow = `0 2px 8px ${tier.glow}` }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
{chipContent}
</a>
) : (
<div key={m.username} className="supporter-chip">{chipContent}</div>
)
})}
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
export default function AboutTab({ appVersion }: Props): React.ReactElement { export default function AboutTab({ appVersion }: Props): React.ReactElement {
const { t, locale } = useTranslation() const { t } = useTranslation()
return ( return (
<Section title={t('settings.about')} icon={Info}> <Section title={t('settings.about')} icon={Info}>
@@ -254,7 +33,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
href="https://ko-fi.com/mauriceboe" href="https://ko-fi.com/mauriceboe"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }} onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -272,7 +51,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
href="https://buymeacoffee.com/mauriceboe" href="https://buymeacoffee.com/mauriceboe"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }} onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -290,7 +69,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
href="https://discord.gg/NhZBDSd4qW" href="https://discord.gg/NhZBDSd4qW"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }} onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -311,7 +90,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml" href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }} onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -329,7 +108,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests" href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }} onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -347,7 +126,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
href="https://github.com/mauriceboe/TREK/wiki" href="https://github.com/mauriceboe/TREK/wiki"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }} onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -362,8 +141,6 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} /> <ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a> </a>
</div> </div>
<SupporterSection t={t} locale={locale} />
</Section> </Section>
) )
} }
@@ -42,7 +42,7 @@ describe('DisplaySettingsTab', () => {
it('FE-COMP-DISPLAY-005: shows Auto mode button', () => { it('FE-COMP-DISPLAY-005: shows Auto mode button', () => {
render(<DisplaySettingsTab />); render(<DisplaySettingsTab />);
expect(screen.getByRole('button', { name: /Auto/i })).toBeInTheDocument(); expect(screen.getByText('Auto')).toBeInTheDocument();
}); });
it('FE-COMP-DISPLAY-006: shows Language section', () => { it('FE-COMP-DISPLAY-006: shows Language section', () => {
@@ -95,16 +95,16 @@ describe('DisplaySettingsTab', () => {
const updateSetting = vi.fn().mockResolvedValue(undefined); const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'light' }), updateSetting }); seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'light' }), updateSetting });
render(<DisplaySettingsTab />); render(<DisplaySettingsTab />);
await user.click(screen.getByRole('button', { name: /Auto/i })); await user.click(screen.getByText('Auto'));
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'auto'); expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'auto');
}); });
it('FE-COMP-DISPLAY-014: active color mode button has border with var(--text-primary)', () => { it('FE-COMP-DISPLAY-014: active color mode button has border with var(--text-primary)', () => {
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }) }); seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }) });
render(<DisplaySettingsTab />); render(<DisplaySettingsTab />);
const darkBtn = screen.getByRole('button', { name: /^Dark$/i }); const darkBtn = screen.getByText('Dark').closest('button')!;
const lightBtn = screen.getByRole('button', { name: /^Light$/i }); const lightBtn = screen.getByText('Light').closest('button')!;
const autoBtn = screen.getByRole('button', { name: /Auto/i }); const autoBtn = screen.getByText('Auto').closest('button')!;
expect(darkBtn.style.border).toContain('var(--text-primary)'); expect(darkBtn.style.border).toContain('var(--text-primary)');
expect(lightBtn.style.border).toContain('var(--border-primary)'); expect(lightBtn.style.border).toContain('var(--border-primary)');
expect(autoBtn.style.border).toContain('var(--border-primary)'); expect(autoBtn.style.border).toContain('var(--border-primary)');
@@ -122,11 +122,8 @@ describe('DisplaySettingsTab', () => {
it('FE-COMP-DISPLAY-016: active language button is visually highlighted', () => { it('FE-COMP-DISPLAY-016: active language button is visually highlighted', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) }); seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) });
render(<DisplaySettingsTab />); render(<DisplaySettingsTab />);
// Multiple elements contain "English" (desktop grid button + mobile dropdown trigger). const englishBtn = screen.getByText('English').closest('button')!;
// The desktop grid button is the one with the active border style. expect(englishBtn.style.border).toContain('var(--text-primary)');
const englishMatches = screen.getAllByText('English').map(el => el.closest('button')!).filter(Boolean);
const activeBtn = englishMatches.find(btn => (btn.style.border || '').includes('var(--text-primary)'));
expect(activeBtn).toBeDefined();
}); });
it('FE-COMP-DISPLAY-017: shows Temperature section label', () => { it('FE-COMP-DISPLAY-017: shows Temperature section label', () => {
@@ -155,9 +152,7 @@ describe('DisplaySettingsTab', () => {
const updateSetting = vi.fn().mockResolvedValue(undefined); const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting }); seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting });
render(<DisplaySettingsTab />); render(<DisplaySettingsTab />);
// The label is split across a text node ('24h') and a responsive span (' (14:30)'). await user.click(screen.getByText('24h (14:30)'));
// Click the button that contains the 24h text instead of matching the full string.
await user.click(screen.getByRole('button', { name: /24h/ }));
expect(updateSetting).toHaveBeenCalledWith('time_format', '24h'); expect(updateSetting).toHaveBeenCalledWith('time_format', '24h');
}); });
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect } from 'react'
import { Palette, Sun, Moon, Monitor, ChevronDown, Check } from 'lucide-react' import { Palette, Sun, Moon, Monitor } from 'lucide-react'
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n' import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
@@ -10,17 +10,6 @@ export default function DisplaySettingsTab(): React.ReactElement {
const { t } = useTranslation() const { t } = useTranslation()
const toast = useToast() const toast = useToast()
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius') const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
const [langOpen, setLangOpen] = useState(false)
const langDropdownRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!langOpen) return
const handler = (e: MouseEvent) => {
if (langDropdownRef.current && !langDropdownRef.current.contains(e.target as Node)) setLangOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [langOpen])
useEffect(() => { useEffect(() => {
setTempUnit(settings.temperature_unit || 'celsius') setTempUnit(settings.temperature_unit || 'celsius')
@@ -57,13 +46,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
transition: 'all 0.15s', transition: 'all 0.15s',
}} }}
> >
<span className="hidden sm:inline-flex"><opt.icon size={16} /></span> <opt.icon size={16} />
{opt.value === 'auto' ? ( {opt.label}
<>
<span className="hidden sm:inline">{opt.label}</span>
<span className="sm:hidden">Auto</span>
</>
) : opt.label}
</button> </button>
) )
})} })}
@@ -73,8 +57,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
{/* Language */} {/* Language */}
<div> <div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label> <label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
{/* Desktop: Button grid */} <div className="flex flex-wrap gap-3">
<div className="hidden sm:flex flex-wrap gap-3">
{SUPPORTED_LANGUAGES.map(opt => ( {SUPPORTED_LANGUAGES.map(opt => (
<button <button
key={opt.value} key={opt.value}
@@ -96,60 +79,6 @@ export default function DisplaySettingsTab(): React.ReactElement {
</button> </button>
))} ))}
</div> </div>
{/* Mobile: Custom dropdown */}
<div ref={langDropdownRef} className="sm:hidden" style={{ position: 'relative' }}>
{(() => {
const current = SUPPORTED_LANGUAGES.find(o => o.value === settings.language) || SUPPORTED_LANGUAGES[0]
return (
<button
type="button"
onClick={() => setLangOpen(v => !v)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 14px', borderRadius: 10,
border: '2px solid var(--border-primary)',
background: 'var(--bg-card)', color: 'var(--text-primary)',
fontSize: 14, fontWeight: 500, fontFamily: 'inherit', cursor: 'pointer',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{current?.label}</span>
<ChevronDown size={14} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: langOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
</button>
)
})()}
{langOpen && (
<div style={{
position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, zIndex: 50,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 8px 24px rgba(0,0,0,0.15)', padding: 4, maxHeight: 280, overflowY: 'auto',
}}>
{SUPPORTED_LANGUAGES.map(opt => {
const active = settings.language === opt.value
return (
<button
key={opt.value}
type="button"
onClick={async () => {
setLangOpen(false)
try { await updateSetting('language', opt.value) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '9px 12px', borderRadius: 6, border: 'none', cursor: 'pointer',
background: active ? 'var(--bg-hover)' : 'transparent',
fontFamily: 'inherit', fontSize: 14, color: 'var(--text-primary)',
textAlign: 'left', fontWeight: active ? 600 : 500,
}}
>
<span style={{ flex: 1 }}>{opt.label}</span>
{active && <Check size={14} strokeWidth={2.5} color="var(--accent)" />}
</button>
)
})}
</div>
)}
</div>
</div> </div>
{/* Temperature */} {/* Temperature */}
@@ -188,8 +117,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label> <label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label>
<div className="flex gap-3"> <div className="flex gap-3">
{[ {[
{ value: '24h', short: '24h', example: '14:30' }, { value: '24h', label: '24h (14:30)' },
{ value: '12h', short: '12h', example: '2:30 PM' }, { value: '12h', label: '12h (2:30 PM)' },
].map(opt => ( ].map(opt => (
<button <button
key={opt.value} key={opt.value}
@@ -207,8 +136,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
transition: 'all 0.15s', transition: 'all 0.15s',
}} }}
> >
{opt.short} {opt.label}
<span className="hidden sm:inline">{` (${opt.example})`}</span>
</button> </button>
))} ))}
</div> </div>
@@ -123,12 +123,12 @@ describe('MapSettingsTab', () => {
}); });
render(<MapSettingsTab />); render(<MapSettingsTab />);
await user.click(screen.getByText('Save Map')); await user.click(screen.getByText('Save Map'));
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ expect(updateSettings).toHaveBeenCalledWith({
map_tile_url: '', map_tile_url: '',
default_lat: 48.8566, default_lat: 48.8566,
default_lng: 2.3522, default_lng: 2.3522,
default_zoom: 10, default_zoom: 10,
})); });
}); });
it('FE-COMP-MAP-013: Save Map button shows spinner while saving', async () => { it('FE-COMP-MAP-013: Save Map button shows spinner while saving', async () => {
+40 -312
View File
@@ -1,13 +1,11 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { Map, Save, Layers, Box, ChevronDown, Check } from 'lucide-react' import { Map, Save } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView' import { MapView } from '../Map/MapView'
import MapboxPreview from './MapboxPreview'
import Section from './Section' import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import type { Place } from '../../types' import type { Place } from '../../types'
interface MapPreset { 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' }, { 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 { export default function MapSettingsTab(): React.ReactElement {
const { settings, updateSettings } = useSettingsStore() const { settings, updateSettings } = useSettingsStore()
const { t } = useTranslation() const { t } = useTranslation()
const toast = useToast() const toast = useToast()
const [saving, setSaving] = useState(false) 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 [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 [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
const [defaultLng, setDefaultLng] = useState<number | string>(settings.default_lng || 2.3522) const [defaultLng, setDefaultLng] = useState<number | string>(settings.default_lng || 2.3522)
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10) const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
useEffect(() => { useEffect(() => {
setProvider((settings.map_provider as Provider) || 'leaflet')
setMapTileUrl(settings.map_tile_url || '') 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) setDefaultLat(settings.default_lat || 48.8566)
setDefaultLng(settings.default_lng || 2.3522) setDefaultLng(settings.default_lng || 2.3522)
setDefaultZoom(settings.default_zoom || 10) setDefaultZoom(settings.default_zoom || 10)
@@ -188,12 +67,7 @@ export default function MapSettingsTab(): React.ReactElement {
setSaving(true) setSaving(true)
try { try {
await updateSettings({ await updateSettings({
map_provider: provider,
map_tile_url: mapTileUrl, 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_lat: parseFloat(String(defaultLat)),
default_lng: parseFloat(String(defaultLng)), default_lng: parseFloat(String(defaultLng)),
default_zoom: parseInt(String(defaultZoom)), default_zoom: parseInt(String(defaultZoom)),
@@ -206,159 +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 ( return (
<Section title={t('settings.map')} icon={Map}> <Section title={t('settings.map')} icon={Map}>
{/* Provider picker — big cards so the choice is obvious */}
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('settings.mapProvider')}</label> <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
<div className="grid grid-cols-2 gap-2"> <CustomSelect
<button value={mapTileUrl}
type="button" onChange={(value: string) => { if (value) setMapTileUrl(value) }}
onClick={() => setProvider('leaflet')} placeholder={t('settings.mapTemplatePlaceholder.select')}
className={`flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${ options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
provider === 'leaflet' size="sm"
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200' style={{ marginBottom: 8 }}
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700' />
}`} <input
> type="text"
<Layers size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" /> value={mapTileUrl}
<div> onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
<div className="text-sm font-medium text-slate-900 dark:text-white">Leaflet</div> placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapLeafletSubtitle')}</div> 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> />
</button> <p className="text-xs text-slate-400 mt-1">{t('settings.mapDefaultHint')}</p>
<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'
}`}
>
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
<div className="min-w-0">
<div className="text-sm font-medium text-slate-900 dark:text-white">
<span className="sm:hidden">Mapbox</span>
<span className="hidden sm:inline">Mapbox GL</span>
</div>
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div>
</div>
{/* Experimental badge only on ≥sm; on mobile there's no room next to the title. */}
<span className="hidden sm:inline-block 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>
</button>
</div>
<p className="text-xs text-slate-400 mt-2">
{t('settings.mapProviderHint')}
</p>
</div> </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 className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.latitude')}</label> <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.latitude')}</label>
@@ -366,7 +109,7 @@ export default function MapSettingsTab(): React.ReactElement {
type="number" type="number"
step="any" step="any"
value={defaultLat} 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" 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> </div>
@@ -376,7 +119,7 @@ export default function MapSettingsTab(): React.ReactElement {
type="number" type="number"
step="any" step="any"
value={defaultLng} 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" 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> </div>
@@ -384,40 +127,25 @@ export default function MapSettingsTab(): React.ReactElement {
<div> <div>
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}> <div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
{provider === 'mapbox-gl' ? ( {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<MapboxPreview {React.createElement(MapView as any, {
token={mapboxToken} places: mapPlaces,
style={mapboxStyle} dayPlaces: [],
lat={parseFloat(String(defaultLat)) || 48.8566} route: null,
lng={parseFloat(String(defaultLng)) || 2.3522} routeSegments: null,
// Zoom in close so the style's character (3D buildings, selectedPlaceId: null,
// satellite texture, label density) is immediately visible. onMarkerClick: null,
zoom={Math.max(parseInt(String(defaultZoom)) || 10, 16)} onMapClick: handleMapClick,
enable3d={mapbox3d && supports3d} onMapContextMenu: null,
quality={mapboxQuality} center: [settings.default_lat, settings.default_lng],
onClick={(ll) => { setDefaultLat(ll.lat); setDefaultLng(ll.lng) }} zoom: defaultZoom,
/> tileUrl: mapTileUrl,
) : ( fitKey: null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any dayOrderMap: [],
React.createElement(MapView as any, { leftWidth: 0,
places: mapPlaces, rightWidth: 0,
dayPlaces: [], hasInspector: false,
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>
</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' }} />
}
@@ -25,7 +25,6 @@ const EVENT_LABEL_KEYS: Record<string, string> = {
trip_invite: 'settings.notifyTripInvite', trip_invite: 'settings.notifyTripInvite',
booking_change: 'settings.notifyBookingChange', booking_change: 'settings.notifyBookingChange',
trip_reminder: 'settings.notifyTripReminder', trip_reminder: 'settings.notifyTripReminder',
todo_due: 'settings.notifyTodoDue',
vacay_invite: 'settings.notifyVacayInvite', vacay_invite: 'settings.notifyVacayInvite',
photos_shared: 'settings.notifyPhotosShared', photos_shared: 'settings.notifyPhotosShared',
collab_message: 'settings.notifyCollabMessage', collab_message: 'settings.notifyCollabMessage',
@@ -5,7 +5,6 @@ import { useToast } from '../../components/shared/Toast'
import apiClient from '../../api/client' import apiClient from '../../api/client'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import Section from './Section' import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
interface ProviderField { interface ProviderField {
key: string key: string
@@ -223,13 +222,15 @@ export default function PhotoProvidersSection(): React.ReactElement {
{fields.map(field => ( {fields.map(field => (
<div key={`${provider.id}-${field.key}`}> <div key={`${provider.id}-${field.key}`}>
{field.input_type === 'checkbox' ? ( {field.input_type === 'checkbox' ? (
<div className="flex items-center gap-3"> <label className="flex items-center gap-2 cursor-pointer select-none">
<ToggleSwitch <input
on={values[field.key] === 'true'} type="checkbox"
onToggle={() => handleProviderFieldChange(provider.id, field.key, values[field.key] === 'true' ? 'false' : 'true')} checked={values[field.key] === 'true'}
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.checked ? 'true' : 'false')}
className="w-4 h-4 rounded border-slate-300 accent-slate-900"
/> />
<span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span> <span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span>
</div> </label>
) : ( ) : (
<> <>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label> <label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
@@ -247,9 +248,7 @@ export default function PhotoProvidersSection(): React.ReactElement {
)} )}
</div> </div>
))} ))}
{/* Wraps on mobile so the connection badge drops to its own row <div className="flex items-center gap-3">
instead of clipping off the side of the card. */}
<div className="flex flex-wrap items-center gap-3">
<button <button
onClick={() => handleSaveProvider(provider)} onClick={() => handleSaveProvider(provider)}
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)} disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
@@ -267,17 +266,15 @@ export default function PhotoProvidersSection(): React.ReactElement {
{testing {testing
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
: <Camera className="w-4 h-4" />} : <Camera className="w-4 h-4" />}
<span className="sm:hidden">{t('memories.testShort')}</span> {t('memories.testConnection')}
<span className="hidden sm:inline">{t('memories.testConnection')}</span>
</button> </button>
{/* On mobile the badge sits on its own row thanks to flex-wrap, so force a line break via basis-full. */}
{connected ? ( {connected ? (
<span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1"> <span className="text-xs font-medium text-green-600 flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full" /> <span className="w-2 h-2 bg-green-500 rounded-full" />
{t('memories.connected')} {t('memories.connected')}
</span> </span>
) : ( ) : (
<span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1"> <span className="text-xs font-medium text-slate-400 flex items-center gap-1">
<span className="w-2 h-2 bg-slate-300 rounded-full" /> <span className="w-2 h-2 bg-slate-300 rounded-full" />
{t('memories.disconnected')} {t('memories.disconnected')}
</span> </span>
@@ -2,10 +2,9 @@ import React from 'react'
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) { export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
return ( return (
<button type="button" onClick={onToggle} <button onClick={onToggle}
style={{ style={{
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0, position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)', background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
transition: 'background 0.2s', transition: 'background 0.2s',
}}> }}>
@@ -37,10 +37,9 @@ describe('TodoListPanel', () => {
expect(screen.getByText('Buy tickets')).toBeInTheDocument(); expect(screen.getByText('Buy tickets')).toBeInTheDocument();
}); });
it('FE-COMP-TODO-002: raising addItemSignal opens the new task form', async () => { it('FE-COMP-TODO-002: shows Add new task button', () => {
const { rerender } = render(<TodoListPanel tripId={1} items={[]} addItemSignal={0} />); render(<TodoListPanel tripId={1} items={[]} />);
rerender(<TodoListPanel tripId={1} items={[]} addItemSignal={1} />); expect(screen.getByText('Add new task...')).toBeInTheDocument();
await screen.findByText('Create task');
}); });
it('FE-COMP-TODO-003: sidebar filter buttons are rendered', () => { it('FE-COMP-TODO-003: sidebar filter buttons are rendered', () => {
@@ -120,9 +119,11 @@ describe('TodoListPanel', () => {
expect(screen.getByText(/1 \/ 2 completed/i)).toBeInTheDocument(); expect(screen.getByText(/1 \/ 2 completed/i)).toBeInTheDocument();
}); });
it('FE-COMP-TODO-011: raising addItemSignal opens detail form with Create task button', async () => { it('FE-COMP-TODO-011: clicking Add new task opens detail form', async () => {
const { rerender } = render(<TodoListPanel tripId={1} items={[]} addItemSignal={0} />); const user = userEvent.setup();
rerender(<TodoListPanel tripId={1} items={[]} addItemSignal={1} />); render(<TodoListPanel tripId={1} items={[]} />);
await user.click(screen.getByText('Add new task...'));
// The detail pane shows "Create task" button
await screen.findByText('Create task'); await screen.findByText('Create task');
}); });
@@ -397,12 +398,15 @@ describe('TodoListPanel', () => {
return HttpResponse.json({ item: buildTodoItem({ id: 99, name: 'Brand New Task' }) }); return HttpResponse.json({ item: buildTodoItem({ id: 99, name: 'Brand New Task' }) });
}), }),
); );
const { rerender } = render(<TodoListPanel tripId={1} items={[]} addItemSignal={0} />); render(<TodoListPanel tripId={1} items={[]} />);
// Raising the signal opens the new task pane (simulates the toolbar button click) // Open the new task pane
rerender(<TodoListPanel tripId={1} items={[]} addItemSignal={1} />); await user.click(screen.getByText('Add new task...'));
// Wait for "Create task" button to appear
await screen.findByText('Create task'); await screen.findByText('Create task');
// Type a task name in the autoFocus input (Task name placeholder)
const nameInput = screen.getByPlaceholderText('Task name'); const nameInput = screen.getByPlaceholderText('Task name');
await user.type(nameInput, 'Brand New Task'); await user.type(nameInput, 'Brand New Task');
// Click the Create task button
await user.click(screen.getByText('Create task')); await user.click(screen.getByText('Create task'));
await waitFor(() => expect(postCalled).toBe(true)); await waitFor(() => expect(postCalled).toBe(true));
}); });
+53 -87
View File
@@ -1,5 +1,4 @@
import { useState, useMemo, useEffect, useRef } from 'react' import { useState, useMemo, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
@@ -38,7 +37,7 @@ type FilterType = 'all' | 'my' | 'overdue' | 'done' | string
interface Member { id: number; username: string; avatar: string | null } interface Member { id: number; username: string; avatar: string | null }
export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tripId: number; items: TodoItem[]; addItemSignal?: number }) { export default function TodoListPanel({ tripId, items }: { tripId: number; items: TodoItem[] }) {
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore() const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore()
const canEdit = useCanDo('packing_edit') const canEdit = useCanDo('packing_edit')
const toast = useToast() const toast = useToast()
@@ -56,15 +55,6 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
const [filter, setFilter] = useState<FilterType>('all') const [filter, setFilter] = useState<FilterType>('all')
const [selectedId, setSelectedId] = useState<number | null>(null) const [selectedId, setSelectedId] = useState<number | null>(null)
const [isAddingNew, setIsAddingNew] = useState(false) const [isAddingNew, setIsAddingNew] = useState(false)
const lastHandledAddSignal = useRef(addItemSignal)
useEffect(() => {
if (addItemSignal !== lastHandledAddSignal.current && addItemSignal > 0) {
setSelectedId(null)
setIsAddingNew(true)
}
lastHandledAddSignal.current = addItemSignal
}, [addItemSignal])
const [sortByPrio, setSortByPrio] = useState(false) const [sortByPrio, setSortByPrio] = useState(false)
const [addingCategory, setAddingCategory] = useState(false) const [addingCategory, setAddingCategory] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('') const [newCategoryName, setNewCategoryName] = useState('')
@@ -170,12 +160,12 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
{/* ── Left Sidebar ── */} {/* ── Left Sidebar ── */}
<div style={{ <div style={{
width: isMobile ? 52 : 220, flexShrink: 0, borderRight: '1px solid var(--border-faint)', width: isMobile ? 52 : 220, flexShrink: 0, borderRight: '1px solid var(--border-faint)',
padding: isMobile ? '12px 6px' : '16px 12px 16px 0', display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto', padding: isMobile ? '12px 6px' : '16px 10px', display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto',
transition: 'width 0.2s', transition: 'width 0.2s',
}}> }}>
{/* Progress Card */} {/* Progress Card */}
{!isMobile && <div style={{ {!isMobile && <div style={{
margin: '0 0 12px', padding: '14px 14px 12px', borderRadius: 14, margin: '0 6px 12px', padding: '14px 14px 12px', borderRadius: 14,
background: 'var(--bg-hover)', background: 'var(--bg-hover)',
border: '1px solid var(--border-primary)', border: '1px solid var(--border-primary)',
boxShadow: '0 1px 2px rgba(0,0,0,0.02)', boxShadow: '0 1px 2px rgba(0,0,0,0.02)',
@@ -202,12 +192,9 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
<SidebarItem id="overdue" icon={AlertCircle} label={t('todo.filter.overdue')} count={overdueCount} /> <SidebarItem id="overdue" icon={AlertCircle} label={t('todo.filter.overdue')} count={overdueCount} />
<SidebarItem id="done" icon={CheckCheck} label={t('todo.filter.done')} count={doneCount} /> <SidebarItem id="done" icon={CheckCheck} label={t('todo.filter.done')} count={doneCount} />
{/* Sort by */} {/* Sort by priority */}
{!isMobile && <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', padding: '16px 12px 4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t('todo.sidebar.sortBy')}
</div>}
<button onClick={() => setSortByPrio(v => !v)} <button onClick={() => setSortByPrio(v => !v)}
title={isMobile ? t('todo.priority') : undefined} title={isMobile ? t('todo.sortByPrio') : undefined}
style={{ style={{
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start', display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
gap: isMobile ? 0 : 8, width: '100%', padding: isMobile ? '8px 0' : '7px 12px', gap: isMobile ? 0 : 8, width: '100%', padding: isMobile ? '8px 0' : '7px 12px',
@@ -219,7 +206,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
onMouseEnter={e => { if (!sortByPrio) e.currentTarget.style.background = 'var(--bg-hover)' }} onMouseEnter={e => { if (!sortByPrio) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!sortByPrio) e.currentTarget.style.background = 'transparent' }}> onMouseLeave={e => { if (!sortByPrio) e.currentTarget.style.background = 'transparent' }}>
<Flag size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} /> <Flag size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} />
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.priority')}</span>} {!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.sortByPrio')}</span>}
</button> </button>
{/* Categories */} {/* Categories */}
@@ -264,6 +251,27 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
</div> </div>
</div> </div>
{/* Add task */}
{canEdit && (
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border-faint)' }}>
<button
onClick={() => { setSelectedId(null); setIsAddingNew(true) }}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
width: '100%', padding: '9px 16px', borderRadius: 8,
background: isAddingNew ? 'var(--text-primary)' : 'var(--bg-hover)',
color: isAddingNew ? 'var(--bg-primary)' : 'var(--text-primary)',
border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 13, fontWeight: 600, transition: 'all 0.15s',
}}
onMouseEnter={e => { if (!isAddingNew) { e.currentTarget.style.background = 'var(--text-primary)'; e.currentTarget.style.color = 'var(--bg-primary)'; e.currentTarget.style.borderColor = 'var(--text-primary)' } }}
onMouseLeave={e => { if (!isAddingNew) { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-primary)'; e.currentTarget.style.borderColor = 'var(--border-primary)' } }}>
<Plus size={14} />
{t('todo.addItem')}
</button>
</div>
)}
{/* Task list */} {/* Task list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}> <div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
{filtered.length === 0 ? null : ( {filtered.length === 0 ? null : (
@@ -399,27 +407,18 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
</div> </div>
</div> </div>
)} )}
{isAddingNew && !selectedItem && !isMobile && ReactDOM.createPortal( {isAddingNew && !selectedItem && !isMobile && (
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }} <NewTaskPane
className="modal-backdrop" tripId={tripId}
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(15,23,42,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'flex-start', paddingTop: 'calc(var(--nav-h) + 60px)', paddingBottom: 40 }}> categories={categories}
<div style={{ width: 'min(520px, 92vw)', maxHeight: 'calc(100vh - var(--nav-h) - 120px)', overflow: 'auto', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.25)' }} members={members}
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px' } } }}> defaultCategory={typeof filter === 'string' && categories.includes(filter) ? filter : null}
<NewTaskPane onCreated={(id) => { setIsAddingNew(false); setSelectedId(id) }}
tripId={tripId} onClose={() => setIsAddingNew(false)}
categories={categories} />
members={members}
defaultCategory={typeof filter === 'string' && categories.includes(filter) ? filter : null}
onCreated={(id) => { setIsAddingNew(false); setSelectedId(id) }}
onClose={() => setIsAddingNew(false)}
/>
</div>
</div>,
document.body
)} )}
{isAddingNew && !selectedItem && isMobile && ReactDOM.createPortal( {isAddingNew && !selectedItem && isMobile && (
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }} <div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
className="modal-backdrop"
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}> style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }} <div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}> ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
@@ -432,8 +431,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
onClose={() => setIsAddingNew(false)} onClose={() => setIsAddingNew(false)}
/> />
</div> </div>
</div>, </div>
document.body
)} )}
</div> </div>
) )
@@ -649,7 +647,6 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
const [desc, setDesc] = useState('') const [desc, setDesc] = useState('')
const [dueDate, setDueDate] = useState('') const [dueDate, setDueDate] = useState('')
const [category, setCategory] = useState(defaultCategory || '') const [category, setCategory] = useState(defaultCategory || '')
const [addingCategory, setAddingCategoryInline] = useState(false)
const [assignedUserId, setAssignedUserId] = useState<number | null>(null) const [assignedUserId, setAssignedUserId] = useState<number | null>(null)
const [priority, setPriority] = useState(0) const [priority, setPriority] = useState(0)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@@ -660,10 +657,9 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
if (!name.trim()) return if (!name.trim()) return
setSaving(true) setSaving(true)
try { try {
const trimmedCategory = category.trim()
const item = await addTodoItem(tripId, { const item = await addTodoItem(tripId, {
name: name.trim(), description: desc || null, priority, name: name.trim(), description: desc || null, priority,
due_date: dueDate || null, category: trimmedCategory || null, due_date: dueDate || null, category: category || null,
assigned_user_id: assignedUserId, assigned_user_id: assignedUserId,
} as any) } as any)
if (item?.id) onCreated(item.id) if (item?.id) onCreated(item.id)
@@ -700,49 +696,19 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
<div> <div>
<label style={labelStyle}>{t('todo.detail.category')}</label> <label style={labelStyle}>{t('todo.detail.category')}</label>
{addingCategory ? ( <CustomSelect
<div style={{ display: 'flex', gap: 4 }}> value={category}
<input onChange={v => setCategory(v)}
autoFocus options={[
value={category} { value: '', label: t('todo.noCategory') },
onChange={e => setCategory(e.target.value)} ...categories.map(c => ({
onKeyDown={e => { if (e.key === 'Enter') setAddingCategoryInline(false); if (e.key === 'Escape') { setCategory(''); setAddingCategoryInline(false) } }} value: c, label: c,
placeholder={t('todo.newCategory')} icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
style={{ flex: 1, fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} })),
/> ]}
<button type="button" onClick={() => setAddingCategoryInline(false)} placeholder={t('todo.noCategory')}
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-primary)' }}> size="sm"
<Check size={14} /> />
</button>
</div>
) : (
<div style={{ display: 'flex', gap: 4 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<CustomSelect
value={category}
onChange={v => setCategory(v)}
options={[
{ value: '', label: t('todo.noCategory') },
...categories.map(c => ({
value: c, label: c,
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
})),
...(category && !categories.includes(category) ? [{
value: category, label: `${category} (${t('todo.newCategoryLabel') || 'new'})`,
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#9ca3af', display: 'inline-block' }} />,
}] : []),
]}
placeholder={t('todo.noCategory')}
size="sm"
/>
</div>
<button type="button" onClick={() => { setCategory(''); setAddingCategoryInline(true) }}
title={t('todo.newCategory')}
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>
<Plus size={14} />
</button>
</div>
)}
</div> </div>
<div> <div>
@@ -94,7 +94,7 @@ export default function VacayCalendar() {
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}> <div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
<button <button
onClick={() => setCompanyMode(false)} onClick={() => setCompanyMode(false)}
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-[background-color,color,border-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]" className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-all"
style={{ style={{
background: !companyMode ? 'var(--text-primary)' : 'transparent', background: !companyMode ? 'var(--text-primary)' : 'transparent',
color: !companyMode ? 'var(--bg-card)' : 'var(--text-muted)', color: !companyMode ? 'var(--bg-card)' : 'var(--text-muted)',
@@ -107,7 +107,7 @@ export default function VacayCalendar() {
{companyHolidaysEnabled && ( {companyHolidaysEnabled && (
<button <button
onClick={() => setCompanyMode(true)} onClick={() => setCompanyMode(true)}
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-[background-color,color,border-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]" className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-all"
style={{ style={{
background: companyMode ? '#d97706' : 'transparent', background: companyMode ? '#d97706' : 'transparent',
color: companyMode ? '#fff' : 'var(--text-muted)', color: companyMode ? '#fff' : 'var(--text-muted)',
+5 -5
View File
@@ -121,9 +121,9 @@ export default function VacayPersons() {
{/* Invite Modal — Portal to body to avoid z-index issues */} {/* Invite Modal — Portal to body to avoid z-index issues */}
{showInvite && ReactDOM.createPortal( {showInvite && ReactDOM.createPortal(
<div className="fixed inset-0 flex items-center justify-center px-4 trek-backdrop-enter" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }} <div className="fixed inset-0 flex items-center justify-center px-4" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
onClick={() => setShowInvite(false)}> onClick={() => setShowInvite(false)}>
<div className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm" style={{ background: 'var(--bg-card)' }} <div className="rounded-2xl shadow-2xl w-full max-w-sm" style={{ background: 'var(--bg-card)', animation: 'modalIn 0.2s ease-out' }}
onClick={e => e.stopPropagation()}> onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}> <div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.inviteUser')}</h2> <h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.inviteUser')}</h2>
@@ -164,9 +164,9 @@ export default function VacayPersons() {
{/* Color Picker Modal — Portal to body */} {/* Color Picker Modal — Portal to body */}
{showColorPicker && ReactDOM.createPortal( {showColorPicker && ReactDOM.createPortal(
<div className="fixed inset-0 flex items-center justify-center px-4 trek-backdrop-enter" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }} <div className="fixed inset-0 flex items-center justify-center px-4" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
onClick={() => { setShowColorPicker(false); setColorEditUserId(null) }}> onClick={() => { setShowColorPicker(false); setColorEditUserId(null) }}>
<div className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-xs" style={{ background: 'var(--bg-card)' }} <div className="rounded-2xl shadow-2xl w-full max-w-xs" style={{ background: 'var(--bg-card)', animation: 'modalIn 0.2s ease-out' }}
onClick={e => e.stopPropagation()}> onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}> <div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.changeColor')}</h2> <h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.changeColor')}</h2>
@@ -178,7 +178,7 @@ export default function VacayPersons() {
<div className="flex flex-wrap gap-2 justify-center"> <div className="flex flex-wrap gap-2 justify-center">
{PRESET_COLORS.map(c => ( {PRESET_COLORS.map(c => (
<button key={c} onClick={() => handleColorChange(c)} <button key={c} onClick={() => handleColorChange(c)}
className={`w-8 h-8 rounded-full transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${editingUserColor === c ? 'ring-2 ring-offset-2 scale-110' : 'hover:scale-110'}`} className={`w-8 h-8 rounded-full transition-all ${editingUserColor === c ? 'ring-2 ring-offset-2 scale-110' : 'hover:scale-110'}`}
style={{ backgroundColor: c }} /> style={{ backgroundColor: c }} />
))} ))}
</div> </div>
+1 -4
View File
@@ -87,10 +87,7 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP
<span className="text-[10px] tabular-nums" style={{ color: 'var(--text-faint)' }}>{s.used}/{s.total_available}</span> <span className="text-[10px] tabular-nums" style={{ color: 'var(--text-faint)' }}>{s.used}/{s.total_available}</span>
</div> </div>
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--bg-secondary)' }}> <div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--bg-secondary)' }}>
<div <div className="h-full rounded-full transition-all duration-500" style={{ width: `${pct}%`, backgroundColor: s.person_color }} />
className="trek-bar-fill h-full rounded-full transition-[width] duration-500 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ width: `${pct}%`, backgroundColor: s.person_color }}
/>
</div> </div>
<div className="grid grid-cols-3 gap-1.5"> <div className="grid grid-cols-3 gap-1.5">
{/* Days — editable */} {/* Days — editable */}
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind } from 'lucide-react' import { Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind } from 'lucide-react'
import { fetchWeather } from '../../services/weatherQueue' import { weatherApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
const WEATHER_ICON_MAP = { const WEATHER_ICON_MAP = {
@@ -61,7 +61,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false }: Weath
// Climate data: use from cache but re-fetch in background to upgrade to forecast // Climate data: use from cache but re-fetch in background to upgrade to forecast
else if (cached.type === 'climate') { else if (cached.type === 'climate') {
setWeather(cached) setWeather(cached)
fetchWeather(lat, lng, date) weatherApi.get(lat, lng, date)
.then(data => { .then(data => {
if (!data.error && data.temp !== undefined && data.type === 'forecast') { if (!data.error && data.temp !== undefined && data.type === 'forecast') {
setWeatherCache(cacheKey, data) setWeatherCache(cacheKey, data)
@@ -77,7 +77,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false }: Weath
return return
} }
setLoading(true) setLoading(true)
fetchWeather(lat, lng, date) weatherApi.get(lat, lng, date)
.then(data => { .then(data => {
if (data.error || data.temp === undefined) { if (data.error || data.temp === undefined) {
setFailed(true) setFailed(true)
+13 -4
View File
@@ -40,13 +40,16 @@ export default function ConfirmDialog({
return ( return (
<div <div
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter" className="fixed inset-0 z-[60] flex items-center justify-center px-4"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }} style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
onClick={onClose} onClick={onClose}
> >
<div <div
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm p-6" className="rounded-2xl shadow-2xl w-full max-w-sm p-6"
style={{ background: 'var(--bg-card)' }} style={{
animation: 'modalIn 0.2s ease-out forwards',
background: 'var(--bg-card)',
}}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
@@ -87,6 +90,12 @@ export default function ConfirmDialog({
</div> </div>
</div> </div>
<style>{`
@keyframes modalIn {
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
`}</style>
</div> </div>
) )
} }
+3 -2
View File
@@ -65,7 +65,7 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
if (!menu) return null if (!menu) return null
return ReactDOM.createPortal( return ReactDOM.createPortal(
<div ref={ref} className="trek-popover-enter" style={{ <div ref={ref} style={{
position: 'fixed', left: menu.x, top: menu.y, zIndex: 999999, position: 'fixed', left: menu.x, top: menu.y, zIndex: 999999,
background: 'var(--bg-card)', borderRadius: 10, padding: '4px', background: 'var(--bg-card)', borderRadius: 10, padding: '4px',
border: '1px solid var(--border-primary)', border: '1px solid var(--border-primary)',
@@ -73,7 +73,7 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
minWidth: 160, minWidth: 160,
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
transformOrigin: 'top left', animation: 'ctxIn 0.1s ease-out',
}}> }}>
{menu.items.filter(Boolean).map((item, i) => { {menu.items.filter(Boolean).map((item, i) => {
if (item.divider) return <div key={i} style={{ height: 1, background: 'var(--border-faint)', margin: '3px 6px' }} /> if (item.divider) return <div key={i} style={{ height: 1, background: 'var(--border-faint)', margin: '3px 6px' }} />
@@ -95,6 +95,7 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
</button> </button>
) )
})} })}
<style>{`@keyframes ctxIn { from { opacity: 0; transform: scale(0.95) } to { opacity: 1; transform: scale(1) } }`}</style>
</div>, </div>,
document.body document.body
) )
@@ -1,65 +0,0 @@
import React, { useCallback, useState } from 'react'
import { Copy, Check } from 'lucide-react'
interface CopyButtonProps {
value: string
size?: number
title?: string
className?: string
onCopy?: () => void
}
// Button that morphs between copy icon and check icon for 1.5s after click.
export function CopyButton({ value, size = 14, title, className, onCopy }: CopyButtonProps): React.ReactElement {
const [copied, setCopied] = useState(false)
const handleClick = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation()
try {
await navigator.clipboard.writeText(value)
setCopied(true)
onCopy?.()
window.setTimeout(() => setCopied(false), 1500)
} catch {
// noop
}
}, [value, onCopy])
return (
<button
type="button"
onClick={handleClick}
title={title}
className={className}
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: size + 12,
height: size + 12,
border: 'none',
background: 'transparent',
color: copied ? '#22c55e' : 'var(--text-muted)',
cursor: 'pointer',
borderRadius: 6,
}}
>
<Copy size={size} style={{
position: 'absolute',
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 200ms cubic-bezier(0.23,1,0.32,1)',
opacity: copied ? 0 : 1,
transform: copied ? 'scale(0.6) rotate(-45deg)' : 'scale(1) rotate(0)',
}} />
<Check size={size} style={{
position: 'absolute',
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 200ms cubic-bezier(0.23,1,0.32,1)',
opacity: copied ? 1 : 0,
transform: copied ? 'scale(1) rotate(0)' : 'scale(0.6) rotate(45deg)',
strokeWidth: 2.5,
}} />
</button>
)
}
export default CopyButton
@@ -1,108 +0,0 @@
import React, { useEffect, useCallback } from 'react'
import { Check, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
interface CopyTripDialogProps {
isOpen: boolean
tripTitle: string
onClose: () => void
onConfirm: () => void
}
const WILL_COPY_KEYS = [
'dashboard.confirm.copy.will1',
'dashboard.confirm.copy.will2',
'dashboard.confirm.copy.will3',
'dashboard.confirm.copy.will4',
'dashboard.confirm.copy.will5',
'dashboard.confirm.copy.will6',
]
const WONT_COPY_KEYS = [
'dashboard.confirm.copy.wont1',
'dashboard.confirm.copy.wont2',
'dashboard.confirm.copy.wont3',
'dashboard.confirm.copy.wont4',
]
export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }: CopyTripDialogProps) {
const { t } = useTranslation()
const handleEsc = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useEffect(() => {
if (isOpen) document.addEventListener('keydown', handleEsc)
return () => document.removeEventListener('keydown', handleEsc)
}, [isOpen, handleEsc])
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
>
<div
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-md p-6"
style={{ background: 'var(--bg-card)' }}
onClick={e => e.stopPropagation()}
>
<h3 className="text-base font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
{t('dashboard.confirm.copy.title')}
</h3>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
{tripTitle}
</p>
<div className="flex flex-col gap-3">
<div className="rounded-xl p-3" style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border-secondary)' }}>
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: '#16a34a' }}>
{t('dashboard.confirm.copy.willCopy')}
</p>
<ul className="flex flex-col gap-1">
{WILL_COPY_KEYS.map(key => (
<li key={key} className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<Check size={13} className="flex-shrink-0" style={{ color: '#16a34a' }} />
{t(key)}
</li>
))}
</ul>
</div>
<div className="rounded-xl p-3" style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border-secondary)' }}>
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-muted)' }}>
{t('dashboard.confirm.copy.wontCopy')}
</p>
<ul className="flex flex-col gap-1">
{WONT_COPY_KEYS.map(key => (
<li key={key} className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<X size={13} className="flex-shrink-0" style={{ color: 'var(--text-muted)' }} />
{t(key)}
</li>
))}
</ul>
</div>
</div>
<div className="flex justify-end gap-3 mt-5">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)', border: '1px solid var(--border-secondary)' }}
>
{t('common.cancel')}
</button>
<button
onClick={() => { onConfirm(); onClose() }}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors text-white bg-blue-600 hover:bg-blue-700"
>
{t('dashboard.confirm.copy.confirm')}
</button>
</div>
</div>
</div>
)
}
@@ -119,14 +119,13 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
...(() => { ...(() => {
const r = ref.current?.getBoundingClientRect() const r = ref.current?.getBoundingClientRect()
if (!r) return { top: 0, left: 0 } if (!r) return { top: 0, left: 0 }
const w = 268, pad = 8, h = 360 const w = 268, pad = 8
const vw = window.innerWidth const vw = window.innerWidth
const vh = window.visualViewport?.height ?? window.innerHeight const vh = window.innerHeight
let left = r.left let left = r.left
let top = r.bottom + 4 let top = r.bottom + 4
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad) if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
if (top + h > vh - pad) top = r.top - h - 4 if (top + 320 > vh) top = Math.max(pad, r.top - 320)
top = Math.max(pad, Math.min(top, vh - h - pad))
if (vw < 360) left = Math.max(pad, (vw - w) / 2) if (vw < 360) left = Math.max(pad, (vw - w) / 2)
return { top, left } return { top, left }
})(), })(),
+8 -19
View File
@@ -9,7 +9,6 @@ interface SelectOption {
isHeader?: boolean isHeader?: boolean
searchLabel?: string searchLabel?: string
groupLabel?: string groupLabel?: string
badge?: string
} }
interface CustomSelectProps { interface CustomSelectProps {
@@ -105,14 +104,7 @@ export default function CustomSelect({
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: selected ? 'var(--text-primary)' : 'var(--text-faint)' }}> <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: selected ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{selected ? selected.label : placeholder} {selected ? selected.label : placeholder}
</span> </span>
{selected?.badge && ( <ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'none' }} />
<span style={{
flexShrink: 0, fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 999,
letterSpacing: '0.01em',
}}>{selected.badge}</span>
)}
<ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 200ms cubic-bezier(0.23,1,0.32,1)', transform: open ? 'rotate(180deg)' : 'none' }} />
</button> </button>
{/* Dropdown */} {/* Dropdown */}
@@ -136,9 +128,7 @@ export default function CustomSelect({
borderRadius: 10, borderRadius: 10,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)', boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
overflow: 'hidden', overflow: 'hidden',
animation: 'trek-menu-enter 200ms cubic-bezier(0.23, 1, 0.32, 1)', animation: 'selectIn 0.15s ease-out',
transformOrigin: 'top center',
willChange: 'transform, opacity',
}}> }}>
{/* Search */} {/* Search */}
{searchable && ( {searchable && (
@@ -194,13 +184,6 @@ export default function CustomSelect({
> >
{option.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{option.icon}</span>} {option.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{option.icon}</span>}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{option.label}</span> <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{option.label}</span>
{option.badge && (
<span style={{
flexShrink: 0, fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 999,
letterSpacing: '0.01em',
}}>{option.badge}</span>
)}
{isSelected && <Check size={13} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />} {isSelected && <Check size={13} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />}
</button> </button>
) )
@@ -211,6 +194,12 @@ export default function CustomSelect({
document.body document.body
)} )}
<style>{`
@keyframes selectIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
</div> </div>
) )
} }
@@ -1,36 +0,0 @@
import React, { useState, type ImgHTMLAttributes } from 'react'
interface LoadingImageProps extends ImgHTMLAttributes<HTMLImageElement> {
containerClassName?: string
containerStyle?: React.CSSProperties
}
// Image with shimmer-placeholder until loaded. Drops the shimmer once native load fires.
export function LoadingImage({
containerClassName, containerStyle, className, style, onLoad, ...imgProps
}: LoadingImageProps): React.ReactElement {
const [loaded, setLoaded] = useState(false)
return (
<div className={containerClassName} style={{ position: 'relative', overflow: 'hidden', ...containerStyle }}>
{!loaded && (
<div
className="trek-skeleton"
style={{ position: 'absolute', inset: 0, borderRadius: 0 }}
aria-hidden
/>
)}
<img
{...imgProps}
className={className}
style={{
...style,
opacity: loaded ? 1 : 0,
transition: 'opacity 300ms cubic-bezier(0.23, 1, 0.32, 1)',
}}
onLoad={e => { setLoaded(true); onLoad?.(e) }}
/>
</div>
)
}
export default LoadingImage
+20 -12
View File
@@ -50,7 +50,7 @@ export default function Modal({
return ( return (
<div <div
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop trek-backdrop-enter" className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }} style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
onMouseDown={e => { mouseDownTarget.current = e.target }} onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => { onClick={e => {
@@ -60,16 +60,18 @@ export default function Modal({
> >
<div <div
className={` className={`
trek-modal-enter rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md} flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
flex flex-col animate-in fade-in zoom-in-95 duration-200
max-h-[calc(100dvh-var(--bottom-nav-h)-90px)] sm:max-h-[calc(100dvh-90px)]
`} `}
style={{ background: 'var(--bg-card)' }} style={{
animation: 'modalIn 0.2s ease-out forwards',
background: 'var(--bg-card)',
}}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
{/* Header — stays put even while the body scrolls */} {/* Header */}
<div className="flex items-center justify-between p-6 flex-shrink-0" style={{ borderBottom: '1px solid var(--border-secondary)' }}> <div className="flex items-center justify-between p-6" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2> <h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
{!hideCloseButton && ( {!hideCloseButton && (
<button <button
@@ -81,19 +83,25 @@ export default function Modal({
)} )}
</div> </div>
{/* Body — scrolls when content overflows. min-h-0 lets the flex child shrink below its intrinsic height. */} {/* Body */}
<div className="flex-1 overflow-y-auto p-6 min-h-0"> <div className="flex-1 overflow-y-auto p-6">
{children} {children}
</div> </div>
{/* Footer — sticky at the bottom of the modal, never compressed */} {/* Footer */}
{footer && ( {footer && (
<div className="p-6 flex-shrink-0" style={{ borderTop: '1px solid var(--border-secondary)' }}> <div className="p-6" style={{ borderTop: '1px solid var(--border-secondary)' }}>
{footer} {footer}
</div> </div>
)} )}
</div> </div>
<style>{`
@keyframes modalIn {
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
`}</style>
</div> </div>
) )
} }
@@ -1,7 +1,6 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { getCategoryIcon } from './categoryIcons' import { getCategoryIcon } from './categoryIcons'
import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService' import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService'
import { useAuthStore } from '../../store/authStore'
import type { Place } from '../../types' import type { Place } from '../../types'
interface Category { interface Category {
@@ -19,12 +18,10 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null) const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
// Observe visibility — fetch photo only when avatar enters viewport // Observe visibility — fetch photo only when avatar enters viewport
useEffect(() => { useEffect(() => {
if (place.image_url) { setVisible(true); return } if (place.image_url) { setVisible(true); return }
if (!placesPhotosEnabled) return
const el = ref.current const el = ref.current
if (!el) return if (!el) return
// Check if already cached — show immediately without waiting for intersection // Check if already cached — show immediately without waiting for intersection
@@ -40,7 +37,6 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
useEffect(() => { useEffect(() => {
if (!visible) return if (!visible) return
if (place.image_url) { setPhotoSrc(place.image_url); return } if (place.image_url) { setPhotoSrc(place.image_url); return }
if (!placesPhotosEnabled) return
const photoId = place.google_place_id || place.osm_id const photoId = place.google_place_id || place.osm_id
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return } if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
-70
View File
@@ -1,70 +0,0 @@
import React from 'react'
// Simple skeleton placeholder with shimmer. Size via className or props.
export function Skeleton({
width, height, radius, className, style,
}: {
width?: number | string
height?: number | string
radius?: number | string
className?: string
style?: React.CSSProperties
}): React.ReactElement {
return (
<div
className={`trek-skeleton ${className ?? ''}`.trim()}
style={{
width,
height: height ?? 14,
borderRadius: radius,
...style,
}}
aria-hidden
/>
)
}
// Trip card skeleton matching SpotlightCard layout
export function SpotlightSkeleton(): React.ReactElement {
return (
<div
className="relative rounded-3xl overflow-hidden mb-8"
style={{ minHeight: 340, background: 'var(--bg-tertiary)' }}
>
<div className="trek-skeleton absolute inset-0" style={{ borderRadius: 24 }} />
<div className="relative p-6 flex flex-col justify-end" style={{ minHeight: 340 }}>
<Skeleton width={160} height={40} radius={8} style={{ marginBottom: 8 }} />
<Skeleton width={220} height={16} radius={4} />
</div>
</div>
)
}
// Trip list item skeleton
export function TripCardSkeleton(): React.ReactElement {
return (
<div
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden"
style={{ background: 'var(--bg-card)' }}
>
<Skeleton height={140} radius={0} />
<div className="p-4 flex flex-col gap-2">
<Skeleton width="60%" height={18} />
<Skeleton width="40%" height={12} />
</div>
</div>
)
}
// Day sidebar skeleton row
export function DaySkeleton(): React.ReactElement {
return (
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<Skeleton width={120} height={16} />
<Skeleton width="80%" height={12} />
<Skeleton width="60%" height={12} />
</div>
)
}
export default Skeleton
@@ -1,126 +0,0 @@
import React, { useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
export interface SlidingTab<T extends string> {
id: T
label: React.ReactNode
title?: string
icon?: React.ComponentType<{ size?: number; className?: string }>
count?: number
}
interface SlidingTabsProps<T extends string> {
tabs: readonly SlidingTab<T>[]
activeTab: T
onChange: (id: T) => void
size?: 'sm' | 'md'
fullWidth?: boolean
className?: string
indicatorColor?: string
indicatorTextColor?: string
}
// Stripe-style sliding indicator — der aktive Pill gleitet zwischen Tabs.
// Nutzt gemessene Offsets der Buttons + CSS transform.
export function SlidingTabs<T extends string>({
tabs, activeTab, onChange, size = 'md', fullWidth, className,
indicatorColor = 'var(--accent)', indicatorTextColor = 'var(--accent-text)',
}: SlidingTabsProps<T>): React.ReactElement {
const containerRef = useRef<HTMLDivElement>(null)
const tabRefs = useRef<Map<T, HTMLButtonElement | null>>(new Map())
const [indicator, setIndicator] = useState<{ left: number; width: number; ready: boolean }>({ left: 0, width: 0, ready: false })
useLayoutEffect(() => {
const active = tabRefs.current.get(activeTab)
const container = containerRef.current
if (!active || !container) return
const containerRect = container.getBoundingClientRect()
const activeRect = active.getBoundingClientRect()
setIndicator({
left: activeRect.left - containerRect.left + container.scrollLeft,
width: activeRect.width,
ready: true,
})
}, [activeTab, tabs.length])
const padding = size === 'sm' ? '5px 12px' : '6px 14px'
const fontSize = size === 'sm' ? 12 : 13
const borderRadius = size === 'sm' ? 18 : 20
return (
<div
ref={containerRef}
className={className}
style={{
position: 'relative', display: 'flex', alignItems: 'center',
gap: 2, overflowX: 'auto', scrollbarWidth: 'none', msOverflowStyle: 'none',
width: fullWidth ? '100%' : undefined,
}}
>
{/* Sliding indicator */}
{indicator.ready && (
<div
aria-hidden
style={{
position: 'absolute',
top: '50%',
left: indicator.left,
width: indicator.width,
height: size === 'sm' ? 26 : 30,
background: indicatorColor,
borderRadius,
transform: 'translateY(-50%)',
transition: 'left 320ms cubic-bezier(0.77, 0, 0.175, 1), width 320ms cubic-bezier(0.77, 0, 0.175, 1)',
pointerEvents: 'none',
zIndex: 0,
willChange: 'left, width',
}}
/>
)}
{tabs.map(tab => {
const isActive = tab.id === activeTab
const Icon = tab.icon
const btnStyle: CSSProperties = {
position: 'relative', zIndex: 1,
flexShrink: 0,
padding,
borderRadius,
border: 'none',
cursor: 'pointer',
fontSize,
fontWeight: isActive ? 600 : 500,
background: 'transparent',
color: isActive ? indicatorTextColor : 'var(--text-muted)',
fontFamily: 'inherit',
transition: 'color 220ms cubic-bezier(0.23, 1, 0.32, 1)',
display: 'flex', alignItems: 'center', gap: 6,
flex: fullWidth ? 1 : undefined,
justifyContent: 'center',
whiteSpace: 'nowrap',
}
return (
<button
key={tab.id}
ref={el => { tabRefs.current.set(tab.id, el) }}
onClick={() => onChange(tab.id)}
style={btnStyle}
title={tab.title ?? (typeof tab.label === 'string' ? tab.label : undefined)}
>
{Icon && <Icon size={size === 'sm' ? 13 : 15} />}
{tab.label}
{tab.count != null && (
<span style={{
fontSize: 10, fontWeight: 600,
padding: '1px 6px', borderRadius: 99, minWidth: 16,
background: isActive ? 'rgba(255,255,255,0.22)' : 'var(--bg-tertiary)',
color: isActive ? 'inherit' : 'var(--text-faint)',
textAlign: 'center',
}}>{tab.count}</span>
)}
</button>
)
})}
</div>
)
}
export default SlidingTabs
-100
View File
@@ -1,100 +0,0 @@
import React, { useState, useRef, useEffect } from 'react'
import ReactDOM from 'react-dom'
type Placement = 'top' | 'bottom' | 'left' | 'right'
interface TooltipProps {
label: string
placement?: Placement
delay?: number
disabled?: boolean
children: React.ReactElement
}
export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, children }: TooltipProps) {
const [open, setOpen] = useState(false)
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
const triggerRef = useRef<HTMLElement | null>(null)
const tooltipRef = useRef<HTMLDivElement | null>(null)
const timerRef = useRef<number | null>(null)
const show = () => {
if (disabled || !label) return
if (timerRef.current) window.clearTimeout(timerRef.current)
timerRef.current = window.setTimeout(() => setOpen(true), delay)
}
const hide = () => {
if (timerRef.current) window.clearTimeout(timerRef.current)
setOpen(false)
}
useEffect(() => () => { if (timerRef.current) window.clearTimeout(timerRef.current) }, [])
useEffect(() => {
if (!open || !triggerRef.current) return
const r = triggerRef.current.getBoundingClientRect()
const tipW = tooltipRef.current?.offsetWidth ?? 0
const tipH = tooltipRef.current?.offsetHeight ?? 0
const gap = 6
let top = 0, left = 0
if (placement === 'top') { top = r.top - tipH - gap; left = r.left + r.width / 2 - tipW / 2 }
else if (placement === 'bottom') { top = r.bottom + gap; left = r.left + r.width / 2 - tipW / 2 }
else if (placement === 'left') { top = r.top + r.height / 2 - tipH / 2; left = r.left - tipW - gap }
else { top = r.top + r.height / 2 - tipH / 2; left = r.right + gap }
const pad = 6
left = Math.max(pad, Math.min(left, window.innerWidth - tipW - pad))
top = Math.max(pad, Math.min(top, window.innerHeight - tipH - pad))
setCoords({ top, left })
}, [open, placement, label])
const child = React.Children.only(children)
const trigger = React.cloneElement(child, {
ref: (node: HTMLElement | null) => {
triggerRef.current = node
const r = (child as any).ref
if (typeof r === 'function') r(node)
else if (r && typeof r === 'object') r.current = node
},
onMouseEnter: (e: any) => { show(); child.props.onMouseEnter?.(e) },
onMouseLeave: (e: any) => { hide(); child.props.onMouseLeave?.(e) },
onFocus: (e: any) => { show(); child.props.onFocus?.(e) },
onBlur: (e: any) => { hide(); child.props.onBlur?.(e) },
})
return (
<>
{trigger}
{open && ReactDOM.createPortal(
<div
ref={tooltipRef}
role="tooltip"
className="trek-popover-enter"
style={{
position: 'fixed',
top: coords?.top ?? -9999,
left: coords?.left ?? -9999,
visibility: coords ? 'visible' : 'hidden',
pointerEvents: 'none',
zIndex: 100000,
background: 'var(--bg-card, #ffffff)',
color: 'var(--text-primary, #111827)',
fontSize: 11,
fontWeight: 500,
padding: '5px 10px',
borderRadius: 8,
whiteSpace: 'nowrap',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
border: '1px solid var(--border-faint, #e5e7eb)',
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
transformOrigin: placement === 'top' ? 'bottom center' : placement === 'bottom' ? 'top center' : placement === 'left' ? 'center right' : 'center left',
}}
>
{label}
</div>,
document.body,
)}
</>
)
}
export default Tooltip
-30
View File
@@ -1,30 +0,0 @@
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 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 }
startRef.current = null
const step = (now: number) => {
if (startRef.current == null) startRef.current = now
const elapsed = now - startRef.current
const t = Math.min(elapsed / duration, 1)
// ease-out-quint
const eased = 1 - Math.pow(1 - t, 5)
setValue(Math.round(target * eased))
if (t < 1) frameRef.current = requestAnimationFrame(step)
}
frameRef.current = requestAnimationFrame(step)
return () => { if (frameRef.current) cancelAnimationFrame(frameRef.current) }
}, [target, duration])
return value
}
-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 }
}
+13 -86
View File
@@ -1,123 +1,50 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react' import { useState, useCallback, useRef, useEffect } from 'react'
import { useSettingsStore } from '../store/settingsStore' import { useSettingsStore } from '../store/settingsStore'
import { useTripStore } from '../store/tripStore'
import { calculateSegments } from '../components/Map/RouteCalculator' import { calculateSegments } from '../components/Map/RouteCalculator'
import type { TripStoreState } from '../store/tripStore' import type { TripStoreState } from '../store/tripStore'
import type { RouteSegment, RouteResult } from '../types' import type { RouteSegment, RouteResult } from '../types'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'cruise']
/** /**
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from * Manages route calculation state for a selected day. Extracts geo-coded waypoints from
* day assignments, draws a straight-line route, and optionally fetches per-segment * day assignments, draws a straight-line route, and optionally fetches per-segment
* driving/walking durations via OSRM. Aborts in-flight requests when the day changes. * driving/walking durations via OSRM. Aborts in-flight requests when the day changes.
*/ */
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null) { export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null) {
const [route, setRoute] = useState<[number, number][][] | null>(null) const [route, setRoute] = useState<[number, number][] | null>(null)
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null) const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([]) const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
const routeAbortRef = useRef<AbortController | null>(null) const routeAbortRef = useRef<AbortController | null>(null)
const reservationsForSignature = useTripStore((s) => s.reservations) // Keep a ref to the latest tripStore so updateRouteForDay never has a stale closure
const tripStoreRef = useRef(tripStore)
tripStoreRef.current = tripStore
const updateRouteForDay = useCallback(async (dayId: number | null) => { const updateRouteForDay = useCallback(async (dayId: number | null) => {
if (routeAbortRef.current) routeAbortRef.current.abort() if (routeAbortRef.current) routeAbortRef.current.abort()
if (!dayId) { setRoute(null); setRouteSegments([]); return } if (!dayId) { setRoute(null); setRouteSegments([]); return }
// Read directly from store (not a render-phase ref) so callers after optimistic const currentAssignments = tripStoreRef.current.assignments || {}
// updates or non-optimistic deletes always see the latest assignments.
const currentAssignments = useTripStore.getState().assignments || {}
const da = (currentAssignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index) const da = (currentAssignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const allReservations = useTripStore.getState().reservations || [] const waypoints = da.map((a) => a.place).filter((p) => p?.lat && p?.lng)
const allDays = useTripStore.getState().days || [] if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
const dayOrder = (id: number | null | undefined): number | null => { setRoute(waypoints.map((p) => [p.lat!, p.lng!]))
if (id == null) return null
const d = allDays.find(x => x.id === id)
return d ? ((d as any).day_number ?? allDays.indexOf(d)) : null
}
const thisOrder = dayOrder(dayId)
// Transport reservations for this day with a known position — mirrors getTransportForDay semantics
const dayTransports = thisOrder == null ? [] : allReservations.filter(r => {
if (!TRANSPORT_TYPES.includes(r.type)) return false
const startId = r.day_id
if (startId == null) return false
const endId = r.end_day_id ?? startId
if (startId === endId) {
if (startId !== dayId) return false
} else {
const startOrder = dayOrder(startId)
const endOrder = dayOrder(endId)
if (startOrder == null || endOrder == null) return false
if (thisOrder < startOrder || thisOrder > endOrder) return false
}
const pos = r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position
return pos != null
})
// Build a unified list of places + transports sorted by effective position,
// then derive segments by resetting whenever a transport appears — mirrors getMergedItems order.
type Entry = { kind: 'place'; lat: number; lng: number } | { kind: 'transport' }
const entries: (Entry & { pos: number })[] = [
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
})),
...dayTransports.map(r => ({
kind: 'transport' as const,
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
})),
].sort((a, b) => a.pos - b.pos)
const segments: [number, number][][] = []
let currentSeg: [number, number][] = []
for (const entry of entries) {
if (entry.kind === 'place') {
currentSeg.push([entry.lat, entry.lng])
} else {
if (currentSeg.length >= 2) segments.push([...currentSeg])
currentSeg = []
}
}
if (currentSeg.length >= 2) segments.push(currentSeg)
const geocodedWaypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng) as { lat: number; lng: number }[]
if (segments.length === 0 && geocodedWaypoints.length < 2) {
setRoute(null); setRouteSegments([]); return
}
setRoute(segments.length > 0 ? segments : null)
if (!routeCalcEnabled) { setRouteSegments([]); return } if (!routeCalcEnabled) { setRouteSegments([]); return }
const controller = new AbortController() const controller = new AbortController()
routeAbortRef.current = controller routeAbortRef.current = controller
try { try {
const calcSegments = await calculateSegments(geocodedWaypoints, { signal: controller.signal }) const segments = await calculateSegments(waypoints as { lat: number; lng: number }[], { signal: controller.signal })
if (!controller.signal.aborted) setRouteSegments(calcSegments) if (!controller.signal.aborted) setRouteSegments(segments)
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([]) if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
else if (!(err instanceof Error)) setRouteSegments([]) else if (!(err instanceof Error)) setRouteSegments([])
} }
}, [routeCalcEnabled]) }, [routeCalcEnabled])
// Stable signature for transport reservations on the selected day changes when a transport // Only recalculate when assignments for the SELECTED day change
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
const transportSignature = useMemo(() => {
if (!selectedDayId) return ''
return reservationsForSignature
.filter(r => TRANSPORT_TYPES.includes(r.type))
.map(r => {
const pos = r.day_positions?.[selectedDayId] ?? r.day_positions?.[String(selectedDayId)] ?? r.day_plan_position
return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}`
})
.sort()
.join('|')
}, [reservationsForSignature, selectedDayId])
// Recalculate when assignments or transport positions for the SELECTED day change
const selectedDayAssignments = selectedDayId ? tripStore.assignments?.[String(selectedDayId)] : null const selectedDayAssignments = selectedDayId ? tripStore.assignments?.[String(selectedDayId)] : null
useEffect(() => { useEffect(() => {
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return } if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
updateRouteForDay(selectedDayId) updateRouteForDay(selectedDayId)
// eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedDayId, selectedDayAssignments])
}, [selectedDayId, selectedDayAssignments, transportSignature])
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
} }
+2 -115
View File
@@ -14,9 +14,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'common.add': 'إضافة', 'common.add': 'إضافة',
'common.loading': 'جارٍ التحميل...', 'common.loading': 'جارٍ التحميل...',
'common.import': 'استيراد', 'common.import': 'استيراد',
'common.select': 'تحديد',
'common.selectAll': 'تحديد الكل',
'common.deselectAll': 'إلغاء تحديد الكل',
'common.error': 'خطأ', 'common.error': 'خطأ',
'common.unknownError': 'خطأ غير معروف', 'common.unknownError': 'خطأ غير معروف',
'common.tooManyAttempts': 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.', 'common.tooManyAttempts': 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
@@ -34,8 +31,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'لا شيء', 'common.none': 'لا شيء',
'common.date': 'التاريخ', 'common.date': 'التاريخ',
'common.rename': 'إعادة تسمية', 'common.rename': 'إعادة تسمية',
'common.discardChanges': 'تجاهل التغييرات',
'common.discard': 'تجاهل',
'common.name': 'الاسم', 'common.name': 'الاسم',
'common.email': 'البريد الإلكتروني', 'common.email': 'البريد الإلكتروني',
'common.password': 'كلمة المرور', 'common.password': 'كلمة المرور',
@@ -163,24 +158,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.mapDefaultHint': 'اتركه فارغًا لاستخدام OpenStreetMap افتراضيًا', 'settings.mapDefaultHint': 'اتركه فارغًا لاستخدام OpenStreetMap افتراضيًا',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'قالب URL لبلاطات الخريطة', '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.latitude': 'خط العرض',
'settings.longitude': 'خط الطول', 'settings.longitude': 'خط الطول',
'settings.saveMap': 'حفظ الخريطة', 'settings.saveMap': 'حفظ الخريطة',
@@ -206,7 +183,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'دعوات الرحلات', 'settings.notifyTripInvite': 'دعوات الرحلات',
'settings.notifyBookingChange': 'تغييرات الحجز', 'settings.notifyBookingChange': 'تغييرات الحجز',
'settings.notifyTripReminder': 'تذكيرات الرحلات', 'settings.notifyTripReminder': 'تذكيرات الرحلات',
'settings.notifyTodoDue': 'مهمة مستحقة',
'settings.notifyVacayInvite': 'دعوات دمج الإجازات', 'settings.notifyVacayInvite': 'دعوات دمج الإجازات',
'settings.notifyPhotosShared': 'صور مشتركة (Immich)', 'settings.notifyPhotosShared': 'صور مشتركة (Immich)',
'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)', 'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)',
@@ -337,16 +313,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'اقتراح ميزة', 'settings.about.featureRequest': 'اقتراح ميزة',
'settings.about.featureRequestHint': 'اقترح ميزة جديدة', 'settings.about.featureRequestHint': 'اقترح ميزة جديدة',
'settings.about.wikiHint': 'التوثيق والأدلة', 'settings.about.wikiHint': 'التوثيق والأدلة',
'settings.about.supporters.badge': 'الداعمون الشهريون',
'settings.about.supporters.title': 'رفاق رحلة TREK',
'settings.about.supporters.subtitle': 'بينما تخطّط لمسارك التالي، يساعد هؤلاء الأشخاص في التخطيط لمستقبل TREK. تذهب مساهمتهم الشهرية مباشرةً إلى التطوير والساعات الفعلية المبذولة — حتى يظلّ TREK مفتوح المصدر.',
'settings.about.supporters.since': 'داعم منذ {date}',
'settings.about.supporters.tierEmpty': 'كن الأول',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK هو مخطط سفر مستضاف ذاتيًا يساعدك على تنظيم رحلاتك من أول فكرة حتى آخر ذكرى. تخطيط يومي، ميزانية، قوائم تعبئة، صور والمزيد — كل شيء في مكان واحد، على خادمك الخاص.', 'settings.about.description': 'TREK هو مخطط سفر مستضاف ذاتيًا يساعدك على تنظيم رحلاتك من أول فكرة حتى آخر ذكرى. تخطيط يومي، ميزانية، قوائم تعبئة، صور والمزيد — كل شيء في مكان واحد، على خادمك الخاص.',
'settings.about.madeWith': 'صُنع بـ', 'settings.about.madeWith': 'صُنع بـ',
'settings.about.madeBy': 'بواسطة موريس ومجتمع مفتوح المصدر متنامٍ.', 'settings.about.madeBy': 'بواسطة موريس ومجتمع مفتوح المصدر متنامٍ.',
@@ -466,28 +432,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC', 'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC',
'login.usernameRequired': 'اسم المستخدم مطلوب', 'login.usernameRequired': 'اسم المستخدم مطلوب',
'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل', '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
'register.passwordMismatch': 'كلمتا المرور غير متطابقتين', 'register.passwordMismatch': 'كلمتا المرور غير متطابقتين',
@@ -644,12 +588,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'تم حفظ إعدادات أنواع الملفات', 'admin.fileTypesSaved': 'تم حفظ إعدادات أنواع الملفات',
// Packing Templates & Bag Tracking // Packing Templates & Bag Tracking
'admin.placesPhotos.title': 'صور الأماكن',
'admin.placesPhotos.subtitle': 'جلب الصور من Google Places API. عطّلها للحفاظ على حصة API. صور Wikimedia غير متأثرة.',
'admin.placesAutocomplete.title': 'الإكمال التلقائي للأماكن',
'admin.placesAutocomplete.subtitle': 'استخدام Google Places API لاقتراحات البحث. عطّلها للحفاظ على حصة API.',
'admin.placesDetails.title': 'تفاصيل الأماكن',
'admin.placesDetails.subtitle': 'جلب معلومات تفصيلية عن الأماكن (الساعات، التقييم، الموقع) من Google Places API. عطّلها للحفاظ على حصة API.',
'admin.bagTracking.title': 'تتبع الأمتعة', 'admin.bagTracking.title': 'تتبع الأمتعة',
'admin.bagTracking.subtitle': 'تفعيل الوزن وتعيين الأمتعة للعناصر', 'admin.bagTracking.subtitle': 'تفعيل الوزن وتعيين الأمتعة للعناصر',
'admin.collab.chat.title': 'الدردشة', 'admin.collab.chat.title': 'الدردشة',
@@ -913,7 +851,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// Trip Planner // Trip Planner
'trip.tabs.plan': 'الخطة', 'trip.tabs.plan': 'الخطة',
'trip.tabs.transports': 'المواصلات',
'trip.tabs.reservations': 'الحجوزات', 'trip.tabs.reservations': 'الحجوزات',
'trip.tabs.reservationsShort': 'حجز', 'trip.tabs.reservationsShort': 'حجز',
'trip.tabs.packing': 'قائمة التجهيز', 'trip.tabs.packing': 'قائمة التجهيز',
@@ -936,8 +873,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'trip.toast.reservationAdded': 'تمت إضافة الحجز', 'trip.toast.reservationAdded': 'تمت إضافة الحجز',
'trip.toast.deleted': 'تم الحذف', 'trip.toast.deleted': 'تم الحذف',
'trip.confirm.deletePlace': 'هل تريد حذف هذا المكان؟', 'trip.confirm.deletePlace': 'هل تريد حذف هذا المكان؟',
'trip.confirm.deletePlaces': 'حذف {count} أماكن؟',
'trip.toast.placesDeleted': 'تم حذف {count} أماكن',
// Day Plan Sidebar // Day Plan Sidebar
'dayplan.emptyDay': 'لا توجد أماكن مخططة لهذا اليوم', 'dayplan.emptyDay': 'لا توجد أماكن مخططة لهذا اليوم',
@@ -982,17 +917,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'places.importFileError': 'فشل الاستيراد', 'places.importFileError': 'فشل الاستيراد',
'places.importAllSkipped': 'جميع الأماكن موجودة بالفعل في الرحلة.', 'places.importAllSkipped': 'جميع الأماكن موجودة بالفعل في الرحلة.',
'places.gpxImported': 'تم استيراد {count} مكان من GPX', 'places.gpxImported': 'تم استيراد {count} مكان من GPX',
'places.gpxImportTypes': 'ما الذي تريد استيراده؟',
'places.gpxImportWaypoints': 'نقاط الطريق',
'places.gpxImportRoutes': 'المسارات',
'places.gpxImportTracks': 'المسارات (مع هندسة الطريق)',
'places.gpxImportNoneSelected': 'اختر نوعاً واحداً على الأقل للاستيراد.',
'places.kmlImportTypes': 'ما الذي تريد استيراده؟',
'places.kmlImportPoints': 'نقاط (Placemarks)',
'places.kmlImportPaths': 'مسارات (LineStrings)',
'places.kmlImportNoneSelected': 'اختر نوعًا واحدًا على الأقل.',
'places.selectionCount': '{count} محدد',
'places.deleteSelected': 'حذف المحدد',
'places.kmlKmzImported': 'تم استيراد {count} مكان من KMZ/KML', 'places.kmlKmzImported': 'تم استيراد {count} مكان من KMZ/KML',
'places.urlResolved': 'تم استيراد المكان من الرابط', 'places.urlResolved': 'تم استيراد المكان من الرابط',
'places.importList': 'استيراد قائمة', 'places.importList': 'استيراد قائمة',
@@ -1009,7 +933,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟', 'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
'places.all': 'الكل', 'places.all': 'الكل',
'places.unplanned': 'غير مخطط', 'places.unplanned': 'غير مخطط',
'places.filterTracks': 'المسارات',
'places.search': 'ابحث عن أماكن...', 'places.search': 'ابحث عن أماكن...',
'places.allCategories': 'كل الفئات', 'places.allCategories': 'كل الفئات',
'places.categoriesSelected': 'فئات', 'places.categoriesSelected': 'فئات',
@@ -1094,15 +1017,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.flightNumber': 'رقم الرحلة', 'reservations.meta.flightNumber': 'رقم الرحلة',
'reservations.meta.from': 'من', 'reservations.meta.from': 'من',
'reservations.meta.to': 'إلى', 'reservations.meta.to': 'إلى',
'reservations.needsReview': 'مراجعة',
'reservations.needsReviewHint': 'تعذّر مطابقة المطار تلقائياً — يرجى تأكيد الموقع.',
'reservations.searchLocation': 'ابحث عن محطة، ميناء، عنوان...',
'airport.searchPlaceholder': 'رمز المطار أو المدينة (مثل FRA)',
'map.connections': 'الاتصالات',
'map.showConnections': 'عرض مسارات الحجوزات',
'map.hideConnections': 'إخفاء مسارات الحجوزات',
'settings.bookingLabels': 'تسميات مسارات الحجوزات',
'settings.bookingLabelsHint': 'عرض أسماء المحطات/المطارات على الخريطة. عند الإيقاف، يتم عرض الرمز فقط.',
'reservations.meta.trainNumber': 'رقم القطار', 'reservations.meta.trainNumber': 'رقم القطار',
'reservations.meta.platform': 'المنصة', 'reservations.meta.platform': 'المنصة',
'reservations.meta.seat': 'المقعد', 'reservations.meta.seat': 'المقعد',
@@ -1121,7 +1035,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.hotel': 'إقامة', 'reservations.type.hotel': 'إقامة',
'reservations.type.restaurant': 'مطعم', 'reservations.type.restaurant': 'مطعم',
'reservations.type.train': 'قطار', 'reservations.type.train': 'قطار',
'reservations.type.car': 'سيارة', 'reservations.type.car': 'سيارة مستأجرة',
'reservations.type.cruise': 'رحلة بحرية', 'reservations.type.cruise': 'رحلة بحرية',
'reservations.type.event': 'فعالية', 'reservations.type.event': 'فعالية',
'reservations.type.tour': 'جولة', 'reservations.type.tour': 'جولة',
@@ -1182,7 +1096,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'reservations.span.end': 'النهاية', 'reservations.span.end': 'النهاية',
'reservations.span.ongoing': 'جارٍ', 'reservations.span.ongoing': 'جارٍ',
'reservations.validation.endBeforeStart': 'يجب أن يكون تاريخ/وقت الانتهاء بعد تاريخ/وقت البدء', 'reservations.validation.endBeforeStart': 'يجب أن يكون تاريخ/وقت الانتهاء بعد تاريخ/وقت البدء',
'reservations.addBooking': 'إضافة حجز',
// Budget // Budget
'budget.title': 'الميزانية', 'budget.title': 'الميزانية',
@@ -1224,8 +1137,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'الملفات', 'files.title': 'الملفات',
'files.pageTitle': 'الملفات والمستندات', 'files.pageTitle': 'الملفات والمستندات',
'files.subtitle': '{count} ملف لـ {trip}', 'files.subtitle': '{count} ملف لـ {trip}',
'files.download': 'تنزيل',
'files.openError': 'تعذر فتح الملف',
'files.downloadPdf': 'تنزيل PDF', 'files.downloadPdf': 'تنزيل PDF',
'files.count': '{count} ملفات', 'files.count': '{count} ملفات',
'files.countSingular': 'ملف واحد', 'files.countSingular': 'ملف واحد',
@@ -1249,7 +1160,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'فشل حذف الملف', 'files.toast.deleteError': 'فشل حذف الملف',
'files.sourcePlan': 'خطة اليوم', 'files.sourcePlan': 'خطة اليوم',
'files.sourceBooking': 'الحجز', 'files.sourceBooking': 'الحجز',
'files.sourceTransport': 'النقل',
'files.attach': 'إرفاق', 'files.attach': 'إرفاق',
'files.pasteHint': 'يمكنك أيضًا لصق الصور من الحافظة (Ctrl+V)', 'files.pasteHint': 'يمكنك أيضًا لصق الصور من الحافظة (Ctrl+V)',
'files.trash': 'سلة المهملات', 'files.trash': 'سلة المهملات',
@@ -1262,7 +1172,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'إسناد ملف', 'files.assignTitle': 'إسناد ملف',
'files.assignPlace': 'المكان', 'files.assignPlace': 'المكان',
'files.assignBooking': 'الحجز', 'files.assignBooking': 'الحجز',
'files.assignTransport': 'النقل',
'files.unassigned': 'غير مسند', 'files.unassigned': 'غير مسند',
'files.unlink': 'إزالة الرابط', 'files.unlink': 'إزالة الرابط',
'files.toast.trashed': 'تم النقل إلى سلة المهملات', 'files.toast.trashed': 'تم النقل إلى سلة المهملات',
@@ -1624,10 +1533,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'memories.providerPassword': 'كلمة المرور', 'memories.providerPassword': 'كلمة المرور',
'memories.providerOTP': 'رمز MFA (إذا كان مفعلاً)', 'memories.providerOTP': 'رمز MFA (إذا كان مفعلاً)',
'memories.skipSSLVerification': 'تخطي التحقق من شهادة SSL', 'memories.skipSSLVerification': 'تخطي التحقق من شهادة SSL',
'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع',
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo', 'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
'memories.testConnection': 'اختبار الاتصال', 'memories.testConnection': 'اختبار الاتصال',
'memories.testShort': 'اختبار',
'memories.testFirst': 'اختبر الاتصال أولاً', 'memories.testFirst': 'اختبر الاتصال أولاً',
'memories.connected': 'متصل', 'memories.connected': 'متصل',
'memories.disconnected': 'غير متصل', 'memories.disconnected': 'غير متصل',
@@ -1704,7 +1611,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'journey.invite.inviting': 'جارٍ الدعوة...', 'journey.invite.inviting': 'جارٍ الدعوة...',
// Journey Entry Editor // Journey Entry Editor
'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟',
'journey.editor.uploadPhotos': 'رفع صور', 'journey.editor.uploadPhotos': 'رفع صور',
'journey.editor.uploading': '...جارٍ الرفع', 'journey.editor.uploading': '...جارٍ الرفع',
'journey.editor.fromGallery': 'من المعرض', 'journey.editor.fromGallery': 'من المعرض',
@@ -1842,7 +1748,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'undo.reorder': 'تمت إعادة ترتيب الأماكن', 'undo.reorder': 'تمت إعادة ترتيب الأماكن',
'undo.optimize': 'تم تحسين المسار', 'undo.optimize': 'تم تحسين المسار',
'undo.deletePlace': 'تم حذف المكان', 'undo.deletePlace': 'تم حذف المكان',
'undo.deletePlaces': 'تم حذف الأماكن',
'undo.moveDay': 'تم نقل المكان إلى يوم آخر', 'undo.moveDay': 'تم نقل المكان إلى يوم آخر',
'undo.lock': 'تم تبديل قفل المكان', 'undo.lock': 'تم تبديل قفل المكان',
'undo.importGpx': 'استيراد GPX', 'undo.importGpx': 'استيراد GPX',
@@ -1902,11 +1807,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'غير مُسنَد', 'todo.unassigned': 'غير مُسنَد',
'todo.noCategory': 'بدون فئة', 'todo.noCategory': 'بدون فئة',
'todo.hasDescription': 'له وصف', 'todo.hasDescription': 'له وصف',
'todo.addItem': 'إضافة مهمة جديدة', 'todo.addItem': 'إضافة مهمة جديدة...',
'todo.sidebar.sortBy': 'ترتيب حسب',
'todo.priority': 'الأولوية',
'todo.newCategoryLabel': 'جديد',
'budget.categoriesLabel': 'فئات',
'todo.newCategory': 'اسم الفئة', 'todo.newCategory': 'اسم الفئة',
'todo.addCategory': 'إضافة فئة', 'todo.addCategory': 'إضافة فئة',
'todo.newItem': 'مهمة جديدة', 'todo.newItem': 'مهمة جديدة',
@@ -2003,8 +1904,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} حدّث حجزاً في {trip}', 'notif.booking_change.text': '{actor} حدّث حجزاً في {trip}',
'notif.trip_reminder.title': 'تذكير بالرحلة', 'notif.trip_reminder.title': 'تذكير بالرحلة',
'notif.trip_reminder.text': 'رحلتك {trip} تقترب!', 'notif.trip_reminder.text': 'رحلتك {trip} تقترب!',
'notif.todo_due.title': 'مهمة مستحقة',
'notif.todo_due.text': '{todo} في {trip} مستحقة في {due}',
'notif.vacay_invite.title': 'دعوة دمج الإجازة', 'notif.vacay_invite.title': 'دعوة دمج الإجازة',
'notif.vacay_invite.text': '{actor} يدعوك لدمج خطط الإجازة', 'notif.vacay_invite.text': '{actor} يدعوك لدمج خطط الإجازة',
'notif.photos_shared.title': 'تمت مشاركة الصور', 'notif.photos_shared.title': 'تمت مشاركة الصور',
@@ -2042,7 +1941,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'oauth.scope.group.vacay': 'الإجازة', 'oauth.scope.group.vacay': 'الإجازة',
'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.geo': 'Geo',
'oauth.scope.group.weather': 'الطقس', 'oauth.scope.group.weather': 'الطقس',
'oauth.scope.group.journey': 'مذكرة السفر',
// OAuth scope labels & descriptions // OAuth scope labels & descriptions
'oauth.scope.trips:read.label': 'عرض الرحلات وخطط السفر', 'oauth.scope.trips:read.label': 'عرض الرحلات وخطط السفر',
@@ -2093,12 +1991,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'oauth.scope.geo:read.description': 'البحث عن مواقع وحل عناوين الخرائط والترميز الجغرافي العكسي للإحداثيات', 'oauth.scope.geo:read.description': 'البحث عن مواقع وحل عناوين الخرائط والترميز الجغرافي العكسي للإحداثيات',
'oauth.scope.weather:read.label': 'توقعات الطقس', 'oauth.scope.weather:read.label': 'توقعات الطقس',
'oauth.scope.weather:read.description': 'جلب توقعات الطقس لمواقع الرحلة وتواريخها', '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 notices
'system_notice.welcome_v1.title': 'مرحبًا بك في TREK', 'system_notice.welcome_v1.title': 'مرحبًا بك في TREK',
@@ -2143,11 +2035,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// System notices — personal thank you // System notices — personal thank you
'system_notice.v3_thankyou.title': 'كلمة شخصية مني', 'system_notice.v3_thankyou.title': 'كلمة شخصية مني',
'system_notice.v3_thankyou.body': 'قبل أن تمضي — أريد أن أتوقف لحظة.\n\nبدأ TREK كمشروع جانبي بنيته لرحلاتي الخاصة. لم أتخيل يومًا أنه سيكبر ليصبح شيئًا يعتمد عليه 4,000 منكم لتخطيط مغامراتهم. كل نجمة، كل مشكلة، كل طلب ميزة — أقرأها جميعًا، وهي ما يبقيني مستمرًا في الليالي المتأخرة بين عمل بدوام كامل والجامعة.\n\nأريدكم أن تعرفوا: TREK سيبقى دائمًا مفتوح المصدر، دائمًا مستضافًا ذاتيًا، دائمًا ملككم. لا تتبع، لا اشتراكات، لا شروط خفية. مجرد أداة بناها شخص يحب السفر بقدر ما تحبونه.\n\nشكر خاص لـ [jubnl](https://github.com/jubnl) — لقد أصبحت متعاونًا رائعًا. الكثير مما يجعل الإصدار 3.0 عظيمًا يحمل بصماتك. شكرًا لإيمانك بهذا المشروع عندما كان لا يزال في بداياته.\n\nولكل واحد منكم ممن أبلغ عن خطأ، أو ترجم نصًا، أو شارك TREK مع صديق، أو ببساطة استخدمه لتخطيط رحلة — **شكرًا لكم**. أنتم السبب في وجود هذا.\n\nإلى المزيد من المغامرات معًا.\n\n— Maurice\n\n---\n\n[انضم إلى المجتمع على Discord](https://discord.gg/7Q6M6jDwzf)\n\nإذا جعل TREK رحلاتك أفضل، [فنجان قهوة صغير](https://ko-fi.com/mauriceboe) يبقي الأضواء مشتعلة.', 'system_notice.v3_thankyou.body': 'قبل أن تمضي — أريد أن أتوقف لحظة.\n\nبدأ TREK كمشروع جانبي بنيته لرحلاتي الخاصة. لم أتخيل يومًا أنه سيكبر ليصبح شيئًا يعتمد عليه 4,000 منكم لتخطيط مغامراتهم. كل نجمة، كل مشكلة، كل طلب ميزة — أقرأها جميعًا، وهي ما يبقيني مستمرًا في الليالي المتأخرة بين عمل بدوام كامل والجامعة.\n\nأريدكم أن تعرفوا: TREK سيبقى دائمًا مفتوح المصدر، دائمًا مستضافًا ذاتيًا، دائمًا ملككم. لا تتبع، لا اشتراكات، لا شروط خفية. مجرد أداة بناها شخص يحب السفر بقدر ما تحبونه.\n\nشكر خاص لـ [jubnl](https://github.com/jubnl) — لقد أصبحت متعاونًا رائعًا. الكثير مما يجعل الإصدار 3.0 عظيمًا يحمل بصماتك. شكرًا لإيمانك بهذا المشروع عندما كان لا يزال في بداياته.\n\nولكل واحد منكم ممن أبلغ عن خطأ، أو ترجم نصًا، أو شارك TREK مع صديق، أو ببساطة استخدمه لتخطيط رحلة — **شكرًا لكم**. أنتم السبب في وجود هذا.\n\nإلى المزيد من المغامرات معًا.\n\n— Maurice\n\n---\n\n[انضم إلى المجتمع على Discord](https://discord.gg/7Q6M6jDwzf)\n\nإذا جعل TREK رحلاتك أفضل، [فنجان قهوة صغير](https://ko-fi.com/mauriceboe) يبقي الأضواء مشتعلة.',
'transport.addTransport': 'إضافة وسيلة نقل',
'transport.modalTitle.create': 'إضافة وسيلة نقل',
'transport.modalTitle.edit': 'تعديل وسيلة النقل',
'transport.title': 'المواصلات',
'transport.addManual': 'نقل يدوي',
} }
export default ar export default ar
+2 -115
View File
@@ -10,9 +10,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'common.add': 'Adicionar', 'common.add': 'Adicionar',
'common.loading': 'Carregando...', 'common.loading': 'Carregando...',
'common.import': 'Importar', 'common.import': 'Importar',
'common.select': 'Selecionar',
'common.selectAll': 'Selecionar tudo',
'common.deselectAll': 'Desmarcar tudo',
'common.error': 'Erro', 'common.error': 'Erro',
'common.unknownError': 'Erro desconhecido', 'common.unknownError': 'Erro desconhecido',
'common.tooManyAttempts': 'Muitas tentativas. Tente novamente mais tarde.', 'common.tooManyAttempts': 'Muitas tentativas. Tente novamente mais tarde.',
@@ -30,8 +27,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Nenhum', 'common.none': 'Nenhum',
'common.date': 'Data', 'common.date': 'Data',
'common.rename': 'Renomear', 'common.rename': 'Renomear',
'common.discardChanges': 'Descartar alterações',
'common.discard': 'Descartar',
'common.name': 'Nome', 'common.name': 'Nome',
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Senha', 'common.password': 'Senha',
@@ -158,24 +153,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.mapDefaultHint': 'Deixe vazio para OpenStreetMap (padrão)', 'settings.mapDefaultHint': 'Deixe vazio para OpenStreetMap (padrão)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'URL do modelo de blocos do mapa', '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.latitude': 'Latitude',
'settings.longitude': 'Longitude', 'settings.longitude': 'Longitude',
'settings.saveMap': 'Salvar mapa', 'settings.saveMap': 'Salvar mapa',
@@ -201,7 +178,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Convites de viagem', 'settings.notifyTripInvite': 'Convites de viagem',
'settings.notifyBookingChange': 'Alterações de reserva', 'settings.notifyBookingChange': 'Alterações de reserva',
'settings.notifyTripReminder': 'Lembretes de viagem', 'settings.notifyTripReminder': 'Lembretes de viagem',
'settings.notifyTodoDue': 'Tarefa com vencimento',
'settings.notifyVacayInvite': 'Convites de fusão Vacay', 'settings.notifyVacayInvite': 'Convites de fusão Vacay',
'settings.notifyPhotosShared': 'Fotos compartilhadas (Immich)', 'settings.notifyPhotosShared': 'Fotos compartilhadas (Immich)',
'settings.notifyCollabMessage': 'Mensagens de chat (Colab)', 'settings.notifyCollabMessage': 'Mensagens de chat (Colab)',
@@ -264,16 +240,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'Solicitar recurso', 'settings.about.featureRequest': 'Solicitar recurso',
'settings.about.featureRequestHint': 'Sugira um novo recurso', 'settings.about.featureRequestHint': 'Sugira um novo recurso',
'settings.about.wikiHint': 'Documentação e guias', 'settings.about.wikiHint': 'Documentação e guias',
'settings.about.supporters.badge': 'Apoiadores Mensais',
'settings.about.supporters.title': 'Companheiros de viagem do TREK',
'settings.about.supporters.subtitle': 'Enquanto você planeja sua próxima rota, essas pessoas planejam junto o futuro do TREK. A contribuição mensal delas vai direto para o desenvolvimento e horas reais investidas — para o TREK continuar Open Source.',
'settings.about.supporters.since': 'apoiador desde {date}',
'settings.about.supporters.tierEmpty': 'Seja o primeiro',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK é um planejador de viagens auto-hospedado que ajuda você a organizar suas viagens da primeira ideia à última lembrança. Planejamento diário, orçamento, listas de bagagem, fotos e muito mais — tudo em um só lugar, no seu próprio servidor.', 'settings.about.description': 'TREK é um planejador de viagens auto-hospedado que ajuda você a organizar suas viagens da primeira ideia à última lembrança. Planejamento diário, orçamento, listas de bagagem, fotos e muito mais — tudo em um só lugar, no seu próprio servidor.',
'settings.about.madeWith': 'Feito com', 'settings.about.madeWith': 'Feito com',
'settings.about.madeBy': 'por Maurice e uma crescente comunidade open-source.', 'settings.about.madeBy': 'por Maurice e uma crescente comunidade open-source.',
@@ -461,28 +427,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'login.oidcFailed': 'Falha no login OIDC', 'login.oidcFailed': 'Falha no login OIDC',
'login.usernameRequired': 'Nome de usuário é obrigatório', 'login.usernameRequired': 'Nome de usuário é obrigatório',
'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres', '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
'register.passwordMismatch': 'As senhas não coincidem', 'register.passwordMismatch': 'As senhas não coincidem',
@@ -602,12 +546,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'Configurações de tipos de arquivo salvas', 'admin.fileTypesSaved': 'Configurações de tipos de arquivo salvas',
// Packing Templates & Bag Tracking // Packing Templates & Bag Tracking
'admin.placesPhotos.title': 'Fotos de Locais',
'admin.placesPhotos.subtitle': 'Busca fotos da Google Places API. Desative para economizar cota da API. Fotos do Wikimedia não são afetadas.',
'admin.placesAutocomplete.title': 'Autocompletar de Locais',
'admin.placesAutocomplete.subtitle': 'Usa a Google Places API para sugestões de pesquisa. Desative para economizar cota da API.',
'admin.placesDetails.title': 'Detalhes do Local',
'admin.placesDetails.subtitle': 'Busca informações detalhadas do local (horários, avaliação, site) da Google Places API. Desative para economizar cota da API.',
'admin.bagTracking.title': 'Rastreamento de malas', 'admin.bagTracking.title': 'Rastreamento de malas',
'admin.bagTracking.subtitle': 'Ativar peso e atribuição de mala para itens da lista', 'admin.bagTracking.subtitle': 'Ativar peso e atribuição de mala para itens da lista',
'admin.collab.chat.title': 'Chat', 'admin.collab.chat.title': 'Chat',
@@ -883,7 +821,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
// Trip Planner // Trip Planner
'trip.tabs.plan': 'Plano', 'trip.tabs.plan': 'Plano',
'trip.tabs.transports': 'Transportes',
'trip.tabs.reservations': 'Reservas', 'trip.tabs.reservations': 'Reservas',
'trip.tabs.reservationsShort': 'Reservas', 'trip.tabs.reservationsShort': 'Reservas',
'trip.tabs.packing': 'Lista de mala', 'trip.tabs.packing': 'Lista de mala',
@@ -905,8 +842,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'trip.toast.reservationAdded': 'Reserva adicionada', 'trip.toast.reservationAdded': 'Reserva adicionada',
'trip.toast.deleted': 'Excluído', 'trip.toast.deleted': 'Excluído',
'trip.confirm.deletePlace': 'Tem certeza de que deseja excluir este lugar?', 'trip.confirm.deletePlace': 'Tem certeza de que deseja excluir este lugar?',
'trip.confirm.deletePlaces': 'Excluir {count} lugares?',
'trip.toast.placesDeleted': '{count} lugares excluídos',
'trip.loadingPhotos': 'Carregando fotos dos lugares...', 'trip.loadingPhotos': 'Carregando fotos dos lugares...',
// Day Plan Sidebar // Day Plan Sidebar
@@ -952,17 +887,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'places.importFileError': 'Importação falhou', 'places.importFileError': 'Importação falhou',
'places.importAllSkipped': 'Todos os lugares já estavam na viagem.', 'places.importAllSkipped': 'Todos os lugares já estavam na viagem.',
'places.gpxImported': '{count} lugares importados do GPX', 'places.gpxImported': '{count} lugares importados do GPX',
'places.gpxImportTypes': 'O que deseja importar?',
'places.gpxImportWaypoints': 'Pontos de caminho',
'places.gpxImportRoutes': 'Rotas',
'places.gpxImportTracks': 'Trilhas (com geometria de percurso)',
'places.gpxImportNoneSelected': 'Selecione pelo menos um tipo para importar.',
'places.kmlImportTypes': 'O que deseja importar?',
'places.kmlImportPoints': 'Pontos (Placemarks)',
'places.kmlImportPaths': 'Caminhos (LineStrings)',
'places.kmlImportNoneSelected': 'Selecione pelo menos um tipo.',
'places.selectionCount': '{count} selecionado(s)',
'places.deleteSelected': 'Excluir seleção',
'places.kmlKmzImported': '{count} lugares importados de KMZ/KML', 'places.kmlKmzImported': '{count} lugares importados de KMZ/KML',
'places.urlResolved': 'Lugar importado da URL', 'places.urlResolved': 'Lugar importado da URL',
'places.importList': 'Importar lista', 'places.importList': 'Importar lista',
@@ -979,7 +903,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'places.assignToDay': 'Adicionar a qual dia?', 'places.assignToDay': 'Adicionar a qual dia?',
'places.all': 'Todos', 'places.all': 'Todos',
'places.unplanned': 'Não planejados', 'places.unplanned': 'Não planejados',
'places.filterTracks': 'Trilhas',
'places.search': 'Buscar lugares...', 'places.search': 'Buscar lugares...',
'places.allCategories': 'Todas as categorias', 'places.allCategories': 'Todas as categorias',
'places.categoriesSelected': 'categorias', 'places.categoriesSelected': 'categorias',
@@ -1063,15 +986,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.flightNumber': 'Nº do voo', 'reservations.meta.flightNumber': 'Nº do voo',
'reservations.meta.from': 'De', 'reservations.meta.from': 'De',
'reservations.meta.to': 'Para', 'reservations.meta.to': 'Para',
'reservations.needsReview': 'Verificar',
'reservations.needsReviewHint': 'Aeroporto não pôde ser identificado automaticamente — confirme o local.',
'reservations.searchLocation': 'Buscar estação, porto, endereço...',
'airport.searchPlaceholder': 'Código ou cidade do aeroporto (ex. FRA)',
'map.connections': 'Conexões',
'map.showConnections': 'Mostrar rotas de reservas',
'map.hideConnections': 'Ocultar rotas de reservas',
'settings.bookingLabels': 'Rótulos das rotas de reservas',
'settings.bookingLabelsHint': 'Mostra nomes de estações / aeroportos no mapa. Desativado, apenas o ícone aparece.',
'reservations.meta.trainNumber': 'Nº do trem', 'reservations.meta.trainNumber': 'Nº do trem',
'reservations.meta.platform': 'Plataforma', 'reservations.meta.platform': 'Plataforma',
'reservations.meta.seat': 'Assento', 'reservations.meta.seat': 'Assento',
@@ -1090,7 +1004,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.hotel': 'Hospedagem', 'reservations.type.hotel': 'Hospedagem',
'reservations.type.restaurant': 'Restaurante', 'reservations.type.restaurant': 'Restaurante',
'reservations.type.train': 'Trem', 'reservations.type.train': 'Trem',
'reservations.type.car': 'Carro', 'reservations.type.car': 'Carro alugado',
'reservations.type.cruise': 'Cruzeiro', 'reservations.type.cruise': 'Cruzeiro',
'reservations.type.event': 'Evento', 'reservations.type.event': 'Evento',
'reservations.type.tour': 'Passeio', 'reservations.type.tour': 'Passeio',
@@ -1151,7 +1065,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'reservations.span.end': 'Fim', 'reservations.span.end': 'Fim',
'reservations.span.ongoing': 'Em andamento', 'reservations.span.ongoing': 'Em andamento',
'reservations.validation.endBeforeStart': 'A data/hora final deve ser posterior à data/hora inicial', 'reservations.validation.endBeforeStart': 'A data/hora final deve ser posterior à data/hora inicial',
'reservations.addBooking': 'Adicionar reserva',
// Budget // Budget
'budget.title': 'Orçamento', 'budget.title': 'Orçamento',
@@ -1193,8 +1106,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'Arquivos', 'files.title': 'Arquivos',
'files.pageTitle': 'Arquivos e documentos', 'files.pageTitle': 'Arquivos e documentos',
'files.subtitle': '{count} arquivos para {trip}', 'files.subtitle': '{count} arquivos para {trip}',
'files.download': 'Baixar',
'files.openError': 'Não foi possível abrir o arquivo',
'files.downloadPdf': 'Baixar PDF', 'files.downloadPdf': 'Baixar PDF',
'files.count': '{count} arquivos', 'files.count': '{count} arquivos',
'files.countSingular': '1 arquivo', 'files.countSingular': '1 arquivo',
@@ -1218,7 +1129,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Falha ao excluir arquivo', 'files.toast.deleteError': 'Falha ao excluir arquivo',
'files.sourcePlan': 'Plano do dia', 'files.sourcePlan': 'Plano do dia',
'files.sourceBooking': 'Reserva', 'files.sourceBooking': 'Reserva',
'files.sourceTransport': 'Transporte',
'files.attach': 'Anexar', 'files.attach': 'Anexar',
'files.pasteHint': 'Você também pode colar imagens da área de transferência (Ctrl+V)', 'files.pasteHint': 'Você também pode colar imagens da área de transferência (Ctrl+V)',
'files.trash': 'Lixeira', 'files.trash': 'Lixeira',
@@ -1231,7 +1141,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Atribuir arquivo', 'files.assignTitle': 'Atribuir arquivo',
'files.assignPlace': 'Lugar', 'files.assignPlace': 'Lugar',
'files.assignBooking': 'Reserva', 'files.assignBooking': 'Reserva',
'files.assignTransport': 'Transporte',
'files.unassigned': 'Não atribuído', 'files.unassigned': 'Não atribuído',
'files.unlink': 'Remover vínculo', 'files.unlink': 'Remover vínculo',
'files.toast.trashed': 'Movido para a lixeira', 'files.toast.trashed': 'Movido para a lixeira',
@@ -1663,10 +1572,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'memories.providerPassword': 'Senha', 'memories.providerPassword': 'Senha',
'memories.providerOTP': 'Código MFA (se habilitado)', 'memories.providerOTP': 'Código MFA (se habilitado)',
'memories.skipSSLVerification': 'Pular verificação de certificado SSL', 'memories.skipSSLVerification': 'Pular verificação de certificado SSL',
'memories.immichAutoUpload': 'Espelhar fotos da jornada no Immich ao enviar',
'memories.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo', 'memories.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo',
'memories.testConnection': 'Testar conexão', 'memories.testConnection': 'Testar conexão',
'memories.testShort': 'Testar',
'memories.testFirst': 'Teste a conexão primeiro', 'memories.testFirst': 'Teste a conexão primeiro',
'memories.connected': 'Conectado', 'memories.connected': 'Conectado',
'memories.disconnected': 'Não conectado', 'memories.disconnected': 'Não conectado',
@@ -1782,7 +1689,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'undo.reorder': 'Locais reordenados', 'undo.reorder': 'Locais reordenados',
'undo.optimize': 'Rota otimizada', 'undo.optimize': 'Rota otimizada',
'undo.deletePlace': 'Local excluído', 'undo.deletePlace': 'Local excluído',
'undo.deletePlaces': 'Lugares excluídos',
'undo.moveDay': 'Local movido para outro dia', 'undo.moveDay': 'Local movido para outro dia',
'undo.lock': 'Bloqueio do local alternado', 'undo.lock': 'Bloqueio do local alternado',
'undo.importGpx': 'Importação de GPX', 'undo.importGpx': 'Importação de GPX',
@@ -1842,11 +1748,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'Não atribuído', 'todo.unassigned': 'Não atribuído',
'todo.noCategory': 'Sem categoria', 'todo.noCategory': 'Sem categoria',
'todo.hasDescription': 'Com descrição', 'todo.hasDescription': 'Com descrição',
'todo.addItem': 'Nova tarefa', 'todo.addItem': 'Adicionar nova tarefa...',
'todo.sidebar.sortBy': 'Ordenar por',
'todo.priority': 'Prioridade',
'todo.newCategoryLabel': 'nova',
'budget.categoriesLabel': 'categorias',
'todo.newCategory': 'Nome da categoria', 'todo.newCategory': 'Nome da categoria',
'todo.addCategory': 'Adicionar categoria', 'todo.addCategory': 'Adicionar categoria',
'todo.newItem': 'Nova tarefa', 'todo.newItem': 'Nova tarefa',
@@ -1943,8 +1845,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} atualizou uma reserva em {trip}', 'notif.booking_change.text': '{actor} atualizou uma reserva em {trip}',
'notif.trip_reminder.title': 'Lembrete de viagem', 'notif.trip_reminder.title': 'Lembrete de viagem',
'notif.trip_reminder.text': 'Sua viagem {trip} está chegando!', 'notif.trip_reminder.text': 'Sua viagem {trip} está chegando!',
'notif.todo_due.title': 'Tarefa com vencimento',
'notif.todo_due.text': '{todo} em {trip} vence em {due}',
'notif.vacay_invite.title': 'Convite Vacay Fusion', 'notif.vacay_invite.title': 'Convite Vacay Fusion',
'notif.vacay_invite.text': '{actor} convidou você para fundir planos de férias', 'notif.vacay_invite.text': '{actor} convidou você para fundir planos de férias',
'notif.photos_shared.title': 'Fotos compartilhadas', 'notif.photos_shared.title': 'Fotos compartilhadas',
@@ -2076,7 +1976,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.verdict.couldBeBetter': 'Poderia ser melhor', 'journey.verdict.couldBeBetter': 'Poderia ser melhor',
'journey.synced.places': 'lugares', 'journey.synced.places': 'lugares',
'journey.synced.synced': 'sincronizado', 'journey.synced.synced': 'sincronizado',
'journey.editor.discardChangesConfirm': 'Você tem alterações não salvas. Descartá-las?',
'journey.editor.uploadPhotos': 'Enviar fotos', 'journey.editor.uploadPhotos': 'Enviar fotos',
'journey.editor.uploading': 'Enviando...', 'journey.editor.uploading': 'Enviando...',
'journey.editor.fromGallery': 'Da galeria', 'journey.editor.fromGallery': 'Da galeria',
@@ -2245,7 +2144,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'oauth.scope.group.vacay': 'Férias', 'oauth.scope.group.vacay': 'Férias',
'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.geo': 'Geo',
'oauth.scope.group.weather': 'Clima', 'oauth.scope.group.weather': 'Clima',
'oauth.scope.group.journey': 'Jornada',
// OAuth scope labels & descriptions // OAuth scope labels & descriptions
'oauth.scope.trips:read.label': 'Ver viagens e itinerários', 'oauth.scope.trips:read.label': 'Ver viagens e itinerários',
@@ -2296,12 +2194,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.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.label': 'Previsão do tempo',
'oauth.scope.weather:read.description': 'Obter previsão do tempo para locais e datas da viagem', '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 notices
'system_notice.welcome_v1.title': 'Bem-vindo ao TREK', 'system_notice.welcome_v1.title': 'Bem-vindo ao TREK',
@@ -2346,11 +2238,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
// System notices — personal thank you // System notices — personal thank you
'system_notice.v3_thankyou.title': 'Uma nota pessoal minha', 'system_notice.v3_thankyou.title': 'Uma nota pessoal minha',
'system_notice.v3_thankyou.body': 'Antes de seguir em frente — quero fazer uma pausa.\n\nO TREK começou como um projeto paralelo que criei para minhas próprias viagens. Nunca imaginei que cresceria a ponto de 4.000 de vocês confiarem nele para planejar suas aventuras. Cada estrela, cada issue, cada pedido de recurso — eu leio todos, e eles me mantêm firme nas noites longas entre um trabalho em tempo integral e a universidade.\n\nQuero que saibam: o TREK sempre será open source, sempre self-hosted, sempre de vocês. Sem rastreamento, sem assinaturas, sem pegadinhas. Apenas uma ferramenta feita por alguém que ama viajar tanto quanto vocês.\n\nAgradecimento especial ao [jubnl](https://github.com/jubnl) — você se tornou um colaborador incrível. Muito do que torna a versão 3.0 especial tem a sua marca. Obrigado por acreditar neste projeto quando ele ainda era bem cru.\n\nE a cada um de vocês que reportou um bug, traduziu uma string, compartilhou o TREK com um amigo ou simplesmente o usou para planejar uma viagem — **obrigado**. Vocês são a razão de tudo isso existir.\n\nQue venham muitas mais aventuras juntos.\n\n— Maurice\n\n---\n\n[Junte-se à comunidade no Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe o TREK torna suas viagens melhores, um [cafezinho](https://ko-fi.com/mauriceboe) sempre mantém as luzes acesas.', 'system_notice.v3_thankyou.body': 'Antes de seguir em frente — quero fazer uma pausa.\n\nO TREK começou como um projeto paralelo que criei para minhas próprias viagens. Nunca imaginei que cresceria a ponto de 4.000 de vocês confiarem nele para planejar suas aventuras. Cada estrela, cada issue, cada pedido de recurso — eu leio todos, e eles me mantêm firme nas noites longas entre um trabalho em tempo integral e a universidade.\n\nQuero que saibam: o TREK sempre será open source, sempre self-hosted, sempre de vocês. Sem rastreamento, sem assinaturas, sem pegadinhas. Apenas uma ferramenta feita por alguém que ama viajar tanto quanto vocês.\n\nAgradecimento especial ao [jubnl](https://github.com/jubnl) — você se tornou um colaborador incrível. Muito do que torna a versão 3.0 especial tem a sua marca. Obrigado por acreditar neste projeto quando ele ainda era bem cru.\n\nE a cada um de vocês que reportou um bug, traduziu uma string, compartilhou o TREK com um amigo ou simplesmente o usou para planejar uma viagem — **obrigado**. Vocês são a razão de tudo isso existir.\n\nQue venham muitas mais aventuras juntos.\n\n— Maurice\n\n---\n\n[Junte-se à comunidade no Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe o TREK torna suas viagens melhores, um [cafezinho](https://ko-fi.com/mauriceboe) sempre mantém as luzes acesas.',
'transport.addTransport': 'Adicionar transporte',
'transport.modalTitle.create': 'Adicionar transporte',
'transport.modalTitle.edit': 'Editar transporte',
'transport.title': 'Transportes',
'transport.addManual': 'Transporte Manual',
} }
export default br export default br

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