Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88d980c657 | |||
| 3f489880da | |||
| 45fa6fd0d3 | |||
| a8c27f9d4a | |||
| 288d33ba42 | |||
| e7fb78dc1e | |||
| 4d3bf390a5 | |||
| 001b2365a1 | |||
| 7d5dadc441 | |||
| c912ad4b01 | |||
| bd6cd55a13 | |||
| 757764d046 | |||
| 94e64acc34 | |||
| 70ba24bfe1 | |||
| 32f431e879 | |||
| 906d8821a4 | |||
| 82b16a4bf5 | |||
| 069269e69c | |||
| 534149ba22 | |||
| 2dd6e04b44 | |||
| 0e3d9f6ddc | |||
| 3b7442c2d5 | |||
| 78b45d7c19 | |||
| 9e5100c71c | |||
| fccf13a7e2 | |||
| 09431f725c | |||
| 13162c0920 | |||
| e25b513d0b | |||
| 9012bffabc | |||
| 24a85b0f91 | |||
| 43a503b593 | |||
| a81fe3da0a | |||
| 70ba4d5435 | |||
| 881b9d0939 | |||
| 758de855bf | |||
| 9652874bbd | |||
| 840f5e82aa | |||
| d59b3334dc | |||
| 5a64d8994e | |||
| e6222894e9 | |||
| 9d48c06068 | |||
| 9f70b56a3a | |||
| 232dc78cc9 | |||
| d2c44380a4 | |||
| 2f9d7adf4a | |||
| ba4a64241b | |||
| ee14f706c8 | |||
| 1cc43f63df | |||
| 3450bd59f8 | |||
| 457d436cf6 | |||
| 1127efb9c4 | |||
| 0a98d3c2e7 | |||
| 5eaf7492dc | |||
| ee31c78db8 | |||
| edf14e2ebc | |||
| 2aad8f465c | |||
| 16b81a8356 | |||
| 5984adb2ea | |||
| f8eb1915fe | |||
| b556c636eb | |||
| b20db1428d | |||
| 4a5a59cb78 | |||
| 20bf9c2312 | |||
| 9f57ab4517 | |||
| 292e443dbe | |||
| 2d0414b4a3 | |||
| e612de9143 | |||
| c857d38bcd | |||
| d7a71c0572 | |||
| 58c061e653 | |||
| 22d1d06d39 | |||
| 290f566daa | |||
| 8ca2507050 | |||
| 9c666a0aaf | |||
| b3f2f7308a | |||
| af9b31c1ff | |||
| d7d1493289 | |||
| 54e042b736 | |||
| 0ba31847eb | |||
| 26ab39dc21 | |||
| 00be0eab05 | |||
| ed97bb1deb | |||
| 51387b0af1 | |||
| 1559ed12bd | |||
| c1b9d11173 |
@@ -30,3 +30,7 @@ sonar-project.properties
|
||||
server/tests/
|
||||
server/vitest.config.ts
|
||||
server/reset-admin.js
|
||||
**/*.test.ts
|
||||
wiki/
|
||||
scripts/
|
||||
charts/
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Normalize line endings to LF on commit
|
||||
* text=auto eol=lf
|
||||
|
||||
# Explicitly enforce LF for source files
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
@@ -14,7 +13,6 @@
|
||||
*.yaml text eol=lf
|
||||
*.py text eol=lf
|
||||
*.sh text eol=lf
|
||||
|
||||
# Binary files — no line ending conversion
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
|
||||
@@ -12,6 +12,8 @@ body:
|
||||
required: true
|
||||
- label: I am running the latest available version of TREK
|
||||
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
|
||||
id: version
|
||||
|
||||
@@ -6,6 +6,8 @@ on:
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- 'wiki/**'
|
||||
- '.github/workflows/wiki.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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: init
|
||||
@@ -58,4 +58,7 @@ coverage
|
||||
*.tgz
|
||||
|
||||
.scannerwork
|
||||
test-data
|
||||
test-data
|
||||
|
||||
.run
|
||||
.full-review
|
||||
@@ -4,7 +4,7 @@ Thanks for your interest in contributing! Please read these guidelines before op
|
||||
|
||||
## 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/P7TUxHJs). 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/NhZBDSd4qW). 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
|
||||
3. **No breaking changes** — Backwards compatibility is non-negotiable
|
||||
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
|
||||
|
||||
@@ -1,121 +1,164 @@
|
||||
<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>
|
||||
<div align="center">
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/NhZBDSd4qW"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/TREK" alt="Last Commit" /></a>
|
||||
</p>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.gif" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" />
|
||||
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
|
||||
</picture>
|
||||
|
||||
<p align="center">
|
||||
A 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>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.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="Live Demo" src="https://img.shields.io/badge/Live_Demo-try_it_now-111827?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge&logo=docker&logoColor=white" /></a>
|
||||
|
||||
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-community-5865F2?style=for-the-badge&logo=discord&logoColor=white" /></a>
|
||||
|
||||
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-board-0EA5E9?style=for-the-badge&logo=trello&logoColor=white" /></a>
|
||||
<br />
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://github.com/mauriceboe/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>
|
||||
<summary>More Screenshots</summary>
|
||||
<summary><b>See all features</b></summary>
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  | |
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧭 Trip planning
|
||||
|
||||
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
||||
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
||||
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
||||
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
||||
- **Route optimisation** — auto-sort places and export to Google Maps
|
||||
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
||||
- **Category filter** — show only matching pins on the map
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧳 Travel management
|
||||
|
||||
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files
|
||||
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
|
||||
- **Packing lists** — categories, templates, user assignment, progress tracking
|
||||
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
||||
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
||||
- **PDF export** — full trip plan as PDF with cover page, images, notes
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 👥 Collaboration
|
||||
|
||||
- **Real-time sync** — WebSocket. Changes appear instantly across all connected users
|
||||
- **Multi-user trips** — invite members with role-based access
|
||||
- **Invite links** — one-time or reusable links with expiry
|
||||
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||
- **2FA** — TOTP + backup codes
|
||||
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 📱 Mobile & PWA
|
||||
|
||||
- **Installable** — iOS and Android, straight from the browser, no App Store needed
|
||||
- **Offline support** — Service Worker caches tiles, API, uploads via Workbox
|
||||
- **Native feel** — fullscreen standalone, themed status bar, splash screen
|
||||
- **Touch optimised** — mobile-specific layouts with safe-area handling
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧩 Addons (admin-toggleable)
|
||||
|
||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||
- **Journey** — magazine-style travel journal with entries, photos, maps, moods
|
||||
- **Dashboard widgets** — currency converter and timezone clocks
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🤖 AI / MCP
|
||||
|
||||
- **Built-in MCP server** — OAuth 2.1 authenticated. 80+ tools, 27 resources
|
||||
- **Granular scopes** — 24 OAuth scopes across 13 permission groups
|
||||
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
|
||||
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
|
||||
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
|
||||
#### ⚙️ Admin & customisation
|
||||
|
||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||
- **14 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
## Features
|
||||
<br />
|
||||
|
||||
### 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
|
||||
## Get started in 30 seconds
|
||||
|
||||
```bash
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
@@ -123,19 +166,40 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||
```
|
||||
|
||||
The app runs on port `3000`. The first user to register becomes the admin.
|
||||
Open `http://localhost:3000`. The first user to register becomes admin.
|
||||
|
||||
### Install as App (PWA)
|
||||
<div align="center">
|
||||
|
||||
TREK works as a Progressive Web App — no App Store needed:
|
||||
· <a href="#docker-compose-production">Docker Compose</a> · <a href="#helm-kubernetes">Helm / Kubernetes</a> · <a href="#install-as-app-pwa">Install as PWA</a> · <a href="#reverse-proxy">Reverse Proxy</a> ·
|
||||
|
||||
1. Open your TREK instance in the browser (HTTPS required)
|
||||
2. **iOS**: Share button → "Add to Home Screen"
|
||||
3. **Android**: Menu → "Install app" or "Add to Home Screen"
|
||||
4. TREK launches fullscreen with its own icon, just like a native app
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
## Tech stack
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</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>
|
||||
<summary>Docker Compose (recommended for production)</summary>
|
||||
<summary>Full compose example with secure defaults</summary>
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -158,30 +222,19 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
|
||||
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
||||
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
||||
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
|
||||
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
|
||||
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
|
||||
- APP_URL=${APP_URL:-} # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP; Also used as the base URL for email notifications and other external links
|
||||
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
|
||||
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
|
||||
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
|
||||
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
|
||||
# - OIDC_ONLY=false # Set to true to force SSO-only login (disables password login and registration). Equivalent to toggling those off in Admin > Settings, but takes priority over any DB setting and cannot be changed at runtime.
|
||||
# - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
|
||||
# - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role
|
||||
# - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM)
|
||||
# - OIDC_DISCOVERY_URL= # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik)
|
||||
# - DEMO_MODE=false # Enable demo mode (resets data hourly)
|
||||
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
|
||||
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
|
||||
# - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
|
||||
# - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # generate with: openssl rand -hex 32
|
||||
- TZ=${TZ:-UTC}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
|
||||
- APP_URL=${APP_URL:-} # required for OIDC + email links
|
||||
# - FORCE_HTTPS=true # behind a TLS-terminating proxy
|
||||
# - TRUST_PROXY=1
|
||||
# - OIDC_ISSUER=https://auth.example.com
|
||||
# - OIDC_CLIENT_ID=trek
|
||||
# - OIDC_CLIENT_SECRET=supersecret
|
||||
# - OIDC_DISPLAY_NAME=SSO
|
||||
# - OIDC_ADMIN_CLAIM=groups
|
||||
# - OIDC_ADMIN_VALUE=app-trek-admins
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
@@ -194,29 +247,49 @@ services:
|
||||
start_period: 15s
|
||||
```
|
||||
|
||||
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.
|
||||
Then:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||
|
||||
</details>
|
||||
|
||||
### Updating
|
||||
<br />
|
||||
|
||||
**Docker Compose** (recommended):
|
||||
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
|
||||
|
||||
```bash
|
||||
helm repo add trek https://mauriceboe.github.io/TREK
|
||||
helm repo update
|
||||
helm install trek trek/trek
|
||||
```
|
||||
|
||||
See [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for values.
|
||||
|
||||
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
|
||||
|
||||
TREK works as a Progressive Web App — no App Store needed.
|
||||
|
||||
1. Open TREK in the browser (HTTPS required)
|
||||
2. **iOS**: Share ▸ *Add to Home Screen*
|
||||
3. **Android**: Menu ▸ *Install app* (or *Add to Home Screen*)
|
||||
|
||||
TREK then launches fullscreen with its own icon, just like a native app.
|
||||
|
||||
<br />
|
||||
|
||||
## Updating
|
||||
|
||||
**Docker Compose:**
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
**Docker Run** — use the same volume paths from your original `docker run` command:
|
||||
**Docker run** — reuse the original volume paths:
|
||||
|
||||
```bash
|
||||
docker pull mauriceboe/trek
|
||||
@@ -224,27 +297,23 @@ 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
|
||||
```
|
||||
|
||||
> **Tip:** Not sure which paths you used? Run `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
|
||||
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
|
||||
|
||||
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
|
||||
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
||||
|
||||
### Rotating the Encryption Key
|
||||
<h3>Rotating the Encryption Key</h3>
|
||||
|
||||
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:
|
||||
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
||||
|
||||
```bash
|
||||
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
|
||||
```
|
||||
|
||||
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.
|
||||
The script creates a timestamped DB backup before making changes and prompts for old + new keys (input is not echoed).
|
||||
|
||||
**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".
|
||||
<h2 id="reverse-proxy">Reverse Proxy</h2>
|
||||
|
||||
### 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.
|
||||
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`.
|
||||
|
||||
<details>
|
||||
<summary>Nginx</summary>
|
||||
@@ -260,8 +329,19 @@ server {
|
||||
listen 443 ssl http2;
|
||||
server_name trek.yourdomain.com;
|
||||
|
||||
ssl_certificate /path/to/fullchain.pem;
|
||||
ssl_certificate_key /path/to/privkey.pem;
|
||||
ssl_certificate /etc/ssl/fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/privkey.pem;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://localhost:3000;
|
||||
@@ -269,21 +349,6 @@ server {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400;
|
||||
# File uploads are capped at 50 MB; backup restore ZIPs can include the full
|
||||
# uploads directory and may exceed that — raise this value if restores fail.
|
||||
client_max_body_size 500m;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -293,17 +358,24 @@ server {
|
||||
<details>
|
||||
<summary>Caddy</summary>
|
||||
|
||||
Caddy handles WebSocket upgrades automatically:
|
||||
|
||||
```
|
||||
```caddy
|
||||
trek.yourdomain.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
Caddy handles TLS and WebSockets automatically.
|
||||
|
||||
</details>
|
||||
|
||||
## Environment Variables
|
||||
<br />
|
||||
|
||||
## Environment variables
|
||||
|
||||
<details>
|
||||
<summary><b>Full reference</b></summary>
|
||||
|
||||
<br />
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
@@ -313,58 +385,46 @@ trek.yourdomain.com {
|
||||
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||
| `DEFAULT_LANGUAGE` | Default language 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` |
|
||||
| `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` |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Only useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY` to be set so Express can detect the forwarded protocol. | `false` |
|
||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: secure is on when `NODE_ENV=production` **or** `FORCE_HTTPS=true`. Set to `false` as an escape hatch to allow session cookies over plain HTTP (e.g. LAN testing without TLS). **Not recommended to disable in production.** | auto (`true` in production) |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Activates automatically in production (defaults to `1`); off in development unless explicitly set. Must be set for `FORCE_HTTPS` redirects to work correctly. | `1` (when active) |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
|
||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — |
|
||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
||||
| **OIDC / SSO** | | |
|
||||
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
|
||||
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
||||
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
|
||||
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
|
||||
| `OIDC_ONLY` | Force SSO-only mode: disables password login 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_ONLY` | Force SSO-only mode: disables password login + registration, regardless of Admin > Settings. The first SSO login becomes admin. | `false` |
|
||||
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
|
||||
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
|
||||
| `OIDC_SCOPE` | Space-separated OIDC scopes to request. **Fully replaces** the default — always include `openid email profile` plus any extra scopes you need (e.g. add `groups` when using `OIDC_ADMIN_CLAIM`) | `openid email profile` |
|
||||
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint. Useful for providers that expose it at a non-standard path (e.g. Authentik: `https://auth.example.com/application/o/trek/.well-known/openid-configuration`) | — |
|
||||
| **Initial Setup** | | |
|
||||
| `ADMIN_EMAIL` | Email for the first admin account created on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is generated and printed to the server log. Has no effect once any user exists. | `admin@trek.local` |
|
||||
| `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random |
|
||||
| `OIDC_SCOPE` | Space-separated OIDC scopes. **Fully replaces** the default — always include `openid email profile`. | `openid email profile` |
|
||||
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — |
|
||||
| **Initial setup** | | |
|
||||
| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` |
|
||||
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
|
||||
| **Other** | | |
|
||||
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
|
||||
|
||||
## Optional API Keys
|
||||
</details>
|
||||
|
||||
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 .
|
||||
```
|
||||
<br />
|
||||
|
||||
## Data & Backups
|
||||
|
||||
- **Database**: SQLite, stored in `./data/travel.db`
|
||||
- **Uploads**: Stored in `./uploads/`
|
||||
- **Logs**: `./data/logs/trek.log` (auto-rotated)
|
||||
- **Backups**: Create and restore via Admin Panel
|
||||
- **Auto-Backups**: Configurable schedule and retention in Admin Panel
|
||||
- **Database** — SQLite, stored in `./data/travel.db`
|
||||
- **Uploads** — stored in `./uploads/`
|
||||
- **Logs** — `./data/logs/trek.log` (auto-rotated)
|
||||
- **Backups** — create and restore via Admin Panel
|
||||
- **Auto-Backups** — configurable schedule and retention in Admin Panel
|
||||
|
||||
<br />
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](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.
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import { useAddonStore } from './store/addonStore'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import TripPlannerPage from './pages/TripPlannerPage'
|
||||
import FilesPage from './pages/FilesPage'
|
||||
@@ -197,7 +199,10 @@ export default function App() {
|
||||
applyDark(mode === true || mode === 'dark')
|
||||
}, [settings.dark_mode, isSharedPage])
|
||||
|
||||
const isAuthPage = location.pathname.startsWith('/login') || location.pathname.startsWith('/register')
|
||||
const isAuthPage = location.pathname.startsWith('/login')
|
||||
|| location.pathname.startsWith('/register')
|
||||
|| location.pathname.startsWith('/forgot-password')
|
||||
|| location.pathname.startsWith('/reset-password')
|
||||
|
||||
return (
|
||||
<TranslationProvider>
|
||||
@@ -210,6 +215,8 @@ export default function App() {
|
||||
<Route path="/shared/:token" element={<SharedTripPage />} />
|
||||
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
|
||||
<Route path="/register" element={<LoginPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
||||
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
|
||||
<Route
|
||||
|
||||
@@ -62,13 +62,20 @@ apiClient.interceptors.request.use(
|
||||
(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
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/') && !window.location.pathname.startsWith('/public/')) {
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname)) {
|
||||
const currentPath = pathname + window.location.search
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
}
|
||||
}
|
||||
@@ -114,6 +121,8 @@ export const authApi = {
|
||||
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
|
||||
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
|
||||
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
||||
forgotPassword: (data: { email: string }) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
|
||||
resetPassword: (data: { token: string; new_password: string; mfa_code?: string }) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }),
|
||||
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
||||
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
||||
mcpTokens: {
|
||||
|
||||
@@ -900,29 +900,30 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<td style={{ ...td, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{canEdit && (
|
||||
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }}
|
||||
onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }}
|
||||
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}>
|
||||
<GripVertical size={12} />
|
||||
<td style={td}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{canEdit && (
|
||||
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }}
|
||||
onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }}
|
||||
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}>
|
||||
<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 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>
|
||||
</td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { downloadFile, openFile } from '../../utils/fileDownload'
|
||||
import { downloadFile, openFile as openFileUrl } from '../../utils/fileDownload'
|
||||
|
||||
function isImage(mimeType) {
|
||||
if (!mimeType) return false
|
||||
@@ -113,7 +113,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => openFile(file.url).catch(() => {})}
|
||||
onClick={() => openFileUrl(file.url, file.original_name).catch(() => {})}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
|
||||
title={t('files.openTab')}>
|
||||
<ExternalLink size={16} />
|
||||
@@ -649,8 +649,17 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
</div>
|
||||
{dayGroups.map(({ day, dayPlaces }) => (
|
||||
<div key={day.id}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
|
||||
{day.title || `${t('dayplan.dayN', { n: day.day_number })}${day.date ? ` · ${day.date}` : ''}`}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
|
||||
<span>{day.title || t('dayplan.dayN', { n: day.day_number })}</span>
|
||||
{(() => {
|
||||
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>
|
||||
{dayPlaces.map(placeBtn)}
|
||||
</div>
|
||||
@@ -743,7 +752,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>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => openFile(previewFile.url).catch(() => {})}
|
||||
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))}
|
||||
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)'}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||
@@ -771,7 +780,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
title={previewFile.original_name}
|
||||
>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<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>
|
||||
<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>
|
||||
</p>
|
||||
</object>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface MapMarkerItem {
|
||||
label: string
|
||||
mood?: string | null
|
||||
time: string
|
||||
dayColor: string
|
||||
dayLabel: number
|
||||
}
|
||||
|
||||
export interface JourneyMapHandle {
|
||||
@@ -24,6 +26,8 @@ interface MapEntry {
|
||||
title?: string | null
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
dayColor?: string
|
||||
dayLabel?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -49,6 +53,8 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
||||
label: e.title || 'Entry',
|
||||
mood: e.mood,
|
||||
time: e.entry_date,
|
||||
dayColor: e.dayColor || '#52525B',
|
||||
dayLabel: e.dayLabel ?? 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -59,30 +65,19 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
||||
const MARKER_W = 28
|
||||
const MARKER_H = 36
|
||||
|
||||
function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
|
||||
// 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')
|
||||
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string {
|
||||
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||
const shadow = highlighted
|
||||
? (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 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||
const label = String(index + 1)
|
||||
const label = String(dayLabel)
|
||||
const scale = highlighted ? 1.2 : 1
|
||||
|
||||
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">
|
||||
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/>
|
||||
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
||||
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||||
<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"/>
|
||||
<circle cx="14" cy="13" r="8" fill="${dayColor}"/>
|
||||
<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>
|
||||
</svg>
|
||||
</div>`
|
||||
}
|
||||
@@ -115,12 +110,11 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
const marker = markersRef.current.get(prev)
|
||||
const item = itemsRef.current.find(i => i.id === prev)
|
||||
if (marker && item) {
|
||||
const idx = itemsRef.current.indexOf(item)
|
||||
marker.setIcon(L.divIcon({
|
||||
className: '',
|
||||
iconSize: [MARKER_W, MARKER_H],
|
||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||
html: markerSvg(idx, false, isDark),
|
||||
html: markerSvg(item.dayColor, item.dayLabel, false),
|
||||
}))
|
||||
marker.setZIndexOffset(0)
|
||||
}
|
||||
@@ -130,12 +124,11 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
const marker = markersRef.current.get(id)
|
||||
const item = itemsRef.current.find(i => i.id === id)
|
||||
if (marker && item) {
|
||||
const idx = itemsRef.current.indexOf(item)
|
||||
marker.setIcon(L.divIcon({
|
||||
className: '',
|
||||
iconSize: [MARKER_W, MARKER_H],
|
||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||
html: markerSvg(idx, true, isDark),
|
||||
html: markerSvg(item.dayColor, item.dayLabel, true),
|
||||
}))
|
||||
marker.setZIndexOffset(1000)
|
||||
}
|
||||
@@ -226,7 +219,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
className: '',
|
||||
iconSize: [MARKER_W, MARKER_H],
|
||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||
html: markerSvg(i, false, !!dark),
|
||||
html: markerSvg(item.dayColor, item.dayLabel, false),
|
||||
})
|
||||
|
||||
const marker = L.marker(pos, { icon }).addTo(map)
|
||||
|
||||
@@ -14,6 +14,8 @@ interface MapEntry {
|
||||
location_name?: string | null
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
dayColor?: string
|
||||
dayLabel?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -18,6 +18,8 @@ interface MapEntry {
|
||||
location_name?: string | null
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
dayColor?: string
|
||||
dayLabel?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -39,6 +41,8 @@ interface Item {
|
||||
label: string
|
||||
locationName: string
|
||||
time: string
|
||||
dayColor: string
|
||||
dayLabel: number
|
||||
}
|
||||
|
||||
const MARKER_W = 28
|
||||
@@ -55,6 +59,8 @@ function buildItems(entries: MapEntry[]): Item[] {
|
||||
label: e.title || '',
|
||||
locationName: e.location_name || '',
|
||||
time: e.entry_date,
|
||||
dayColor: e.dayColor || '#52525B',
|
||||
dayLabel: e.dayLabel ?? 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -157,21 +163,15 @@ function ensureJourneyPopupStyle() {
|
||||
document.head.appendChild(s)
|
||||
}
|
||||
|
||||
function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDivElement {
|
||||
const fill = dark
|
||||
? (highlighted ? '#FAFAFA' : '#A1A1AA')
|
||||
: (highlighted ? '#18181B' : '#52525B')
|
||||
const textColor = highlighted ? (dark ? '#18181B' : '#fff') : '#fff'
|
||||
const stroke = highlighted
|
||||
? (dark ? '#fff' : '#18181B')
|
||||
: (dark ? '#3F3F46' : '#fff')
|
||||
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
|
||||
? (dark
|
||||
? 'drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||
: 'drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
|
||||
? 'drop-shadow(0 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(index + 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
|
||||
@@ -183,7 +183,7 @@ function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDiv
|
||||
inner.className = 'trek-journey-marker-inner'
|
||||
inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};`
|
||||
inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/>
|
||||
<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>`
|
||||
@@ -273,13 +273,12 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
const item = itemsRef.current.find(i => i.id === id)
|
||||
const marker = markersRef.current.get(id)
|
||||
if (!item || !marker) return
|
||||
const idx = itemsRef.current.indexOf(item)
|
||||
const el = marker.getElement()
|
||||
const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null
|
||||
if (!currentInner) return
|
||||
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
|
||||
// would wipe mapbox's positional transform and make the marker flicker.
|
||||
const next = markerHtml(idx, highlighted, !!darkRef.current)
|
||||
const 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
|
||||
@@ -382,8 +381,8 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
}
|
||||
|
||||
// markers
|
||||
items.forEach((item, i) => {
|
||||
const el = markerHtml(i, false, !!darkRef.current)
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
|
||||
const MOOD_ICONS: Record<string, typeof Smile> = {
|
||||
@@ -37,13 +38,14 @@ function stripMarkdown(text: string): string {
|
||||
|
||||
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 }
|
||||
index: number
|
||||
dayLabel: number
|
||||
dayColor: string
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
}
|
||||
|
||||
export default function MobileEntryCard({ entry, index, isActive, onClick, publicPhotoUrl }: Props) {
|
||||
export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, onClick, publicPhotoUrl }: Props) {
|
||||
const hasLocation = !!(entry.location_lat && entry.location_lng)
|
||||
const hasPhotos = entry.photos && entry.photos.length > 0
|
||||
const firstPhoto = hasPhotos ? entry.photos![0] : null
|
||||
@@ -98,8 +100,8 @@ export default function MobileEntryCard({ entry, index, isActive, onClick, publi
|
||||
<div className="flex-1 p-3 flex flex-col min-w-0">
|
||||
{/* Day number + date + mood/weather */}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<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">
|
||||
{index + 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 }}>
|
||||
{dayLabel}
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
|
||||
{entry.entry_time && (
|
||||
@@ -141,7 +143,7 @@ export default function MobileEntryCard({ entry, index, isActive, onClick, publi
|
||||
{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">
|
||||
<MapPin size={10} className="flex-shrink-0" />
|
||||
<span className="truncate">{entry.location_name || 'On the map'}</span>
|
||||
<span className="truncate">{formatLocationName(entry.location_name) || 'On the map'}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-zinc-400 italic">No location</span>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ThumbsUp, ThumbsDown, ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import JournalBody from './JournalBody'
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
|
||||
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
|
||||
@@ -130,7 +131,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
|
||||
<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">
|
||||
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
|
||||
{entry.location_name}
|
||||
{formatLocationName(entry.location_name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||
import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import JourneyMap from './JourneyMap'
|
||||
import MobileEntryCard from './MobileEntryCard'
|
||||
import type { JourneyMapHandle } from './JourneyMap'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
import { DAY_COLORS } from './dayColors'
|
||||
|
||||
interface MapEntry {
|
||||
id: string
|
||||
@@ -23,6 +24,7 @@ interface Props {
|
||||
onEntryClick: (entry: any) => void
|
||||
onAddEntry?: () => void
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
carouselBottom?: string
|
||||
}
|
||||
|
||||
export default function MobileMapTimeline({
|
||||
@@ -34,14 +36,23 @@ export default function MobileMapTimeline({
|
||||
onEntryClick,
|
||||
onAddEntry,
|
||||
publicPhotoUrl,
|
||||
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
|
||||
}: Props) {
|
||||
const mapRef = useRef<JourneyMapHandle>(null)
|
||||
const carouselRef = useRef<HTMLDivElement>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||
const activeIndexRef = useRef(activeIndex)
|
||||
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
|
||||
|
||||
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())
|
||||
// Sync map focus when carousel scrolls (with guard for uninitialized map)
|
||||
const syncMapToCarousel = useCallback((index: number) => {
|
||||
const entry = entries[index]
|
||||
@@ -76,29 +87,19 @@ export default function MobileMapTimeline({
|
||||
})
|
||||
}, [syncMapToCarousel])
|
||||
|
||||
// Track scroll; debounce to re-center the active card when the user stops.
|
||||
// Defer all state updates until scrolling settles — updating activeIndex
|
||||
// mid-swipe resizes cards (240→320px), causing layout reflow every frame.
|
||||
useEffect(() => {
|
||||
const el = carouselRef.current
|
||||
if (!el || entries.length === 0) return
|
||||
let rafId: number | null = null
|
||||
let settleTimer: number | null = null
|
||||
const onScroll = () => {
|
||||
if (rafId != null) return
|
||||
rafId = requestAnimationFrame(() => {
|
||||
pickNearestCard()
|
||||
rafId = null
|
||||
})
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||
settleTimer = window.setTimeout(() => {
|
||||
// Ensure the active card sits at the center once the user settles.
|
||||
const card = cardRefs.current.get(activeIndexRef.current)
|
||||
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||
}, 180)
|
||||
settleTimer = window.setTimeout(pickNearestCard, 150)
|
||||
}
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
if (rafId != null) cancelAnimationFrame(rafId)
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||
}
|
||||
}, [entries.length, pickNearestCard])
|
||||
@@ -142,7 +143,10 @@ export default function MobileMapTimeline({
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
|
||||
<div
|
||||
className="fixed left-0 right-0 z-10"
|
||||
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
>
|
||||
<JourneyMap
|
||||
ref={mapRef}
|
||||
entries={mapEntries}
|
||||
@@ -168,7 +172,10 @@ export default function MobileMapTimeline({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
|
||||
<div
|
||||
className="fixed left-0 right-0 z-10"
|
||||
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
>
|
||||
{/* Full-screen map */}
|
||||
<JourneyMap
|
||||
ref={mapRef}
|
||||
@@ -186,13 +193,13 @@ export default function MobileMapTimeline({
|
||||
{/* Bottom carousel */}
|
||||
<div
|
||||
className="fixed left-0 right-0 z-40"
|
||||
style={{ touchAction: 'pan-x', bottom: 'calc(var(--bottom-nav-h, 84px) + 8px)' }}
|
||||
style={{ touchAction: 'pan-x', bottom: carouselBottom }}
|
||||
>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
|
||||
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1"
|
||||
style={{
|
||||
scrollSnapType: 'x proximity',
|
||||
scrollSnapType: 'x mandatory',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
@@ -207,7 +214,8 @@ export default function MobileMapTimeline({
|
||||
>
|
||||
<MobileEntryCard
|
||||
entry={entry}
|
||||
index={i}
|
||||
dayLabel={entryDayMeta[i]?.dayLabel ?? i + 1}
|
||||
dayColor={entryDayMeta[i]?.dayColor ?? DAY_COLORS[0]}
|
||||
isActive={i === activeIndex}
|
||||
onClick={() => handleCardTap(entry, i)}
|
||||
publicPhotoUrl={publicPhotoUrl}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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
|
||||
]
|
||||
@@ -61,11 +61,25 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
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 = () => {
|
||||
document.documentElement.classList.add('trek-theme-transitioning')
|
||||
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
||||
window.setTimeout(() => {
|
||||
if (themeTransitionTimer.current !== null) window.clearTimeout(themeTransitionTimer.current)
|
||||
themeTransitionTimer.current = window.setTimeout(() => {
|
||||
document.documentElement.classList.remove('trek-theme-transitioning')
|
||||
themeTransitionTimer.current = null
|
||||
}, 360)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
/**
|
||||
* OfflineBanner — persistent top bar indicating connectivity + sync state.
|
||||
* OfflineBanner — connectivity + sync state indicator.
|
||||
*
|
||||
* States:
|
||||
* offline + N queued → amber bar "Offline — N changes queued"
|
||||
* offline + 0 queued → amber bar "Offline"
|
||||
* online + N pending → blue bar "Syncing N changes…"
|
||||
* offline + N queued → amber pill "Offline · N queued"
|
||||
* offline + 0 queued → amber pill "Offline"
|
||||
* online + N pending → blue pill "Syncing N…"
|
||||
* 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 { WifiOff, RefreshCw } from 'lucide-react'
|
||||
@@ -48,9 +52,9 @@ export default function OfflineBanner(): React.ReactElement | null {
|
||||
|
||||
const label = offline
|
||||
? pendingCount > 0
|
||||
? `Offline — ${pendingCount} change${pendingCount !== 1 ? 's' : ''} queued`
|
||||
? `Offline · ${pendingCount} queued`
|
||||
: 'Offline'
|
||||
: `Syncing ${pendingCount} change${pendingCount !== 1 ? 's' : ''}…`
|
||||
: `Syncing ${pendingCount}…`
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -58,27 +62,29 @@ export default function OfflineBanner(): React.ReactElement | null {
|
||||
aria-live="polite"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
// Hover above the mobile bottom nav; on desktop --bottom-nav-h is 0,
|
||||
// so the pill sits 16px from the bottom.
|
||||
bottom: 'calc(var(--bottom-nav-h) + 16px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 9999,
|
||||
background: bg,
|
||||
color: text,
|
||||
display: 'flex',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 6px)',
|
||||
paddingBottom: '6px',
|
||||
paddingLeft: '16px',
|
||||
paddingRight: '16px',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
gap: 6,
|
||||
padding: '6px 14px',
|
||||
borderRadius: 999,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.08)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{offline
|
||||
? <WifiOff size={14} />
|
||||
: <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
? <WifiOff size={12} />
|
||||
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
}
|
||||
{label}
|
||||
</div>
|
||||
|
||||
@@ -477,7 +477,11 @@ export const MapView = memo(function MapView({
|
||||
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
||||
|
||||
if (!cached && !isLoading(cacheKey)) {
|
||||
const photoId = place.image_url || place.google_place_id || place.osm_id
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '..
|
||||
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 } from '../../types'
|
||||
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']
|
||||
@@ -44,6 +45,10 @@ interface Props {
|
||||
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 {
|
||||
@@ -139,17 +144,28 @@ export function MapViewGL({
|
||||
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
|
||||
@@ -228,6 +244,10 @@ export function MapViewGL({
|
||||
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) => {
|
||||
@@ -299,12 +319,17 @@ export function MapViewGL({
|
||||
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
|
||||
|
||||
@@ -341,7 +366,11 @@ export function MapViewGL({
|
||||
}
|
||||
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
||||
if (!cached && !isLoading(cacheKey)) {
|
||||
const photoId = place.image_url || place.google_place_id || place.osm_id
|
||||
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)
|
||||
}
|
||||
@@ -434,6 +463,41 @@ export function MapViewGL({
|
||||
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
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
// 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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,12 +96,12 @@ async function fetchPlacePhotos(assignments) {
|
||||
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 toFetch = unique.filter(p => !p.image_url && p.google_place_id)
|
||||
const toFetch = unique.filter(p => !p.image_url && (p.google_place_id || p.osm_id))
|
||||
|
||||
await Promise.allSettled(
|
||||
toFetch.map(async (place) => {
|
||||
try {
|
||||
const data = await mapsApi.placePhoto(place.google_place_id)
|
||||
const data = await mapsApi.placePhoto(place.google_place_id || place.osm_id, place.lat, place.lng, place.name)
|
||||
if (data.photoUrl) photoMap[place.id] = data.photoUrl
|
||||
} catch {}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
|
||||
@@ -43,9 +44,9 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT
|
||||
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)
|
||||
window.location.reload()
|
||||
} catch {
|
||||
toast.error(t('packing.templateError'))
|
||||
} finally {
|
||||
|
||||
@@ -208,9 +208,14 @@ interface ArtikelZeileProps {
|
||||
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) {
|
||||
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editName, setEditName] = useState(item.name)
|
||||
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [showCatPicker, setShowCatPicker] = useState(false)
|
||||
const [showBagPicker, setShowBagPicker] = useState(false)
|
||||
@@ -223,7 +228,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!editName.trim()) { setEditing(false); setEditName(item.name); return }
|
||||
if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return }
|
||||
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
|
||||
catch { toast.error(t('packing.toast.saveError')) }
|
||||
}
|
||||
@@ -275,9 +280,10 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
{editing && canEdit ? (
|
||||
<input
|
||||
type="text" value={editName} autoFocus
|
||||
placeholder={isPlaceholder ? '...' : undefined}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
onBlur={handleSaveName}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(item.name) } }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
|
||||
style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
|
||||
/>
|
||||
) : (
|
||||
@@ -286,7 +292,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
style={{
|
||||
flex: 1, fontSize: 13.5,
|
||||
cursor: !canEdit || item.checked ? 'default' : 'text',
|
||||
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
|
||||
color: isPlaceholder ? 'var(--text-faint)' : (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',
|
||||
}}
|
||||
@@ -959,10 +965,9 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
||||
setApplyingTemplate(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 }))
|
||||
setShowTemplateDropdown(false)
|
||||
// Reload packing items
|
||||
window.location.reload()
|
||||
} catch {
|
||||
toast.error(t('packing.templateError'))
|
||||
} finally {
|
||||
@@ -1020,10 +1025,10 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
||||
if (parsed.length === 0) { toast.error(t('packing.importEmpty')); return }
|
||||
try {
|
||||
const result = await packingApi.bulkImport(tripId, parsed)
|
||||
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(result.items || [])] }))
|
||||
toast.success(t('packing.importSuccess', { count: result.count }))
|
||||
setImportText('')
|
||||
setShowImportModal(false)
|
||||
window.location.reload()
|
||||
} catch { toast.error(t('packing.importError')) }
|
||||
}
|
||||
|
||||
|
||||
@@ -168,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" }
|
||||
|
||||
return (
|
||||
<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 className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
|
||||
<div style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
backdropFilter: 'blur(40px) saturate(180%)',
|
||||
@@ -462,7 +462,10 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
|
||||
options={days.map((d, i) => ({
|
||||
value: d.id,
|
||||
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' })}` : ''}`,
|
||||
label: d.title || t('planner.dayN', { n: i + 1 }),
|
||||
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"
|
||||
/>
|
||||
@@ -474,7 +477,10 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
|
||||
options={days.map((d, i) => ({
|
||||
value: d.id,
|
||||
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' })}` : ''}`,
|
||||
label: d.title || t('planner.dayN', { n: i + 1 }),
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -336,6 +336,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
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) => {
|
||||
e.stopPropagation()
|
||||
setExpandedDays(prev => {
|
||||
@@ -490,11 +494,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
||||
const transport = getTransportForDay(dayId)
|
||||
|
||||
// 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
|
||||
const baseItems = [
|
||||
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
||||
@@ -1117,7 +1116,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
|
||||
{/* Tagesliste */}
|
||||
<div className="scroll-container trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
<div className={`scroll-container${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{days.map((day, index) => {
|
||||
const isSelected = selectedDayId === day.id
|
||||
const isExpanded = expandedDays.has(day.id)
|
||||
@@ -1135,7 +1134,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
||||
<div
|
||||
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
|
||||
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
|
||||
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
||||
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
|
||||
onDrop={e => handleDropOnDay(e, day.id)}
|
||||
style={{
|
||||
@@ -1236,9 +1235,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 iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-muted)'
|
||||
return (
|
||||
<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' }}>
|
||||
<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' }}>
|
||||
<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.place_name}</span>
|
||||
<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>
|
||||
)
|
||||
})
|
||||
@@ -1349,7 +1348,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
>
|
||||
{merged.length === 0 && !dayNoteUi ? (
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
|
||||
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
||||
onDrop={e => handleDropOnDay(e, day.id)}
|
||||
style={{ padding: '16px', textAlign: 'center', borderRadius: 8,
|
||||
background: dragOverDayId === day.id ? 'rgba(17,24,39,0.05)' : 'transparent',
|
||||
@@ -1409,7 +1408,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
|
||||
return (
|
||||
<React.Fragment key={`place-${assignment.id}`}>
|
||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
<div
|
||||
draggable={canEditDays}
|
||||
onDragStart={e => {
|
||||
@@ -1499,6 +1497,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
borderLeft: lockedIds.has(assignment.id)
|
||||
? '3px solid #dc2626'
|
||||
: '3px solid transparent',
|
||||
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
|
||||
transition: 'background 0.15s, border-color 0.15s',
|
||||
opacity: isDraggingThis ? 0.4 : 1,
|
||||
}}
|
||||
@@ -1722,7 +1721,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
|
||||
return (
|
||||
<React.Fragment key={`transport-${res.id}-${day.id}`}>
|
||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
<div
|
||||
onClick={() => canEditDays && onEditTransport?.(res)}
|
||||
onDragOver={e => {
|
||||
@@ -1771,6 +1769,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
margin: '1px 8px',
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${color}33`,
|
||||
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
|
||||
borderBottom: showDropLineAfter ? '2px solid var(--text-primary)' : undefined,
|
||||
background: `${color}08`,
|
||||
cursor: canEditDays && onEditTransport ? 'pointer' : 'default', userSelect: 'none',
|
||||
transition: 'background 0.1s',
|
||||
@@ -1844,7 +1844,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
{showDropLineAfter && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
@@ -1855,7 +1854,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const noteIdx = idx
|
||||
return (
|
||||
<React.Fragment key={`note-${note.id}`}>
|
||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
<div
|
||||
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}`) }}
|
||||
@@ -1911,6 +1909,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
margin: '1px 8px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--border-faint)',
|
||||
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
|
||||
background: 'var(--bg-hover)',
|
||||
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
|
||||
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
|
||||
|
||||
@@ -360,6 +360,25 @@ export default function PlaceFormModal({
|
||||
onClose={onClose}
|
||||
title={place ? t('places.editPlace') : t('places.addPlace')}
|
||||
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}>
|
||||
{/* Place Search */}
|
||||
@@ -613,23 +632,6 @@ export default function PlaceFormModal({
|
||||
</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>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -203,8 +203,10 @@ describe('ReservationModal', () => {
|
||||
fireEvent.change(datePickers[1], { target: { value: '2025-06-09' } });
|
||||
fireEvent.change(timePickers[1], { target: { value: '09:00' } });
|
||||
|
||||
// When isEndBeforeStart=true the submit button is disabled, so submit the form directly
|
||||
const form = screen.getByRole('button', { name: /^Add$/i }).closest('form')!;
|
||||
// When isEndBeforeStart=true the submit button is disabled, so fire submit on the form directly.
|
||||
// The Save button now lives in the Modal's sticky footer (outside the <form>), so we query
|
||||
// the form by tag instead of walking up from the button.
|
||||
const form = document.querySelector('form')!;
|
||||
fireEvent.submit(form);
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
|
||||
@@ -143,6 +143,18 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
}
|
||||
}, [reservation, isOpen, selectedDayId, defaultAssignmentId])
|
||||
|
||||
// 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 isEndBeforeStart = (() => {
|
||||
@@ -193,9 +205,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||
: { total_price: 0 }
|
||||
}
|
||||
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
|
||||
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
|
||||
saveData.create_accommodation = {
|
||||
place_id: form.hotel_place_id,
|
||||
place_id: form.hotel_place_id || null,
|
||||
start_day_id: form.hotel_start_day,
|
||||
end_day_id: form.hotel_end_day,
|
||||
check_in: form.meta_check_in_time || null,
|
||||
@@ -259,7 +271,22 @@ 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' }
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="2xl">
|
||||
<Modal
|
||||
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 }}>
|
||||
|
||||
{/* Type selector */}
|
||||
@@ -405,12 +432,17 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<CustomSelect
|
||||
value={form.hotel_place_id}
|
||||
onChange={value => {
|
||||
set('hotel_place_id', value)
|
||||
const p = places.find(pl => pl.id === value)
|
||||
if (p) {
|
||||
if (!form.title) set('title', p.name)
|
||||
if (!form.location && p.address) set('location', p.address)
|
||||
}
|
||||
setForm(prev => {
|
||||
const next = { ...prev, hotel_place_id: value }
|
||||
if (!value) {
|
||||
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')}
|
||||
options={[
|
||||
@@ -427,7 +459,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
value={form.hotel_start_day}
|
||||
onChange={value => set('hotel_start_day', value)}
|
||||
placeholder={t('reservations.meta.selectDay')}
|
||||
options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))}
|
||||
options={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,
|
||||
}
|
||||
})}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -437,7 +477,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
value={form.hotel_end_day}
|
||||
onChange={value => set('hotel_end_day', value)}
|
||||
placeholder={t('reservations.meta.selectDay')}
|
||||
options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))}
|
||||
options={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,
|
||||
}
|
||||
})}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -589,15 +637,6 @@ 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>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -112,17 +112,30 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
|
||||
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
|
||||
const startDay = r.day_id ? days.find(d => d.id === r.day_id) : undefined
|
||||
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id) : undefined
|
||||
const dayLabel = (day: typeof startDay): string => {
|
||||
if (!day) return ''
|
||||
const base = day.title || t('dayplan.dayN', { n: day.day_number })
|
||||
if (day.date) {
|
||||
const d = new Date(day.date + 'T00:00:00Z')
|
||||
const dateStr = d.toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
return `${base} · ${dateStr}`
|
||||
}
|
||||
return base
|
||||
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 (
|
||||
@@ -135,13 +148,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
|
||||
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
|
||||
>
|
||||
{/* Header */}
|
||||
{/* Header — wraps to a second row on narrow screens so the status/category chips
|
||||
never collide with the title. */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
padding: '12px 14px',
|
||||
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 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
fontSize: 12, fontWeight: 600, color: confirmed ? '#16a34a' : '#d97706',
|
||||
@@ -202,12 +217,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}>
|
||||
{/* Day label for transport reservations linked to a day */}
|
||||
{isTransportType && startDay && (
|
||||
{/* Day label for transport/hotel reservations linked to days */}
|
||||
{(isTransportType || isHotel) && startDay && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||
{dayLabel(startDay)}{endDay && endDay.id !== startDay.id ? ` – ${dayLabel(endDay)}` : ''}
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -220,10 +220,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
|
||||
const dayOptions = [
|
||||
{ value: '', label: '—' },
|
||||
...days.map(d => ({
|
||||
value: d.id,
|
||||
label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale) ?? ''}` : ''}`,
|
||||
})),
|
||||
...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 (
|
||||
@@ -232,6 +237,16 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
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 }}>
|
||||
|
||||
@@ -407,15 +422,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
||||
</div>
|
||||
|
||||
{/* 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()} 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>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -155,7 +155,9 @@ describe('DisplaySettingsTab', () => {
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
await user.click(screen.getByText('24h (14:30)'));
|
||||
// The label is split across a text node ('24h') and a responsive span (' (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');
|
||||
});
|
||||
|
||||
|
||||
@@ -188,8 +188,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>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
{ value: '24h', short: '24h', example: '14:30' },
|
||||
{ value: '12h', short: '12h', example: '2:30 PM' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
@@ -207,7 +207,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
{opt.short}
|
||||
<span className="hidden sm:inline">{` (${opt.example})`}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -240,14 +240,18 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span className="absolute top-2 right-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
|
||||
{t('settings.mapExperimental')}
|
||||
</span>
|
||||
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white">Mapbox GL</div>
|
||||
<div className="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">
|
||||
|
||||
@@ -25,6 +25,7 @@ const EVENT_LABEL_KEYS: Record<string, string> = {
|
||||
trip_invite: 'settings.notifyTripInvite',
|
||||
booking_change: 'settings.notifyBookingChange',
|
||||
trip_reminder: 'settings.notifyTripReminder',
|
||||
todo_due: 'settings.notifyTodoDue',
|
||||
vacay_invite: 'settings.notifyVacayInvite',
|
||||
photos_shared: 'settings.notifyPhotosShared',
|
||||
collab_message: 'settings.notifyCollabMessage',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useToast } from '../../components/shared/Toast'
|
||||
import apiClient from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
|
||||
interface ProviderField {
|
||||
key: string
|
||||
@@ -222,15 +223,13 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
{fields.map(field => (
|
||||
<div key={`${provider.id}-${field.key}`}>
|
||||
{field.input_type === 'checkbox' ? (
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
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"
|
||||
<div className="flex items-center gap-3">
|
||||
<ToggleSwitch
|
||||
on={values[field.key] === 'true'}
|
||||
onToggle={() => handleProviderFieldChange(provider.id, field.key, values[field.key] === 'true' ? 'false' : 'true')}
|
||||
/>
|
||||
<span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
|
||||
@@ -248,7 +247,9 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Wraps on mobile so the connection badge drops to its own row
|
||||
instead of clipping off the side of the card. */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleSaveProvider(provider)}
|
||||
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
|
||||
@@ -266,15 +267,17 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
{testing
|
||||
? <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" />}
|
||||
{t('memories.testConnection')}
|
||||
<span className="sm:hidden">{t('memories.testShort')}</span>
|
||||
<span className="hidden sm:inline">{t('memories.testConnection')}</span>
|
||||
</button>
|
||||
{/* On mobile the badge sits on its own row thanks to flex-wrap, so force a line break via basis-full. */}
|
||||
{connected ? (
|
||||
<span className="text-xs font-medium text-green-600 flex items-center gap-1">
|
||||
<span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
{t('memories.connected')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-medium text-slate-400 flex items-center gap-1">
|
||||
<span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-slate-300 rounded-full" />
|
||||
{t('memories.disconnected')}
|
||||
</span>
|
||||
|
||||
@@ -2,9 +2,10 @@ import React from 'react'
|
||||
|
||||
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<button onClick={onToggle}
|
||||
<button type="button" onClick={onToggle}
|
||||
style={{
|
||||
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
|
||||
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
||||
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
|
||||
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
||||
transition: 'background 0.2s',
|
||||
}}>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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)' }}
|
||||
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,13 +119,14 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
|
||||
...(() => {
|
||||
const r = ref.current?.getBoundingClientRect()
|
||||
if (!r) return { top: 0, left: 0 }
|
||||
const w = 268, pad = 8
|
||||
const w = 268, pad = 8, h = 360
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
const vh = window.visualViewport?.height ?? window.innerHeight
|
||||
let left = r.left
|
||||
let top = r.bottom + 4
|
||||
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
|
||||
if (top + 320 > vh) top = Math.max(pad, r.top - 320)
|
||||
if (top + h > vh - pad) top = r.top - h - 4
|
||||
top = Math.max(pad, Math.min(top, vh - h - pad))
|
||||
if (vw < 360) left = Math.max(pad, (vw - w) / 2)
|
||||
return { top, left }
|
||||
})(),
|
||||
|
||||
@@ -9,6 +9,7 @@ interface SelectOption {
|
||||
isHeader?: boolean
|
||||
searchLabel?: string
|
||||
groupLabel?: string
|
||||
badge?: string
|
||||
}
|
||||
|
||||
interface CustomSelectProps {
|
||||
@@ -104,6 +105,13 @@ export default function CustomSelect({
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: selected ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
{selected?.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',
|
||||
}}>{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>
|
||||
|
||||
@@ -186,6 +194,13 @@ export default function CustomSelect({
|
||||
>
|
||||
{option.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{option.icon}</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)' }} />}
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -61,14 +61,15 @@ export default function Modal({
|
||||
<div
|
||||
className={`
|
||||
trek-modal-enter
|
||||
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||
flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
|
||||
rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||
flex flex-col
|
||||
max-h-[calc(100dvh-var(--bottom-nav-h)-90px)] sm:max-h-[calc(100dvh-90px)]
|
||||
`}
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
{/* Header — stays put even while the body scrolls */}
|
||||
<div className="flex items-center justify-between p-6 flex-shrink-0" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
|
||||
{!hideCloseButton && (
|
||||
<button
|
||||
@@ -80,14 +81,14 @@ export default function Modal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* Body — scrolls when content overflows. min-h-0 lets the flex child shrink below its intrinsic height. */}
|
||||
<div className="flex-1 overflow-y-auto p-6 min-h-0">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{/* Footer — sticky at the bottom of the modal, never compressed */}
|
||||
{footer && (
|
||||
<div className="p-6" style={{ borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<div className="p-6 flex-shrink-0" style={{ borderTop: '1px solid var(--border-secondary)' }}>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -34,6 +34,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.none': 'لا شيء',
|
||||
'common.date': 'التاريخ',
|
||||
'common.rename': 'إعادة تسمية',
|
||||
'common.discardChanges': 'تجاهل التغييرات',
|
||||
'common.discard': 'تجاهل',
|
||||
'common.name': 'الاسم',
|
||||
'common.email': 'البريد الإلكتروني',
|
||||
'common.password': 'كلمة المرور',
|
||||
@@ -204,6 +206,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyTripInvite': 'دعوات الرحلات',
|
||||
'settings.notifyBookingChange': 'تغييرات الحجز',
|
||||
'settings.notifyTripReminder': 'تذكيرات الرحلات',
|
||||
'settings.notifyTodoDue': 'مهمة مستحقة',
|
||||
'settings.notifyVacayInvite': 'دعوات دمج الإجازات',
|
||||
'settings.notifyPhotosShared': 'صور مشتركة (Immich)',
|
||||
'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)',
|
||||
@@ -463,6 +466,28 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC',
|
||||
'login.usernameRequired': 'اسم المستخدم مطلوب',
|
||||
'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل',
|
||||
'login.forgotPassword': 'نسيت كلمة المرور؟',
|
||||
'login.forgotPasswordTitle': 'إعادة تعيين كلمة المرور',
|
||||
'login.forgotPasswordBody': 'أدخل عنوان البريد الإلكتروني المسجَّل. إذا كان الحساب موجودًا، سنرسل رابط إعادة التعيين.',
|
||||
'login.forgotPasswordSubmit': 'إرسال الرابط',
|
||||
'login.forgotPasswordSentTitle': 'تحقق من بريدك',
|
||||
'login.forgotPasswordSentBody': 'إذا كان هناك حساب مرتبط بهذا البريد، فإن الرابط في الطريق. تنتهي صلاحيته خلال 60 دقيقة.',
|
||||
'login.forgotPasswordSmtpHintOff': 'ملاحظة: لم يقم المسؤول بتكوين SMTP، لذا سيتم كتابة رابط إعادة التعيين في وحدة تحكم الخادم بدلاً من إرساله عبر البريد الإلكتروني.',
|
||||
'login.backToLogin': 'العودة إلى تسجيل الدخول',
|
||||
'login.newPassword': 'كلمة المرور الجديدة',
|
||||
'login.confirmPassword': 'تأكيد كلمة المرور الجديدة',
|
||||
'login.passwordsDontMatch': 'كلمتا المرور غير متطابقتين',
|
||||
'login.mfaCode': 'رمز 2FA',
|
||||
'login.resetPasswordTitle': 'ضبط كلمة مرور جديدة',
|
||||
'login.resetPasswordBody': 'اختر كلمة مرور قوية لم تستخدمها هنا من قبل. 8 أحرف على الأقل.',
|
||||
'login.resetPasswordMfaBody': 'أدخل رمز 2FA أو رمز النسخ الاحتياطي لإتمام إعادة التعيين.',
|
||||
'login.resetPasswordSubmit': 'إعادة تعيين كلمة المرور',
|
||||
'login.resetPasswordVerify': 'تحقق وأعد التعيين',
|
||||
'login.resetPasswordSuccessTitle': 'تم تحديث كلمة المرور',
|
||||
'login.resetPasswordSuccessBody': 'يمكنك الآن تسجيل الدخول بكلمة المرور الجديدة.',
|
||||
'login.resetPasswordInvalidLink': 'رابط إعادة تعيين غير صالح',
|
||||
'login.resetPasswordInvalidLinkBody': 'هذا الرابط مفقود أو تالف. اطلب رابطًا جديدًا للمتابعة.',
|
||||
'login.resetPasswordFailed': 'فشلت إعادة التعيين. ربما انتهت صلاحية الرابط.',
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'كلمتا المرور غير متطابقتين',
|
||||
@@ -1199,6 +1224,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.title': 'الملفات',
|
||||
'files.pageTitle': 'الملفات والمستندات',
|
||||
'files.subtitle': '{count} ملف لـ {trip}',
|
||||
'files.download': 'تنزيل',
|
||||
'files.openError': 'تعذر فتح الملف',
|
||||
'files.downloadPdf': 'تنزيل PDF',
|
||||
'files.count': '{count} ملفات',
|
||||
'files.countSingular': 'ملف واحد',
|
||||
@@ -1598,6 +1625,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع',
|
||||
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
|
||||
'memories.testConnection': 'اختبار الاتصال',
|
||||
'memories.testShort': 'اختبار',
|
||||
'memories.testFirst': 'اختبر الاتصال أولاً',
|
||||
'memories.connected': 'متصل',
|
||||
'memories.disconnected': 'غير متصل',
|
||||
@@ -1973,6 +2001,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notif.booking_change.text': '{actor} حدّث حجزاً في {trip}',
|
||||
'notif.trip_reminder.title': 'تذكير بالرحلة',
|
||||
'notif.trip_reminder.text': 'رحلتك {trip} تقترب!',
|
||||
'notif.todo_due.title': 'مهمة مستحقة',
|
||||
'notif.todo_due.text': '{todo} في {trip} مستحقة في {due}',
|
||||
'notif.vacay_invite.title': 'دعوة دمج الإجازة',
|
||||
'notif.vacay_invite.text': '{actor} يدعوك لدمج خطط الإجازة',
|
||||
'notif.photos_shared.title': 'تمت مشاركة الصور',
|
||||
@@ -2111,9 +2141,9 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
// System notices — personal thank you
|
||||
'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) يبقي الأضواء مشتعلة.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.addTransport': 'إضافة وسيلة نقل',
|
||||
'transport.modalTitle.create': 'إضافة وسيلة نقل',
|
||||
'transport.modalTitle.edit': 'تعديل وسيلة النقل',
|
||||
'transport.title': 'المواصلات',
|
||||
'transport.addManual': 'نقل يدوي',
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.none': 'Nenhum',
|
||||
'common.date': 'Data',
|
||||
'common.rename': 'Renomear',
|
||||
'common.discardChanges': 'Descartar alterações',
|
||||
'common.discard': 'Descartar',
|
||||
'common.name': 'Nome',
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Senha',
|
||||
@@ -199,6 +201,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyTripInvite': 'Convites de viagem',
|
||||
'settings.notifyBookingChange': 'Alterações de reserva',
|
||||
'settings.notifyTripReminder': 'Lembretes de viagem',
|
||||
'settings.notifyTodoDue': 'Tarefa com vencimento',
|
||||
'settings.notifyVacayInvite': 'Convites de fusão Vacay',
|
||||
'settings.notifyPhotosShared': 'Fotos compartilhadas (Immich)',
|
||||
'settings.notifyCollabMessage': 'Mensagens de chat (Colab)',
|
||||
@@ -458,6 +461,28 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.oidcFailed': 'Falha no login OIDC',
|
||||
'login.usernameRequired': 'Nome de usuário é obrigatório',
|
||||
'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres',
|
||||
'login.forgotPassword': 'Esqueceu a senha?',
|
||||
'login.forgotPasswordTitle': 'Redefinir sua senha',
|
||||
'login.forgotPasswordBody': 'Digite o e-mail cadastrado. Se houver uma conta, enviaremos um link de redefinição.',
|
||||
'login.forgotPasswordSubmit': 'Enviar link',
|
||||
'login.forgotPasswordSentTitle': 'Verifique seu e-mail',
|
||||
'login.forgotPasswordSentBody': 'Se houver uma conta para esse e-mail, o link está a caminho. Ele expira em 60 minutos.',
|
||||
'login.forgotPasswordSmtpHintOff': 'Observação: seu administrador não configurou SMTP, então o link de redefinição será gravado no console do servidor em vez de ser enviado por e-mail.',
|
||||
'login.backToLogin': 'Voltar ao login',
|
||||
'login.newPassword': 'Nova senha',
|
||||
'login.confirmPassword': 'Confirmar nova senha',
|
||||
'login.passwordsDontMatch': 'As senhas não coincidem',
|
||||
'login.mfaCode': 'Código 2FA',
|
||||
'login.resetPasswordTitle': 'Definir uma nova senha',
|
||||
'login.resetPasswordBody': 'Escolha uma senha forte que você ainda não tenha usado aqui. Mínimo de 8 caracteres.',
|
||||
'login.resetPasswordMfaBody': 'Digite seu código 2FA ou um código de backup para concluir a redefinição.',
|
||||
'login.resetPasswordSubmit': 'Redefinir senha',
|
||||
'login.resetPasswordVerify': 'Verificar e redefinir',
|
||||
'login.resetPasswordSuccessTitle': 'Senha atualizada',
|
||||
'login.resetPasswordSuccessBody': 'Agora você pode entrar com sua nova senha.',
|
||||
'login.resetPasswordInvalidLink': 'Link de redefinição inválido',
|
||||
'login.resetPasswordInvalidLinkBody': 'Este link está ausente ou corrompido. Solicite um novo para continuar.',
|
||||
'login.resetPasswordFailed': 'Falha na redefinição. O link pode ter expirado.',
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'As senhas não coincidem',
|
||||
@@ -1168,6 +1193,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.title': 'Arquivos',
|
||||
'files.pageTitle': 'Arquivos e documentos',
|
||||
'files.subtitle': '{count} arquivos para {trip}',
|
||||
'files.download': 'Baixar',
|
||||
'files.openError': 'Não foi possível abrir o arquivo',
|
||||
'files.downloadPdf': 'Baixar PDF',
|
||||
'files.count': '{count} arquivos',
|
||||
'files.countSingular': '1 arquivo',
|
||||
@@ -1637,6 +1664,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.testConnection': 'Testar conexão',
|
||||
'memories.testShort': 'Testar',
|
||||
'memories.testFirst': 'Teste a conexão primeiro',
|
||||
'memories.connected': 'Conectado',
|
||||
'memories.disconnected': 'Não conectado',
|
||||
@@ -1913,6 +1941,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notif.booking_change.text': '{actor} atualizou uma reserva em {trip}',
|
||||
'notif.trip_reminder.title': 'Lembrete de viagem',
|
||||
'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.text': '{actor} convidou você para fundir planos de férias',
|
||||
'notif.photos_shared.title': 'Fotos compartilhadas',
|
||||
@@ -2314,9 +2344,9 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
// System notices — personal thank you
|
||||
'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.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.addTransport': 'Adicionar transporte',
|
||||
'transport.modalTitle.create': 'Adicionar transporte',
|
||||
'transport.modalTitle.edit': 'Editar transporte',
|
||||
'transport.title': 'Transportes',
|
||||
'transport.addManual': 'Transporte Manual',
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.none': 'Žádné',
|
||||
'common.date': 'Datum',
|
||||
'common.rename': 'Přejmenovat',
|
||||
'common.discardChanges': 'Zahodit změny',
|
||||
'common.discard': 'Zahodit',
|
||||
'common.name': 'Jméno',
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Heslo',
|
||||
@@ -200,6 +202,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyTripInvite': 'Pozvánky na cesty',
|
||||
'settings.notifyBookingChange': 'Změny rezervací',
|
||||
'settings.notifyTripReminder': 'Připomínky cest',
|
||||
'settings.notifyTodoDue': 'Úkol se blíží',
|
||||
'settings.notifyVacayInvite': 'Pozvánky k propojení Vacay',
|
||||
'settings.notifyPhotosShared': 'Sdílené fotky (Immich)',
|
||||
'settings.notifyCollabMessage': 'Zprávy v chatu (Collab)',
|
||||
@@ -458,6 +461,28 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.oidcFailed': 'Přihlášení přes OIDC se nezdařilo',
|
||||
'login.usernameRequired': 'Uživatelské jméno je povinné',
|
||||
'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků',
|
||||
'login.forgotPassword': 'Zapomenuté heslo?',
|
||||
'login.forgotPasswordTitle': 'Obnovení hesla',
|
||||
'login.forgotPasswordBody': 'Zadej e-mail použitý při registraci. Pokud účet existuje, pošleme odkaz pro obnovení.',
|
||||
'login.forgotPasswordSubmit': 'Odeslat odkaz',
|
||||
'login.forgotPasswordSentTitle': 'Zkontroluj e-mail',
|
||||
'login.forgotPasswordSentBody': 'Pokud k tomuto e-mailu existuje účet, odkaz je na cestě. Platnost vyprší za 60 minut.',
|
||||
'login.forgotPasswordSmtpHintOff': 'Upozornění: správce nemá nakonfigurovaný SMTP, takže se odkaz pro obnovení zapíše do konzole serveru místo odeslání e-mailem.',
|
||||
'login.backToLogin': 'Zpět na přihlášení',
|
||||
'login.newPassword': 'Nové heslo',
|
||||
'login.confirmPassword': 'Potvrď nové heslo',
|
||||
'login.passwordsDontMatch': 'Hesla se neshodují',
|
||||
'login.mfaCode': 'Kód 2FA',
|
||||
'login.resetPasswordTitle': 'Nastavit nové heslo',
|
||||
'login.resetPasswordBody': 'Vyber silné heslo, které jsi tu ještě nepoužil. Minimálně 8 znaků.',
|
||||
'login.resetPasswordMfaBody': 'Zadej 2FA kód nebo záložní kód pro dokončení obnovení.',
|
||||
'login.resetPasswordSubmit': 'Obnovit heslo',
|
||||
'login.resetPasswordVerify': 'Ověřit a obnovit',
|
||||
'login.resetPasswordSuccessTitle': 'Heslo aktualizováno',
|
||||
'login.resetPasswordSuccessBody': 'Nyní se můžeš přihlásit novým heslem.',
|
||||
'login.resetPasswordInvalidLink': 'Neplatný odkaz',
|
||||
'login.resetPasswordInvalidLinkBody': 'Odkaz chybí nebo je poškozený. Pro pokračování si vyžádej nový.',
|
||||
'login.resetPasswordFailed': 'Obnovení se nezdařilo. Odkaz mohl vypršet.',
|
||||
|
||||
// Registrace (Register)
|
||||
'register.passwordMismatch': 'Hesla se neshodují',
|
||||
@@ -1197,6 +1222,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.title': 'Soubory',
|
||||
'files.pageTitle': 'Soubory a dokumenty',
|
||||
'files.subtitle': '{count} souborů pro {trip}',
|
||||
'files.download': 'Stáhnout',
|
||||
'files.openError': 'Soubor nelze otevřít',
|
||||
'files.downloadPdf': 'Stáhnout PDF',
|
||||
'files.count': '{count} souborů',
|
||||
'files.countSingular': '1 soubor',
|
||||
@@ -1596,6 +1623,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.immichAutoUpload': 'Zrcadlit fotky journey při nahrávání také do Immich',
|
||||
'memories.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Otestovat připojení',
|
||||
'memories.testShort': 'Otestovat',
|
||||
'memories.testFirst': 'Nejprve otestujte připojení',
|
||||
'memories.connected': 'Připojeno',
|
||||
'memories.disconnected': 'Nepřipojeno',
|
||||
@@ -1918,6 +1946,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notif.booking_change.text': '{actor} aktualizoval rezervaci v {trip}',
|
||||
'notif.trip_reminder.title': 'Připomínka výletu',
|
||||
'notif.trip_reminder.text': 'Váš výlet {trip} se blíží!',
|
||||
'notif.todo_due.title': 'Úkol se blíží',
|
||||
'notif.todo_due.text': '{todo} ve výletě {trip} má termín {due}',
|
||||
'notif.vacay_invite.title': 'Pozvánka Vacay Fusion',
|
||||
'notif.vacay_invite.text': '{actor} vás pozval ke spojení dovolenkových plánů',
|
||||
'notif.photos_shared.title': 'Fotky sdíleny',
|
||||
@@ -2318,9 +2348,9 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Osobní slovo ode mě',
|
||||
'system_notice.v3_thankyou.body': 'Než budete pokračovat — chci se na chvíli zastavit.\n\nTREK začal jako vedlejší projekt, který jsem vytvořil pro své vlastní cesty. Nikdy jsem si nepředstavoval, že vyroste v něco, čemu 4 000 z vás důvěřuje při plánování svých dobrodružství. Každou hvězdičku, každý issue, každý požadavek na funkci — všechny čtu a právě ony mě drží při životě během pozdních nocí mezi prací na plný úvazek a univerzitou.\n\nChci, abyste věděli: TREK bude vždy open source, vždy self-hosted, vždy váš. Žádné sledování, žádná předplatná, žádné háčky. Jen nástroj vytvořený někým, kdo miluje cestování stejně jako vy.\n\nZvláštní poděkování patří [jubnl](https://github.com/jubnl) — stal ses neuvěřitelným spolupracovníkem. Tolik z toho, co dělá verzi 3.0 skvělou, nese tvůj rukopis. Děkuji, že jsi věřil tomuto projektu, když byl ještě v plenkách.\n\nA každému z vás, kdo nahlásil chybu, přeložil řetězec, sdílel TREK s přítelem nebo ho jednoduše použil k plánování cesty — **děkuji**. Vy jste důvod, proč tohle existuje.\n\nNa mnoho dalších dobrodružství společně.\n\n— Maurice\n\n---\n\n[Přidej se ke komunitě na Discordu](https://discord.gg/7Q6M6jDwzf)\n\nPokud ti TREK zlepšuje cestování, [malá káva](https://ko-fi.com/mauriceboe) vždy pomůže udržet světla rozsvícená.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.addTransport': 'Přidat dopravu',
|
||||
'transport.modalTitle.create': 'Přidat dopravu',
|
||||
'transport.modalTitle.edit': 'Upravit dopravu',
|
||||
'transport.title': 'Doprava',
|
||||
'transport.addManual': 'Ruční doprava',
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.none': 'Keine',
|
||||
'common.date': 'Datum',
|
||||
'common.rename': 'Umbenennen',
|
||||
'common.discardChanges': 'Änderungen verwerfen',
|
||||
'common.discard': 'Verwerfen',
|
||||
'common.name': 'Name',
|
||||
'common.email': 'E-Mail',
|
||||
'common.password': 'Passwort',
|
||||
@@ -204,6 +206,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyTripInvite': 'Trip-Einladungen',
|
||||
'settings.notifyBookingChange': 'Buchungsänderungen',
|
||||
'settings.notifyTripReminder': 'Trip-Erinnerungen',
|
||||
'settings.notifyTodoDue': 'Aufgabe bald fällig',
|
||||
'settings.notifyVacayInvite': 'Vacay Fusion-Einladungen',
|
||||
'settings.notifyPhotosShared': 'Geteilte Fotos (Immich)',
|
||||
'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)',
|
||||
@@ -463,6 +466,28 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.oidcFailed': 'OIDC-Anmeldung fehlgeschlagen',
|
||||
'login.usernameRequired': 'Benutzername ist erforderlich',
|
||||
'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein',
|
||||
'login.forgotPassword': 'Passwort vergessen?',
|
||||
'login.forgotPasswordTitle': 'Passwort zurücksetzen',
|
||||
'login.forgotPasswordBody': 'Gib die E-Mail-Adresse deines Kontos ein. Falls ein Konto existiert, schicken wir dir einen Reset-Link.',
|
||||
'login.forgotPasswordSubmit': 'Reset-Link senden',
|
||||
'login.forgotPasswordSentTitle': 'Prüfe deine E-Mails',
|
||||
'login.forgotPasswordSentBody': 'Falls ein Konto mit dieser Adresse existiert, ist ein Reset-Link unterwegs. Er läuft in 60 Minuten ab.',
|
||||
'login.forgotPasswordSmtpHintOff': 'Hinweis: Der Administrator hat SMTP nicht konfiguriert. Der Reset-Link wird statt per E-Mail in die Server-Konsole geschrieben.',
|
||||
'login.backToLogin': 'Zurück zur Anmeldung',
|
||||
'login.newPassword': 'Neues Passwort',
|
||||
'login.confirmPassword': 'Neues Passwort bestätigen',
|
||||
'login.passwordsDontMatch': 'Passwörter stimmen nicht überein',
|
||||
'login.mfaCode': '2FA-Code',
|
||||
'login.resetPasswordTitle': 'Neues Passwort festlegen',
|
||||
'login.resetPasswordBody': 'Wähle ein starkes Passwort, das du hier noch nicht verwendet hast. Mindestens 8 Zeichen.',
|
||||
'login.resetPasswordMfaBody': 'Gib deinen 2FA-Code oder einen Backup-Code ein, um den Reset abzuschließen.',
|
||||
'login.resetPasswordSubmit': 'Passwort zurücksetzen',
|
||||
'login.resetPasswordVerify': 'Prüfen & zurücksetzen',
|
||||
'login.resetPasswordSuccessTitle': 'Passwort aktualisiert',
|
||||
'login.resetPasswordSuccessBody': 'Du kannst dich jetzt mit deinem neuen Passwort anmelden.',
|
||||
'login.resetPasswordInvalidLink': 'Ungültiger Reset-Link',
|
||||
'login.resetPasswordInvalidLinkBody': 'Dieser Link fehlt oder ist beschädigt. Fordere einen neuen an, um fortzufahren.',
|
||||
'login.resetPasswordFailed': 'Zurücksetzen fehlgeschlagen. Der Link ist möglicherweise abgelaufen.',
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'Passwörter stimmen nicht überein',
|
||||
@@ -1201,6 +1226,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.title': 'Dateien',
|
||||
'files.pageTitle': 'Dateien & Dokumente',
|
||||
'files.subtitle': '{count} Dateien für {trip}',
|
||||
'files.download': 'Herunterladen',
|
||||
'files.openError': 'Datei konnte nicht geöffnet werden',
|
||||
'files.downloadPdf': 'PDF herunterladen',
|
||||
'files.count': '{count} Dateien',
|
||||
'files.countSingular': '1 Datei',
|
||||
@@ -1600,6 +1627,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.immichAutoUpload': 'Journey-Fotos beim Upload auch zu Immich spiegeln',
|
||||
'memories.providerUrlHintSynology': 'Füge den Fotos-App-Pfad in die URL ein, z.B. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Verbindung testen',
|
||||
'memories.testShort': 'Testen',
|
||||
'memories.testFirst': 'Verbindung zuerst testen',
|
||||
'memories.connected': 'Verbunden',
|
||||
'memories.disconnected': 'Nicht verbunden',
|
||||
@@ -1923,6 +1951,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notif.booking_change.text': '{actor} hat eine Buchung in {trip} aktualisiert',
|
||||
'notif.trip_reminder.title': 'Reiseerinnerung',
|
||||
'notif.trip_reminder.text': 'Deine Reise {trip} steht bald an!',
|
||||
'notif.todo_due.title': 'Aufgabe fällig',
|
||||
'notif.todo_due.text': '{todo} in {trip} ist am {due} fällig',
|
||||
'notif.vacay_invite.title': 'Vacay Fusion-Einladung',
|
||||
'notif.vacay_invite.text': '{actor} hat dich zum Fusionieren von Urlaubsplänen eingeladen',
|
||||
'notif.photos_shared.title': 'Fotos geteilt',
|
||||
@@ -2324,9 +2354,9 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
// System notices — persönlicher Dank
|
||||
'system_notice.v3_thankyou.title': 'Ein persönliches Wort von mir',
|
||||
'system_notice.v3_thankyou.body': 'Bevor du weiterklickst — einen Moment noch.\n\nTREK hat als Nebenprojekt für meine eigenen Reisen angefangen. Ich hätte nie gedacht, dass es jemals so weit kommt, dass 4.000 von euch damit ihre Abenteuer planen. Jeder Stern, jedes Issue, jeder Feature-Wunsch — ich lese sie alle, und sie halten mich am Laufen durch die späten Nächte zwischen Vollzeitjob und Studium.\n\nEins will ich euch sagen: TREK wird immer Open Source bleiben, immer self-hosted, immer eures. Kein Tracking, keine Abos, keine versteckten Haken. Einfach ein Tool, gebaut von jemandem, der das Reisen genauso liebt wie ihr.\n\nBesonderer Dank an [jubnl](https://github.com/jubnl) — du bist ein unglaublicher Mitstreiter geworden. So vieles, was 3.0 großartig macht, trägt deine Handschrift. Danke, dass du an dieses Projekt geglaubt hast, als es noch holprig war.\n\nUnd an jeden einzelnen von euch, der einen Bug gemeldet, einen String übersetzt, TREK mit Freunden geteilt oder einfach damit eine Reise geplant hat — **danke**. Ihr seid der Grund, warum es das hier gibt.\n\nAuf viele weitere Abenteuer zusammen.\n\n— Maurice\n\n---\n\n[Tritt der Community auf Discord bei](https://discord.gg/7Q6M6jDwzf)\n\nWenn TREK deine Reisen besser macht, hält ein [kleiner Kaffee](https://ko-fi.com/mauriceboe) die Lichter an.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.addTransport': 'Transport hinzufügen',
|
||||
'transport.modalTitle.create': 'Transport hinzufügen',
|
||||
'transport.modalTitle.edit': 'Transport bearbeiten',
|
||||
'transport.title': 'Transporte',
|
||||
'transport.addManual': 'Manuelles Transportmittel',
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.none': 'None',
|
||||
'common.date': 'Date',
|
||||
'common.rename': 'Rename',
|
||||
'common.discardChanges': 'Discard Changes',
|
||||
'common.discard': 'Discard',
|
||||
'common.name': 'Name',
|
||||
'common.email': 'Email',
|
||||
'common.password': 'Password',
|
||||
@@ -122,6 +124,20 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dashboard.toast.copied': 'Trip copied!',
|
||||
'dashboard.toast.copyError': 'Failed to copy trip',
|
||||
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
|
||||
'dashboard.confirm.copy.title': 'Copy this trip?',
|
||||
'dashboard.confirm.copy.willCopy': 'Will be copied',
|
||||
'dashboard.confirm.copy.will1': 'Days, places & day assignments',
|
||||
'dashboard.confirm.copy.will2': 'Accommodations & reservations',
|
||||
'dashboard.confirm.copy.will3': 'Budget items & category order',
|
||||
'dashboard.confirm.copy.will4': 'Packing lists (unchecked)',
|
||||
'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)',
|
||||
'dashboard.confirm.copy.will6': 'Day notes',
|
||||
'dashboard.confirm.copy.wontCopy': "Won't be copied",
|
||||
'dashboard.confirm.copy.wont1': 'Collaborators & member assignments',
|
||||
'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages',
|
||||
'dashboard.confirm.copy.wont3': 'Files & photos',
|
||||
'dashboard.confirm.copy.wont4': 'Share tokens',
|
||||
'dashboard.confirm.copy.confirm': 'Copy trip',
|
||||
'dashboard.editTrip': 'Edit Trip',
|
||||
'dashboard.createTrip': 'Create New Trip',
|
||||
'dashboard.tripTitle': 'Title',
|
||||
@@ -204,6 +220,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyTripInvite': 'Trip invitations',
|
||||
'settings.notifyBookingChange': 'Booking changes',
|
||||
'settings.notifyTripReminder': 'Trip reminders',
|
||||
'settings.notifyTodoDue': 'Todo due soon',
|
||||
'settings.notifyVacayInvite': 'Vacay fusion invitations',
|
||||
'settings.notifyPhotosShared': 'Shared photos (Immich)',
|
||||
'settings.notifyCollabMessage': 'Chat messages (Collab)',
|
||||
@@ -522,6 +539,28 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.oidcFailed': 'OIDC login failed',
|
||||
'login.usernameRequired': 'Username is required',
|
||||
'login.passwordMinLength': 'Password must be at least 8 characters',
|
||||
'login.forgotPassword': 'Forgot password?',
|
||||
'login.forgotPasswordTitle': 'Reset your password',
|
||||
'login.forgotPasswordBody': 'Enter the email address you signed up with. If an account exists, we\'ll send a reset link.',
|
||||
'login.forgotPasswordSubmit': 'Send reset link',
|
||||
'login.forgotPasswordSentTitle': 'Check your email',
|
||||
'login.forgotPasswordSentBody': 'If an account exists for that email, a reset link is on its way. It expires in 60 minutes.',
|
||||
'login.forgotPasswordSmtpHintOff': 'Heads up: your administrator hasn\'t configured SMTP, so the reset link will be written to the server console instead of being emailed.',
|
||||
'login.backToLogin': 'Back to sign in',
|
||||
'login.newPassword': 'New password',
|
||||
'login.confirmPassword': 'Confirm new password',
|
||||
'login.passwordsDontMatch': 'Passwords don\'t match',
|
||||
'login.mfaCode': '2FA code',
|
||||
'login.resetPasswordTitle': 'Set a new password',
|
||||
'login.resetPasswordBody': 'Pick a strong password you haven’t used here before. Minimum 8 characters.',
|
||||
'login.resetPasswordMfaBody': 'Enter your 2FA code or a backup code to complete the reset.',
|
||||
'login.resetPasswordSubmit': 'Reset password',
|
||||
'login.resetPasswordVerify': 'Verify & reset',
|
||||
'login.resetPasswordSuccessTitle': 'Password updated',
|
||||
'login.resetPasswordSuccessBody': 'You can now sign in with your new password.',
|
||||
'login.resetPasswordInvalidLink': 'Invalid reset link',
|
||||
'login.resetPasswordInvalidLinkBody': 'This link is missing or broken. Request a new one to continue.',
|
||||
'login.resetPasswordFailed': 'Reset failed. The link may have expired.',
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'Passwords do not match',
|
||||
@@ -1258,6 +1297,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.title': 'Files',
|
||||
'files.pageTitle': 'Files & Documents',
|
||||
'files.subtitle': '{count} files for {trip}',
|
||||
'files.download': 'Download',
|
||||
'files.openError': 'Could not open file',
|
||||
'files.downloadPdf': 'Download PDF',
|
||||
'files.count': '{count} files',
|
||||
'files.countSingular': '1 file',
|
||||
@@ -1659,6 +1700,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.immichAutoUpload': 'Mirror journey photos to Immich on upload',
|
||||
'memories.providerUrlHintSynology': 'Include the Photos app path in the URL, e.g. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Test connection',
|
||||
'memories.testShort': 'Test',
|
||||
'memories.testFirst': 'Test connection first',
|
||||
'memories.connected': 'Connected',
|
||||
'memories.disconnected': 'Not connected',
|
||||
@@ -1926,6 +1968,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notif.booking_change.text': '{actor} updated a booking in {trip}',
|
||||
'notif.trip_reminder.title': 'Trip Reminder',
|
||||
'notif.trip_reminder.text': 'Your trip {trip} is coming up soon!',
|
||||
'notif.todo_due.title': 'To-do due',
|
||||
'notif.todo_due.text': '{todo} in {trip} is due on {due}',
|
||||
'notif.vacay_invite.title': 'Vacay Fusion Invite',
|
||||
'notif.vacay_invite.text': '{actor} invited you to fuse vacation plans',
|
||||
'notif.photos_shared.title': 'Photos Shared',
|
||||
|
||||
@@ -30,6 +30,8 @@ const es: Record<string, string> = {
|
||||
'common.none': 'Ninguno',
|
||||
'common.date': 'Fecha',
|
||||
'common.rename': 'Renombrar',
|
||||
'common.discardChanges': 'Descartar cambios',
|
||||
'common.discard': 'Descartar',
|
||||
'common.name': 'Nombre',
|
||||
'common.email': 'Correo',
|
||||
'common.password': 'Contraseña',
|
||||
@@ -200,6 +202,7 @@ const es: Record<string, string> = {
|
||||
'settings.notifyTripInvite': 'Invitaciones de viaje',
|
||||
'settings.notifyBookingChange': 'Cambios en reservas',
|
||||
'settings.notifyTripReminder': 'Recordatorios de viaje',
|
||||
'settings.notifyTodoDue': 'Tarea próxima',
|
||||
'settings.notifyVacayInvite': 'Invitaciones de fusión Vacay',
|
||||
'settings.notifyPhotosShared': 'Fotos compartidas (Immich)',
|
||||
'settings.notifyCollabMessage': 'Mensajes de chat (Collab)',
|
||||
@@ -450,6 +453,28 @@ const es: Record<string, string> = {
|
||||
'login.oidcFailed': 'Error de inicio de sesión OIDC',
|
||||
'login.usernameRequired': 'El nombre de usuario es obligatorio',
|
||||
'login.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres',
|
||||
'login.forgotPassword': '¿Olvidaste tu contraseña?',
|
||||
'login.forgotPasswordTitle': 'Restablecer tu contraseña',
|
||||
'login.forgotPasswordBody': 'Introduce la dirección de correo con la que te registraste. Si existe una cuenta, enviaremos un enlace.',
|
||||
'login.forgotPasswordSubmit': 'Enviar enlace',
|
||||
'login.forgotPasswordSentTitle': 'Revisa tu correo',
|
||||
'login.forgotPasswordSentBody': 'Si existe una cuenta con ese correo, el enlace de restablecimiento está en camino. Caduca en 60 minutos.',
|
||||
'login.forgotPasswordSmtpHintOff': 'Nota: tu administrador no ha configurado SMTP, así que el enlace de restablecimiento se escribirá en la consola del servidor en lugar de enviarse por correo.',
|
||||
'login.backToLogin': 'Volver al inicio de sesión',
|
||||
'login.newPassword': 'Nueva contraseña',
|
||||
'login.confirmPassword': 'Confirmar nueva contraseña',
|
||||
'login.passwordsDontMatch': 'Las contraseñas no coinciden',
|
||||
'login.mfaCode': 'Código 2FA',
|
||||
'login.resetPasswordTitle': 'Establecer una nueva contraseña',
|
||||
'login.resetPasswordBody': 'Elige una contraseña segura que no hayas usado aquí antes. Mínimo 8 caracteres.',
|
||||
'login.resetPasswordMfaBody': 'Introduce tu código 2FA o un código de respaldo para completar el restablecimiento.',
|
||||
'login.resetPasswordSubmit': 'Restablecer contraseña',
|
||||
'login.resetPasswordVerify': 'Verificar y restablecer',
|
||||
'login.resetPasswordSuccessTitle': 'Contraseña actualizada',
|
||||
'login.resetPasswordSuccessBody': 'Ya puedes iniciar sesión con tu nueva contraseña.',
|
||||
'login.resetPasswordInvalidLink': 'Enlace de restablecimiento no válido',
|
||||
'login.resetPasswordInvalidLinkBody': 'Este enlace falta o está roto. Solicita uno nuevo para continuar.',
|
||||
'login.resetPasswordFailed': 'Restablecimiento fallido. El enlace puede haber caducado.',
|
||||
'login.oidc.tokenFailed': 'La autenticación falló.',
|
||||
'login.oidc.invalidState': 'Sesión no válida. Inténtalo de nuevo.',
|
||||
'login.demoFailed': 'Falló el acceso a la demo',
|
||||
@@ -1145,6 +1170,8 @@ const es: Record<string, string> = {
|
||||
'files.title': 'Archivos',
|
||||
'files.pageTitle': 'Archivos y documentos',
|
||||
'files.subtitle': '{count} archivos para {trip}',
|
||||
'files.download': 'Descargar',
|
||||
'files.openError': 'No se pudo abrir el archivo',
|
||||
'files.downloadPdf': 'Descargar PDF',
|
||||
'files.count': '{count} archivos',
|
||||
'files.countSingular': '1 archivo',
|
||||
@@ -1537,6 +1564,7 @@ const es: Record<string, string> = {
|
||||
'memories.immichAutoUpload': 'Duplicar las fotos del journey en Immich al subirlas',
|
||||
'memories.providerUrlHintSynology': 'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Probar conexión',
|
||||
'memories.testShort': 'Probar',
|
||||
'memories.testFirst': 'Probar conexión primero',
|
||||
'memories.connected': 'Conectado',
|
||||
'memories.disconnected': 'No conectado',
|
||||
@@ -1923,6 +1951,8 @@ const es: Record<string, string> = {
|
||||
'notif.booking_change.text': '{actor} actualizó una reserva en {trip}',
|
||||
'notif.trip_reminder.title': 'Recordatorio de viaje',
|
||||
'notif.trip_reminder.text': '¡Tu viaje {trip} se acerca!',
|
||||
'notif.todo_due.title': 'Tarea pendiente',
|
||||
'notif.todo_due.text': '{todo} en {trip} vence el {due}',
|
||||
'notif.vacay_invite.title': 'Invitación Vacay Fusion',
|
||||
'notif.vacay_invite.text': '{actor} te invitó a fusionar planes de vacaciones',
|
||||
'notif.photos_shared.title': 'Fotos compartidas',
|
||||
@@ -2320,9 +2350,9 @@ const es: Record<string, string> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Una nota personal de mi parte',
|
||||
'system_notice.v3_thankyou.body': 'Antes de seguir — quiero tomarme un momento.\n\nTREK empezó como un proyecto personal que construí para mis propios viajes. Nunca imaginé que crecería hasta convertirse en algo en lo que 4.000 de vosotros confían para planificar sus aventuras. Cada estrella, cada issue, cada solicitud de funcionalidad — los leo todos, y son lo que me mantiene en pie durante las noches largas entre un trabajo a jornada completa y la universidad.\n\nQuiero que sepáis: TREK siempre será open source, siempre self-hosted, siempre vuestro. Sin rastreo, sin suscripciones, sin letra pequeña. Solo una herramienta hecha por alguien que ama viajar tanto como vosotros.\n\nUn agradecimiento especial a [jubnl](https://github.com/jubnl) — te has convertido en un colaborador increíble. Mucho de lo que hace grande la versión 3.0 lleva tu huella. Gracias por creer en este proyecto cuando todavía era un borrador.\n\nY a cada uno de vosotros que reportó un bug, tradujo un texto, compartió TREK con un amigo o simplemente lo usó para planificar un viaje — **gracias**. Vosotros sois la razón de que esto exista.\n\nPor muchas más aventuras juntos.\n\n— Maurice\n\n---\n\n[Únete a la comunidad en Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK mejora tus viajes, un [pequeño café](https://ko-fi.com/mauriceboe) siempre mantiene las luces encendidas.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.addTransport': 'Añadir transporte',
|
||||
'transport.modalTitle.create': 'Añadir transporte',
|
||||
'transport.modalTitle.edit': 'Editar transporte',
|
||||
'transport.title': 'Transportes',
|
||||
'transport.addManual': 'Transporte manual',
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ const fr: Record<string, string> = {
|
||||
'common.none': 'Aucun',
|
||||
'common.date': 'Date',
|
||||
'common.rename': 'Renommer',
|
||||
'common.discardChanges': 'Ignorer les modifications',
|
||||
'common.discard': 'Ignorer',
|
||||
'common.name': 'Nom',
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Mot de passe',
|
||||
@@ -199,6 +201,7 @@ const fr: Record<string, string> = {
|
||||
'settings.notifyTripInvite': 'Invitations de voyage',
|
||||
'settings.notifyBookingChange': 'Modifications de réservation',
|
||||
'settings.notifyTripReminder': 'Rappels de voyage',
|
||||
'settings.notifyTodoDue': 'Tâche à échéance',
|
||||
'settings.notifyVacayInvite': 'Invitations de fusion Vacay',
|
||||
'settings.notifyPhotosShared': 'Photos partagées (Immich)',
|
||||
'settings.notifyCollabMessage': 'Messages de chat (Collab)',
|
||||
@@ -451,6 +454,28 @@ const fr: Record<string, string> = {
|
||||
'login.oidcFailed': 'Échec de connexion OIDC',
|
||||
'login.usernameRequired': 'Le nom d\'utilisateur est obligatoire',
|
||||
'login.passwordMinLength': 'Le mot de passe doit comporter au moins 8 caractères',
|
||||
'login.forgotPassword': 'Mot de passe oublié ?',
|
||||
'login.forgotPasswordTitle': 'Réinitialiser votre mot de passe',
|
||||
'login.forgotPasswordBody': 'Entrez l\'adresse e-mail associée à votre compte. Si un compte existe, nous enverrons un lien de réinitialisation.',
|
||||
'login.forgotPasswordSubmit': 'Envoyer le lien',
|
||||
'login.forgotPasswordSentTitle': 'Vérifiez vos e-mails',
|
||||
'login.forgotPasswordSentBody': 'Si un compte existe pour cette adresse, un lien de réinitialisation est en route. Il expire dans 60 minutes.',
|
||||
'login.forgotPasswordSmtpHintOff': 'Remarque : votre administrateur n\'a pas configuré SMTP. Le lien de réinitialisation sera écrit dans la console du serveur au lieu d\'être envoyé par e-mail.',
|
||||
'login.backToLogin': 'Retour à la connexion',
|
||||
'login.newPassword': 'Nouveau mot de passe',
|
||||
'login.confirmPassword': 'Confirmer le nouveau mot de passe',
|
||||
'login.passwordsDontMatch': 'Les mots de passe ne correspondent pas',
|
||||
'login.mfaCode': 'Code 2FA',
|
||||
'login.resetPasswordTitle': 'Définir un nouveau mot de passe',
|
||||
'login.resetPasswordBody': 'Choisissez un mot de passe fort que vous n\'avez pas encore utilisé ici. 8 caractères minimum.',
|
||||
'login.resetPasswordMfaBody': 'Entrez votre code 2FA ou un code de secours pour finaliser la réinitialisation.',
|
||||
'login.resetPasswordSubmit': 'Réinitialiser',
|
||||
'login.resetPasswordVerify': 'Vérifier et réinitialiser',
|
||||
'login.resetPasswordSuccessTitle': 'Mot de passe mis à jour',
|
||||
'login.resetPasswordSuccessBody': 'Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.',
|
||||
'login.resetPasswordInvalidLink': 'Lien de réinitialisation invalide',
|
||||
'login.resetPasswordInvalidLinkBody': 'Ce lien est manquant ou invalide. Demandez-en un nouveau pour continuer.',
|
||||
'login.resetPasswordFailed': 'Échec de la réinitialisation. Le lien a peut-être expiré.',
|
||||
'login.oidc.tokenFailed': 'L\'authentification a échoué.',
|
||||
'login.oidc.invalidState': 'Session invalide. Veuillez réessayer.',
|
||||
'login.demoFailed': 'Échec de la connexion démo',
|
||||
@@ -1195,6 +1220,8 @@ const fr: Record<string, string> = {
|
||||
'files.title': 'Fichiers',
|
||||
'files.pageTitle': 'Fichiers et documents',
|
||||
'files.subtitle': '{count} fichiers pour {trip}',
|
||||
'files.download': 'Télécharger',
|
||||
'files.openError': "Impossible d'ouvrir le fichier",
|
||||
'files.downloadPdf': 'Télécharger le PDF',
|
||||
'files.count': '{count} fichiers',
|
||||
'files.countSingular': '1 fichier',
|
||||
@@ -1594,6 +1621,7 @@ const fr: Record<string, string> = {
|
||||
'memories.immichAutoUpload': 'Répliquer les photos du journey vers Immich au téléversement',
|
||||
'memories.providerUrlHintSynology': 'Incluez le chemin de l\'application Photos dans l\'URL, ex. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Tester la connexion',
|
||||
'memories.testShort': 'Tester',
|
||||
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
|
||||
'memories.connected': 'Connecté',
|
||||
'memories.disconnected': 'Non connecté',
|
||||
@@ -1917,6 +1945,8 @@ const fr: Record<string, string> = {
|
||||
'notif.booking_change.text': '{actor} a mis à jour une réservation dans {trip}',
|
||||
'notif.trip_reminder.title': 'Rappel de voyage',
|
||||
'notif.trip_reminder.text': 'Votre voyage {trip} approche !',
|
||||
'notif.todo_due.title': 'Tâche à échéance',
|
||||
'notif.todo_due.text': '{todo} dans {trip} est due le {due}',
|
||||
'notif.vacay_invite.title': 'Invitation Vacay Fusion',
|
||||
'notif.vacay_invite.text': '{actor} vous invite à fusionner les plans de vacances',
|
||||
'notif.photos_shared.title': 'Photos partagées',
|
||||
@@ -2314,9 +2344,9 @@ const fr: Record<string, string> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Un mot personnel de ma part',
|
||||
'system_notice.v3_thankyou.body': 'Avant de continuer — je veux prendre un instant.\n\nTREK a commencé comme un projet perso que j\'ai construit pour mes propres voyages. Je n\'aurais jamais imaginé qu\'il grandirait au point que 4 000 d\'entre vous lui fassent confiance pour planifier vos aventures. Chaque étoile, chaque issue, chaque demande de fonctionnalité — je les lis toutes, et ce sont elles qui me font tenir pendant les nuits blanches entre un travail à temps plein et l\'université.\n\nJe veux que vous sachiez : TREK sera toujours open source, toujours auto-hébergé, toujours à vous. Pas de tracking, pas d\'abonnements, pas de conditions cachées. Juste un outil construit par quelqu\'un qui aime voyager autant que vous.\n\nUn merci tout particulier à [jubnl](https://github.com/jubnl) — tu es devenu un collaborateur incroyable. Une grande partie de ce qui rend la 3.0 géniale porte ton empreinte. Merci d\'avoir cru en ce projet quand il était encore brut.\n\nEt à chacun d\'entre vous qui a signalé un bug, traduit une chaîne, partagé TREK avec un ami ou simplement l\'a utilisé pour planifier un voyage — **merci**. Vous êtes la raison pour laquelle tout ceci existe.\n\nÀ de nombreuses autres aventures ensemble.\n\n— Maurice\n\n---\n\n[Rejoins la communauté sur Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK rend tes voyages meilleurs, un [petit café](https://ko-fi.com/mauriceboe) aide toujours à garder les lumières allumées.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.addTransport': 'Ajouter un transport',
|
||||
'transport.modalTitle.create': 'Ajouter un transport',
|
||||
'transport.modalTitle.edit': 'Modifier le transport',
|
||||
'transport.title': 'Transports',
|
||||
'transport.addManual': 'Transport manuel',
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.none': 'Nincs',
|
||||
'common.date': 'Dátum',
|
||||
'common.rename': 'Átnevezés',
|
||||
'common.discardChanges': 'Változtatások elvetése',
|
||||
'common.discard': 'Elveti',
|
||||
'common.name': 'Név',
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Jelszó',
|
||||
@@ -199,6 +201,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyTripInvite': 'Utazási meghívók',
|
||||
'settings.notifyBookingChange': 'Foglalási változások',
|
||||
'settings.notifyTripReminder': 'Utazási emlékeztetők',
|
||||
'settings.notifyTodoDue': 'Teendő esedékes',
|
||||
'settings.notifyVacayInvite': 'Vacay összevonási meghívók',
|
||||
'settings.notifyPhotosShared': 'Megosztott fotók (Immich)',
|
||||
'settings.notifyCollabMessage': 'Csevegés üzenetek (Collab)',
|
||||
@@ -458,6 +461,28 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.oidcFailed': 'OIDC bejelentkezés sikertelen',
|
||||
'login.usernameRequired': 'A felhasználónév kötelező',
|
||||
'login.passwordMinLength': 'A jelszónak legalább 8 karakter hosszúnak kell lennie',
|
||||
'login.forgotPassword': 'Elfelejtetted a jelszavad?',
|
||||
'login.forgotPasswordTitle': 'Jelszó visszaállítása',
|
||||
'login.forgotPasswordBody': 'Írd be a regisztrációnál használt e-mail-címet. Ha létezik fiók, küldünk egy visszaállítási linket.',
|
||||
'login.forgotPasswordSubmit': 'Link küldése',
|
||||
'login.forgotPasswordSentTitle': 'Nézd meg az e-mailjeidet',
|
||||
'login.forgotPasswordSentBody': 'Ha létezik fiók ehhez az e-mailhez, a visszaállítási link úton van. 60 perc után lejár.',
|
||||
'login.forgotPasswordSmtpHintOff': 'Megjegyzés: a rendszergazda nem konfigurálta az SMTP-t, ezért a visszaállítási link e-mail helyett a szerverkonzolba kerül.',
|
||||
'login.backToLogin': 'Vissza a bejelentkezéshez',
|
||||
'login.newPassword': 'Új jelszó',
|
||||
'login.confirmPassword': 'Új jelszó megerősítése',
|
||||
'login.passwordsDontMatch': 'A jelszavak nem egyeznek',
|
||||
'login.mfaCode': '2FA-kód',
|
||||
'login.resetPasswordTitle': 'Új jelszó beállítása',
|
||||
'login.resetPasswordBody': 'Válassz erős jelszót, amit itt még nem használtál. Minimum 8 karakter.',
|
||||
'login.resetPasswordMfaBody': 'Add meg a 2FA-kódodat vagy egy tartalék kódot a visszaállítás befejezéséhez.',
|
||||
'login.resetPasswordSubmit': 'Jelszó visszaállítása',
|
||||
'login.resetPasswordVerify': 'Ellenőrzés és visszaállítás',
|
||||
'login.resetPasswordSuccessTitle': 'Jelszó frissítve',
|
||||
'login.resetPasswordSuccessBody': 'Mostantól bejelentkezhetsz az új jelszavaddal.',
|
||||
'login.resetPasswordInvalidLink': 'Érvénytelen visszaállítási link',
|
||||
'login.resetPasswordInvalidLinkBody': 'A link hiányzik vagy sérült. A folytatáshoz kérj egy újat.',
|
||||
'login.resetPasswordFailed': 'A visszaállítás nem sikerült. A link lehet, hogy lejárt.',
|
||||
|
||||
// Regisztráció
|
||||
'register.passwordMismatch': 'A jelszavak nem egyeznek',
|
||||
@@ -1196,6 +1221,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.title': 'Fájlok',
|
||||
'files.pageTitle': 'Fájlok és dokumentumok',
|
||||
'files.subtitle': '{count} fájl a következőhöz: {trip}',
|
||||
'files.download': 'Letöltés',
|
||||
'files.openError': 'A fájl megnyitása sikertelen',
|
||||
'files.downloadPdf': 'PDF letöltése',
|
||||
'files.count': '{count} fájl',
|
||||
'files.countSingular': '1 fájl',
|
||||
@@ -1665,6 +1692,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.immichAutoUpload': 'Journey-fotók feltöltésekor másolat Immich-be is',
|
||||
'memories.providerUrlHintSynology': 'Adja meg a Photos alkalmazás elérési útját az URL-ben, pl. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Kapcsolat tesztelése',
|
||||
'memories.testShort': 'Teszt',
|
||||
'memories.testFirst': 'Először teszteld a kapcsolatot',
|
||||
'memories.connected': 'Csatlakoztatva',
|
||||
'memories.disconnected': 'Nincs csatlakoztatva',
|
||||
@@ -1915,6 +1943,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notif.booking_change.text': '{actor} frissített egy foglalást a(z) {trip} utazásban',
|
||||
'notif.trip_reminder.title': 'Utazás emlékeztető',
|
||||
'notif.trip_reminder.text': 'A(z) {trip} utazás hamarosan kezdődik!',
|
||||
'notif.todo_due.title': 'Teendő esedékes',
|
||||
'notif.todo_due.text': '{todo} ({trip}) határideje: {due}',
|
||||
'notif.vacay_invite.title': 'Vacay Fusion meghívó',
|
||||
'notif.vacay_invite.text': '{actor} meghívott a nyaralási tervek összevonásához',
|
||||
'notif.photos_shared.title': 'Fotók megosztva',
|
||||
@@ -2315,9 +2345,9 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Egy személyes gondolat tőlem',
|
||||
'system_notice.v3_thankyou.body': 'Mielőtt továbbmennél — szeretnék egy pillanatra megállni.\n\nA TREK egy hobbiprojektként indult, amit a saját utazásaimhoz építettem. Sosem gondoltam volna, hogy valami olyanná nő, amire 4000-en bízzátok a kalandjaitok tervezését. Minden csillagot, minden issue-t, minden funkciókérést — mindet elolvasom, és ezek tartanak életben a késő éjszakákon a teljes állás és az egyetem között.\n\nSzeretnétek, ha tudnátok: a TREK mindig nyílt forráskódú marad, mindig self-hosted, mindig a tiétek. Nincs nyomkövetés, nincs előfizetés, nincsenek rejtett feltételek. Csak egy eszköz, amit valaki épített, aki ugyanúgy szereti az utazást, mint ti.\n\nKülönleges köszönet [jubnl](https://github.com/jubnl)-nek — hihetetlen társsá váltál. A 3.0 nagyszerűségének nagy része a te kézjegyedet viseli. Köszönöm, hogy hittél ebben a projektben, amikor még nyers volt.\n\nÉs mindannyiótoknak, akik hibát jelentettetek, szöveget fordítottatok, megosztottátok a TREK-et egy baráttal, vagy egyszerűen csak egy utazást terveztetek vele — **köszönöm**. Ti vagytok az ok, amiért ez létezik.\n\nSok további közös kalandért.\n\n— Maurice\n\n---\n\n[Csatlakozz a közösséghez a Discordon](https://discord.gg/7Q6M6jDwzf)\n\nHa a TREK jobbá teszi az utazásaidat, egy [kis kávé](https://ko-fi.com/mauriceboe) mindig segít, hogy égve maradjanak a fények.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.addTransport': 'Közlekedés hozzáadása',
|
||||
'transport.modalTitle.create': 'Közlekedés hozzáadása',
|
||||
'transport.modalTitle.edit': 'Közlekedés szerkesztése',
|
||||
'transport.title': 'Közlekedés',
|
||||
'transport.addManual': 'Kézi közlekedés',
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.none': 'Tidak ada',
|
||||
'common.date': 'Tanggal',
|
||||
'common.rename': 'Ganti nama',
|
||||
'common.discardChanges': 'Buang perubahan',
|
||||
'common.discard': 'Buang',
|
||||
'common.name': 'Nama',
|
||||
'common.email': 'Email',
|
||||
'common.password': 'Kata sandi',
|
||||
@@ -202,6 +204,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyTripInvite': 'Undangan perjalanan',
|
||||
'settings.notifyBookingChange': 'Perubahan pemesanan',
|
||||
'settings.notifyTripReminder': 'Pengingat perjalanan',
|
||||
'settings.notifyTodoDue': 'Tugas jatuh tempo',
|
||||
'settings.notifyVacayInvite': 'Undangan Vacay fusion',
|
||||
'settings.notifyPhotosShared': 'Foto dibagikan (Immich)',
|
||||
'settings.notifyCollabMessage': 'Pesan chat (Collab)',
|
||||
@@ -520,6 +523,28 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.oidcFailed': 'Login OIDC gagal',
|
||||
'login.usernameRequired': 'Nama pengguna wajib diisi',
|
||||
'login.passwordMinLength': 'Kata sandi minimal 8 karakter',
|
||||
'login.forgotPassword': 'Lupa kata sandi?',
|
||||
'login.forgotPasswordTitle': 'Setel ulang kata sandi',
|
||||
'login.forgotPasswordBody': 'Masukkan alamat email akunmu. Jika akun ada, kami akan mengirim tautan reset.',
|
||||
'login.forgotPasswordSubmit': 'Kirim tautan',
|
||||
'login.forgotPasswordSentTitle': 'Periksa email kamu',
|
||||
'login.forgotPasswordSentBody': 'Jika ada akun dengan email tersebut, tautannya sedang dikirim. Berlaku 60 menit.',
|
||||
'login.forgotPasswordSmtpHintOff': 'Catatan: administrator belum mengonfigurasi SMTP, jadi tautan reset akan ditulis ke konsol server alih-alih dikirim lewat email.',
|
||||
'login.backToLogin': 'Kembali ke login',
|
||||
'login.newPassword': 'Kata sandi baru',
|
||||
'login.confirmPassword': 'Konfirmasi kata sandi baru',
|
||||
'login.passwordsDontMatch': 'Kata sandi tidak cocok',
|
||||
'login.mfaCode': 'Kode 2FA',
|
||||
'login.resetPasswordTitle': 'Tetapkan kata sandi baru',
|
||||
'login.resetPasswordBody': 'Pilih kata sandi kuat yang belum pernah kamu pakai di sini. Minimal 8 karakter.',
|
||||
'login.resetPasswordMfaBody': 'Masukkan kode 2FA atau kode cadangan untuk menyelesaikan reset.',
|
||||
'login.resetPasswordSubmit': 'Setel ulang kata sandi',
|
||||
'login.resetPasswordVerify': 'Verifikasi & setel ulang',
|
||||
'login.resetPasswordSuccessTitle': 'Kata sandi diperbarui',
|
||||
'login.resetPasswordSuccessBody': 'Sekarang kamu bisa login dengan kata sandi baru.',
|
||||
'login.resetPasswordInvalidLink': 'Tautan tidak valid',
|
||||
'login.resetPasswordInvalidLinkBody': 'Tautan hilang atau rusak. Minta tautan baru untuk melanjutkan.',
|
||||
'login.resetPasswordFailed': 'Reset gagal. Tautan mungkin sudah kedaluwarsa.',
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'Kata sandi tidak cocok',
|
||||
@@ -1256,6 +1281,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.title': 'File',
|
||||
'files.pageTitle': 'File & Dokumen',
|
||||
'files.subtitle': '{count} file untuk {trip}',
|
||||
'files.download': 'Unduh',
|
||||
'files.openError': 'Tidak dapat membuka file',
|
||||
'files.downloadPdf': 'Unduh PDF',
|
||||
'files.count': '{count} file',
|
||||
'files.countSingular': '1 berkas',
|
||||
@@ -1657,6 +1684,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.immichAutoUpload': 'Salin foto journey ke Immich saat diunggah',
|
||||
'memories.providerUrlHintSynology': 'Sertakan path aplikasi Photos di URL, mis. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Uji koneksi',
|
||||
'memories.testShort': 'Uji',
|
||||
'memories.testFirst': 'Uji koneksi terlebih dahulu',
|
||||
'memories.connected': 'Terhubung',
|
||||
'memories.disconnected': 'Tidak terhubung',
|
||||
@@ -1924,6 +1952,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notif.booking_change.text': '{actor} memperbarui pemesanan di {trip}',
|
||||
'notif.trip_reminder.title': 'Pengingat Perjalanan',
|
||||
'notif.trip_reminder.text': 'Perjalananmu {trip} akan segera dimulai!',
|
||||
'notif.todo_due.title': 'Tugas jatuh tempo',
|
||||
'notif.todo_due.text': '{todo} di {trip} jatuh tempo pada {due}',
|
||||
'notif.vacay_invite.title': 'Undangan Vacay Fusion',
|
||||
'notif.vacay_invite.text': '{actor} mengundangmu untuk menggabungkan rencana liburan',
|
||||
'notif.photos_shared.title': 'Foto Dibagikan',
|
||||
@@ -2356,9 +2386,9 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Catatan pribadi dari saya',
|
||||
'system_notice.v3_thankyou.body': 'Sebelum kamu lanjut — saya ingin berhenti sejenak.\n\nTREK dimulai sebagai proyek sampingan yang saya buat untuk perjalanan saya sendiri. Saya tidak pernah membayangkan ia akan tumbuh menjadi sesuatu yang dipercaya oleh 4.000 dari kalian untuk merencanakan petualangan. Setiap bintang, setiap issue, setiap permintaan fitur — saya membaca semuanya, dan itulah yang membuat saya terus bertahan di malam-malam larut antara pekerjaan penuh waktu dan kuliah.\n\nSaya ingin kalian tahu: TREK akan selalu open source, selalu self-hosted, selalu milik kalian. Tanpa pelacakan, tanpa langganan, tanpa syarat tersembunyi. Hanya sebuah alat yang dibuat oleh seseorang yang mencintai traveling sama seperti kalian.\n\nTerima kasih khusus untuk [jubnl](https://github.com/jubnl) — kamu telah menjadi kolaborator yang luar biasa. Begitu banyak hal yang membuat versi 3.0 hebat memiliki jejakmu. Terima kasih telah percaya pada proyek ini ketika masih kasar.\n\nDan untuk setiap dari kalian yang melaporkan bug, menerjemahkan string, membagikan TREK kepada teman, atau sekadar menggunakannya untuk merencanakan perjalanan — **terima kasih**. Kalianlah alasan semua ini ada.\n\nUntuk lebih banyak petualangan bersama.\n\n— Maurice\n\n---\n\n[Bergabunglah dengan komunitas di Discord](https://discord.gg/7Q6M6jDwzf)\n\nJika TREK membuat perjalananmu lebih baik, [secangkir kopi kecil](https://ko-fi.com/mauriceboe) selalu membantu menjaga lampu tetap menyala.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.addTransport': 'Tambah transportasi',
|
||||
'transport.modalTitle.create': 'Tambah transportasi',
|
||||
'transport.modalTitle.edit': 'Edit transportasi',
|
||||
'transport.title': 'Transportasi',
|
||||
'transport.addManual': 'Transportasi Manual',
|
||||
};
|
||||
|
||||
@@ -30,6 +30,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.none': 'Nessuno',
|
||||
'common.date': 'Data',
|
||||
'common.rename': 'Rinomina',
|
||||
'common.discardChanges': 'Scarta modifiche',
|
||||
'common.discard': 'Scarta',
|
||||
'common.name': 'Nome',
|
||||
'common.email': 'Email',
|
||||
'common.password': 'Password',
|
||||
@@ -199,6 +201,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyTripInvite': 'Inviti di viaggio',
|
||||
'settings.notifyBookingChange': 'Modifiche alle prenotazioni',
|
||||
'settings.notifyTripReminder': 'Promemoria di viaggio',
|
||||
'settings.notifyTodoDue': 'Attività in scadenza',
|
||||
'settings.notifyVacayInvite': 'Inviti fusione Vacay',
|
||||
'settings.notifyPhotosShared': 'Foto condivise (Immich)',
|
||||
'settings.notifyCollabMessage': 'Messaggi chat (Collab)',
|
||||
@@ -458,6 +461,28 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.oidcFailed': 'Accesso OIDC non riuscito',
|
||||
'login.usernameRequired': 'Il nome utente è obbligatorio',
|
||||
'login.passwordMinLength': 'La password deve contenere almeno 8 caratteri',
|
||||
'login.forgotPassword': 'Password dimenticata?',
|
||||
'login.forgotPasswordTitle': 'Reimposta la password',
|
||||
'login.forgotPasswordBody': 'Inserisci l’indirizzo email del tuo account. Se esiste un account, invieremo un link per reimpostarla.',
|
||||
'login.forgotPasswordSubmit': 'Invia link',
|
||||
'login.forgotPasswordSentTitle': 'Controlla la tua email',
|
||||
'login.forgotPasswordSentBody': 'Se esiste un account con questa email, il link è in arrivo. Scade tra 60 minuti.',
|
||||
'login.forgotPasswordSmtpHintOff': 'Nota: il tuo amministratore non ha configurato SMTP, quindi il link di reset verrà scritto nella console del server invece di essere inviato via email.',
|
||||
'login.backToLogin': 'Torna all’accesso',
|
||||
'login.newPassword': 'Nuova password',
|
||||
'login.confirmPassword': 'Conferma nuova password',
|
||||
'login.passwordsDontMatch': 'Le password non corrispondono',
|
||||
'login.mfaCode': 'Codice 2FA',
|
||||
'login.resetPasswordTitle': 'Imposta una nuova password',
|
||||
'login.resetPasswordBody': 'Scegli una password robusta che non hai già usato qui. Minimo 8 caratteri.',
|
||||
'login.resetPasswordMfaBody': 'Inserisci il codice 2FA o un codice di backup per completare il reset.',
|
||||
'login.resetPasswordSubmit': 'Reimposta password',
|
||||
'login.resetPasswordVerify': 'Verifica e reimposta',
|
||||
'login.resetPasswordSuccessTitle': 'Password aggiornata',
|
||||
'login.resetPasswordSuccessBody': 'Ora puoi accedere con la nuova password.',
|
||||
'login.resetPasswordInvalidLink': 'Link di reset non valido',
|
||||
'login.resetPasswordInvalidLinkBody': 'Il link è mancante o danneggiato. Richiedine uno nuovo per continuare.',
|
||||
'login.resetPasswordFailed': 'Reset non riuscito. Il link potrebbe essere scaduto.',
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'Le password non corrispondono',
|
||||
@@ -1196,6 +1221,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.title': 'File',
|
||||
'files.pageTitle': 'File e documenti',
|
||||
'files.subtitle': '{count} file per {trip}',
|
||||
'files.download': 'Scarica',
|
||||
'files.openError': 'Impossibile aprire il file',
|
||||
'files.downloadPdf': 'Scarica PDF',
|
||||
'files.count': '{count} file',
|
||||
'files.countSingular': '1 documento',
|
||||
@@ -1595,6 +1622,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.immichAutoUpload': 'Rispecchia le foto del journey su Immich al caricamento',
|
||||
'memories.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Test connessione',
|
||||
'memories.testShort': 'Prova',
|
||||
'memories.testFirst': 'Testa prima la connessione',
|
||||
'memories.connected': 'Connesso',
|
||||
'memories.disconnected': 'Non connesso',
|
||||
@@ -1918,6 +1946,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notif.booking_change.text': '{actor} ha aggiornato una prenotazione in {trip}',
|
||||
'notif.trip_reminder.title': 'Promemoria viaggio',
|
||||
'notif.trip_reminder.text': 'Il tuo viaggio {trip} si avvicina!',
|
||||
'notif.todo_due.title': 'Attività in scadenza',
|
||||
'notif.todo_due.text': '{todo} in {trip} scade il {due}',
|
||||
'notif.vacay_invite.title': 'Invito Vacay Fusion',
|
||||
'notif.vacay_invite.text': '{actor} ti ha invitato a fondere i piani vacanza',
|
||||
'notif.photos_shared.title': 'Foto condivise',
|
||||
@@ -2315,9 +2345,9 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Una nota personale da parte mia',
|
||||
'system_notice.v3_thankyou.body': 'Prima di andare avanti — voglio prendermi un momento.\n\nTREK è nato come un progetto secondario che ho costruito per i miei viaggi. Non avrei mai immaginato che sarebbe cresciuto fino a diventare qualcosa di cui 4.000 di voi si fidano per pianificare le proprie avventure. Ogni stella, ogni issue, ogni richiesta di funzionalità — le leggo tutte, e sono loro a tenermi in piedi nelle notti tarde tra un lavoro a tempo pieno e l\'università.\n\nVoglio che sappiate: TREK sarà sempre open source, sempre self-hosted, sempre vostro. Nessun tracciamento, nessun abbonamento, nessuna fregatura. Solo uno strumento creato da qualcuno che ama viaggiare tanto quanto voi.\n\nUn ringraziamento speciale a [jubnl](https://github.com/jubnl) — sei diventato un collaboratore incredibile. Molto di ciò che rende la 3.0 fantastica porta la tua impronta. Grazie per aver creduto in questo progetto quando era ancora acerbo.\n\nE a ognuno di voi che ha segnalato un bug, tradotto una stringa, condiviso TREK con un amico o semplicemente lo ha usato per pianificare un viaggio — **grazie**. Voi siete il motivo per cui tutto questo esiste.\n\nA molte altre avventure insieme.\n\n— Maurice\n\n---\n\n[Unisciti alla community su Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe TREK rende i tuoi viaggi migliori, un [piccolo caffè](https://ko-fi.com/mauriceboe) aiuta sempre a tenere le luci accese.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.addTransport': 'Aggiungi trasporto',
|
||||
'transport.modalTitle.create': 'Aggiungi trasporto',
|
||||
'transport.modalTitle.edit': 'Modifica trasporto',
|
||||
'transport.title': 'Trasporti',
|
||||
'transport.addManual': 'Trasporto manuale',
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ const nl: Record<string, string> = {
|
||||
'common.none': 'Geen',
|
||||
'common.date': 'Datum',
|
||||
'common.rename': 'Hernoemen',
|
||||
'common.discardChanges': 'Wijzigingen verwerpen',
|
||||
'common.discard': 'Verwerpen',
|
||||
'common.name': 'Naam',
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Wachtwoord',
|
||||
@@ -199,6 +201,7 @@ const nl: Record<string, string> = {
|
||||
'settings.notifyTripInvite': 'Reisuitnodigingen',
|
||||
'settings.notifyBookingChange': 'Boekingswijzigingen',
|
||||
'settings.notifyTripReminder': 'Reisherinneringen',
|
||||
'settings.notifyTodoDue': 'Taak verloopt',
|
||||
'settings.notifyVacayInvite': 'Vacay-fusieuitnodigingen',
|
||||
'settings.notifyPhotosShared': 'Gedeelde foto\'s (Immich)',
|
||||
'settings.notifyCollabMessage': 'Chatberichten (Collab)',
|
||||
@@ -451,6 +454,28 @@ const nl: Record<string, string> = {
|
||||
'login.oidcFailed': 'OIDC-aanmelding mislukt',
|
||||
'login.usernameRequired': 'Gebruikersnaam is vereist',
|
||||
'login.passwordMinLength': 'Wachtwoord moet minimaal 8 tekens bevatten',
|
||||
'login.forgotPassword': 'Wachtwoord vergeten?',
|
||||
'login.forgotPasswordTitle': 'Wachtwoord resetten',
|
||||
'login.forgotPasswordBody': 'Voer het e-mailadres van je account in. Als er een account bestaat, sturen we een resetlink.',
|
||||
'login.forgotPasswordSubmit': 'Resetlink verzenden',
|
||||
'login.forgotPasswordSentTitle': 'Controleer je e-mail',
|
||||
'login.forgotPasswordSentBody': 'Als er een account bestaat met dit adres, is de resetlink onderweg. Hij verloopt over 60 minuten.',
|
||||
'login.forgotPasswordSmtpHintOff': 'Let op: de beheerder heeft SMTP niet ingesteld. De resetlink wordt naar de serverconsole geschreven in plaats van via e-mail verzonden.',
|
||||
'login.backToLogin': 'Terug naar inloggen',
|
||||
'login.newPassword': 'Nieuw wachtwoord',
|
||||
'login.confirmPassword': 'Nieuw wachtwoord bevestigen',
|
||||
'login.passwordsDontMatch': 'Wachtwoorden komen niet overeen',
|
||||
'login.mfaCode': '2FA-code',
|
||||
'login.resetPasswordTitle': 'Nieuw wachtwoord instellen',
|
||||
'login.resetPasswordBody': 'Kies een sterk wachtwoord dat je hier nog niet hebt gebruikt. Minimaal 8 tekens.',
|
||||
'login.resetPasswordMfaBody': 'Voer je 2FA-code of een back-upcode in om de reset te voltooien.',
|
||||
'login.resetPasswordSubmit': 'Wachtwoord resetten',
|
||||
'login.resetPasswordVerify': 'Verifiëren en resetten',
|
||||
'login.resetPasswordSuccessTitle': 'Wachtwoord bijgewerkt',
|
||||
'login.resetPasswordSuccessBody': 'Je kunt nu inloggen met je nieuwe wachtwoord.',
|
||||
'login.resetPasswordInvalidLink': 'Ongeldige resetlink',
|
||||
'login.resetPasswordInvalidLinkBody': 'Deze link ontbreekt of is ongeldig. Vraag een nieuwe aan om door te gaan.',
|
||||
'login.resetPasswordFailed': 'Resetten mislukt. De link is mogelijk verlopen.',
|
||||
'login.oidc.tokenFailed': 'Authenticatie mislukt.',
|
||||
'login.oidc.invalidState': 'Ongeldige sessie. Probeer het opnieuw.',
|
||||
'login.demoFailed': 'Demo-login mislukt',
|
||||
@@ -589,8 +614,8 @@ const nl: Record<string, string> = {
|
||||
'admin.collab.chat.subtitle': 'Realtime berichten voor reissamenwerking',
|
||||
'admin.collab.notes.title': 'Notities',
|
||||
'admin.collab.notes.subtitle': 'Gedeelde notities en documenten',
|
||||
'admin.collab.polls.title': 'Peilingen',
|
||||
'admin.collab.polls.subtitle': 'Groepspeilingen en stemmen',
|
||||
'admin.collab.polls.title': 'Polls',
|
||||
'admin.collab.polls.subtitle': 'Groepspolls en stemmen',
|
||||
'admin.collab.whatsnext.title': 'Wat nu',
|
||||
'admin.collab.whatsnext.subtitle': 'Activiteitssuggesties en volgende stappen',
|
||||
'admin.tabs.config': 'Personalisatie',
|
||||
@@ -1195,6 +1220,8 @@ const nl: Record<string, string> = {
|
||||
'files.title': 'Bestanden',
|
||||
'files.pageTitle': 'Bestanden en documenten',
|
||||
'files.subtitle': '{count} bestanden voor {trip}',
|
||||
'files.download': 'Downloaden',
|
||||
'files.openError': 'Bestand kon niet worden geopend',
|
||||
'files.downloadPdf': 'PDF downloaden',
|
||||
'files.count': '{count} bestanden',
|
||||
'files.countSingular': '1 bestand',
|
||||
@@ -1594,6 +1621,7 @@ const nl: Record<string, string> = {
|
||||
'memories.immichAutoUpload': 'Journey-foto\'s bij upload ook naar Immich spiegelen',
|
||||
'memories.providerUrlHintSynology': 'Voeg het pad van de Photos-app toe aan de URL, bijv. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Verbinding testen',
|
||||
'memories.testShort': 'Testen',
|
||||
'memories.testFirst': 'Test eerst de verbinding',
|
||||
'memories.connected': 'Verbonden',
|
||||
'memories.disconnected': 'Niet verbonden',
|
||||
@@ -1633,7 +1661,7 @@ const nl: Record<string, string> = {
|
||||
// Collab Addon
|
||||
'collab.tabs.chat': 'Chat',
|
||||
'collab.tabs.notes': 'Notities',
|
||||
'collab.tabs.polls': 'Peilingen',
|
||||
'collab.tabs.polls': 'Polls',
|
||||
'collab.whatsNext.title': 'Wat komt er',
|
||||
'collab.whatsNext.today': 'Vandaag',
|
||||
'collab.whatsNext.tomorrow': 'Morgen',
|
||||
@@ -1679,7 +1707,7 @@ const nl: Record<string, string> = {
|
||||
'collab.notes.attachFiles': 'Bestanden bijvoegen',
|
||||
'collab.notes.noCategoriesYet': 'Nog geen categorieën',
|
||||
'collab.notes.emptyDesc': 'Maak een notitie om te beginnen',
|
||||
'collab.polls.title': 'Peilingen',
|
||||
'collab.polls.title': 'Polls',
|
||||
'collab.polls.new': 'Nieuwe poll',
|
||||
'collab.polls.empty': 'Nog geen polls',
|
||||
'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen',
|
||||
@@ -1917,6 +1945,8 @@ const nl: Record<string, string> = {
|
||||
'notif.booking_change.text': '{actor} heeft een boeking bijgewerkt in {trip}',
|
||||
'notif.trip_reminder.title': 'Reisherinnering',
|
||||
'notif.trip_reminder.text': 'Je reis {trip} komt eraan!',
|
||||
'notif.todo_due.title': 'Taak verloopt',
|
||||
'notif.todo_due.text': '{todo} in {trip} verloopt op {due}',
|
||||
'notif.vacay_invite.title': 'Vacay Fusion-uitnodiging',
|
||||
'notif.vacay_invite.text': '{actor} nodigt je uit om vakantieplannen te fuseren',
|
||||
'notif.photos_shared.title': 'Foto\'s gedeeld',
|
||||
@@ -2314,9 +2344,9 @@ const nl: Record<string, string> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Een persoonlijk woord van mij',
|
||||
'system_notice.v3_thankyou.body': 'Voordat je verdergaat — ik wil even stilstaan.\n\nTREK begon als een zijproject dat ik bouwde voor mijn eigen reizen. Ik had nooit gedacht dat het zou uitgroeien tot iets waar 4.000 van jullie op vertrouwen om avonturen te plannen. Elke ster, elke issue, elk functieverzoek — ik lees ze allemaal, en ze houden me op de been tijdens de late avonden tussen een fulltime baan en de universiteit.\n\nIk wil dat jullie weten: TREK zal altijd open source zijn, altijd self-hosted, altijd van jullie. Geen tracking, geen abonnementen, geen addertjes. Gewoon een tool gebouwd door iemand die net zo veel van reizen houdt als jullie.\n\nSpeciale dank aan [jubnl](https://github.com/jubnl) — je bent een ongelooflijke medewerker geworden. Zo veel van wat 3.0 geweldig maakt draagt jouw vingerafdruk. Bedankt dat je in dit project geloofde toen het nog ruw was.\n\nEn aan ieder van jullie die een bug meldde, een string vertaalde, TREK deelde met een vriend of het simpelweg gebruikte om een reis te plannen — **bedankt**. Jullie zijn de reden dat dit bestaat.\n\nOp nog vele avonturen samen.\n\n— Maurice\n\n---\n\n[Sluit je aan bij de community op Discord](https://discord.gg/7Q6M6jDwzf)\n\nAls TREK je reizen beter maakt, houdt een [klein kopje koffie](https://ko-fi.com/mauriceboe) altijd de lichten aan.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.addTransport': 'Vervoer toevoegen',
|
||||
'transport.modalTitle.create': 'Vervoer toevoegen',
|
||||
'transport.modalTitle.edit': 'Vervoer bewerken',
|
||||
'transport.title': 'Transport',
|
||||
'transport.addManual': 'Handmatig transport',
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.none': 'Brak',
|
||||
'common.date': 'Data',
|
||||
'common.rename': 'Zmień nazwę',
|
||||
'common.discardChanges': 'Odrzuć zmiany',
|
||||
'common.discard': 'Odrzuć',
|
||||
'common.name': 'Nazwa',
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Hasło',
|
||||
@@ -182,6 +184,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyTripInvite': 'Zaproszenia do podróży',
|
||||
'settings.notifyBookingChange': 'Zmiany w rezerwacjach',
|
||||
'settings.notifyTripReminder': 'Przypomnienia o podróżach',
|
||||
'settings.notifyTodoDue': 'Zadanie z terminem',
|
||||
'settings.notifyVacayInvite': 'Zaproszenia do połączenia kalendarzy',
|
||||
'settings.notifyPhotosShared': 'Udostępnione zdjęcia (Immich)',
|
||||
'settings.notifyCollabMessage': 'Wiadomości czatu (Collab)',
|
||||
@@ -425,6 +428,28 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.oidcFailed': 'Logowanie OIDC nie powiodło się',
|
||||
'login.usernameRequired': 'Nazwa użytkownika jest wymagana',
|
||||
'login.passwordMinLength': 'Hasło musi mieć co najmniej 8 znaków',
|
||||
'login.forgotPassword': 'Nie pamiętasz hasła?',
|
||||
'login.forgotPasswordTitle': 'Zresetuj hasło',
|
||||
'login.forgotPasswordBody': 'Wpisz adres e-mail użyty przy rejestracji. Jeśli konto istnieje, wyślemy link do resetu.',
|
||||
'login.forgotPasswordSubmit': 'Wyślij link',
|
||||
'login.forgotPasswordSentTitle': 'Sprawdź swoją pocztę',
|
||||
'login.forgotPasswordSentBody': 'Jeśli istnieje konto dla tego adresu, link jest już w drodze. Wygaśnie za 60 minut.',
|
||||
'login.forgotPasswordSmtpHintOff': 'Uwaga: administrator nie skonfigurował SMTP, więc link resetujący zostanie zapisany w konsoli serwera zamiast wysłania e-mailem.',
|
||||
'login.backToLogin': 'Wróć do logowania',
|
||||
'login.newPassword': 'Nowe hasło',
|
||||
'login.confirmPassword': 'Potwierdź nowe hasło',
|
||||
'login.passwordsDontMatch': 'Hasła nie są zgodne',
|
||||
'login.mfaCode': 'Kod 2FA',
|
||||
'login.resetPasswordTitle': 'Ustaw nowe hasło',
|
||||
'login.resetPasswordBody': 'Wybierz silne hasło, którego tu jeszcze nie używałeś. Minimum 8 znaków.',
|
||||
'login.resetPasswordMfaBody': 'Wpisz kod 2FA lub kod zapasowy, aby zakończyć reset.',
|
||||
'login.resetPasswordSubmit': 'Zresetuj hasło',
|
||||
'login.resetPasswordVerify': 'Zweryfikuj i zresetuj',
|
||||
'login.resetPasswordSuccessTitle': 'Hasło zaktualizowane',
|
||||
'login.resetPasswordSuccessBody': 'Możesz się teraz zalogować nowym hasłem.',
|
||||
'login.resetPasswordInvalidLink': 'Nieprawidłowy link',
|
||||
'login.resetPasswordInvalidLinkBody': 'Brakuje linku lub jest uszkodzony. Poproś o nowy, aby kontynuować.',
|
||||
'login.resetPasswordFailed': 'Reset nie powiódł się. Link mógł wygasnąć.',
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'Hasła nie są identyczne',
|
||||
@@ -1147,6 +1172,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.title': 'Pliki',
|
||||
'files.pageTitle': 'Pliki i dokumenty',
|
||||
'files.subtitle': '{count} plików dla {trip}',
|
||||
'files.download': 'Pobierz',
|
||||
'files.openError': 'Nie można otworzyć pliku',
|
||||
'files.downloadPdf': 'Pobierz PDF',
|
||||
'files.count': '{count} plików',
|
||||
'files.countSingular': '1 plik',
|
||||
@@ -1546,6 +1573,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.immichAutoUpload': 'Przy przesyłaniu kopiuj zdjęcia journey także do Immich',
|
||||
'memories.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Test',
|
||||
'memories.testShort': 'Test',
|
||||
'memories.connected': 'Połączono',
|
||||
'memories.disconnected': 'Nie połączono',
|
||||
'memories.connectionSuccess': 'Połączono z Immich',
|
||||
@@ -1907,6 +1935,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notif.booking_change.text': '{actor} zaktualizował rezerwację w {trip}',
|
||||
'notif.trip_reminder.title': 'Przypomnienie o podróży',
|
||||
'notif.trip_reminder.text': 'Twoja podróż {trip} zbliża się!',
|
||||
'notif.todo_due.title': 'Zadanie z terminem',
|
||||
'notif.todo_due.text': '{todo} w {trip} — termin {due}',
|
||||
'notif.vacay_invite.title': 'Zaproszenie Vacay Fusion',
|
||||
'notif.vacay_invite.text': '{actor} zaprosił Cię do połączenia planów urlopowych',
|
||||
'notif.photos_shared.title': 'Zdjęcia udostępnione',
|
||||
@@ -2307,9 +2337,9 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Osobiste słowo ode mnie',
|
||||
'system_notice.v3_thankyou.body': 'Zanim pójdziesz dalej — chcę się na chwilę zatrzymać.\n\nTREK zaczął się jako poboczny projekt, który zbudowałem na własne podróże. Nigdy nie wyobrażałem sobie, że wyrośnie na coś, czemu 4000 z was ufa przy planowaniu swoich przygód. Każda gwiazdka, każdy issue, każda prośba o funkcję — czytam je wszystkie i to one trzymają mnie na nogach podczas późnych nocy między pracą na pełny etat a uczelnią.\n\nChcę, żebyście wiedzieli: TREK zawsze będzie open source, zawsze self-hosted, zawsze wasz. Bez śledzenia, bez subskrypcji, bez haczyków. Po prostu narzędzie zbudowane przez kogoś, kto kocha podróżowanie tak samo jak wy.\n\nSzczególne podziękowania dla [jubnl](https://github.com/jubnl) — stałeś się niesamowitym współpracownikiem. Tak wiele z tego, co czyni wersję 3.0 wspaniałą, nosi twój ślad. Dziękuję, że uwierzyłeś w ten projekt, gdy był jeszcze surowy.\n\nI każdemu z was, kto zgłosił błąd, przetłumaczył tekst, podzielił się TREK z przyjacielem lub po prostu użył go do zaplanowania podróży — **dziękuję**. To wy jesteście powodem, dla którego to istnieje.\n\nZa wiele kolejnych wspólnych przygód.\n\n— Maurice\n\n---\n\n[Dołącz do społeczności na Discordzie](https://discord.gg/7Q6M6jDwzf)\n\nJeśli TREK sprawia, że Twoje podróże są lepsze, [mała kawa](https://ko-fi.com/mauriceboe) zawsze pomaga utrzymać światła włączone.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.addTransport': 'Dodaj transport',
|
||||
'transport.modalTitle.create': 'Dodaj transport',
|
||||
'transport.modalTitle.edit': 'Edytuj transport',
|
||||
'transport.title': 'Transport',
|
||||
'transport.addManual': 'Ręczny transport',
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ const ru: Record<string, string> = {
|
||||
'common.none': 'Нет',
|
||||
'common.date': 'Дата',
|
||||
'common.rename': 'Переименовать',
|
||||
'common.discardChanges': 'Отменить изменения',
|
||||
'common.discard': 'Отменить',
|
||||
'common.name': 'Имя',
|
||||
'common.email': 'Эл. почта',
|
||||
'common.password': 'Пароль',
|
||||
@@ -199,6 +201,7 @@ const ru: Record<string, string> = {
|
||||
'settings.notifyTripInvite': 'Приглашения в поездку',
|
||||
'settings.notifyBookingChange': 'Изменения бронирований',
|
||||
'settings.notifyTripReminder': 'Напоминания о поездке',
|
||||
'settings.notifyTodoDue': 'Задача к сроку',
|
||||
'settings.notifyVacayInvite': 'Приглашения слияния Vacay',
|
||||
'settings.notifyPhotosShared': 'Общие фото (Immich)',
|
||||
'settings.notifyCollabMessage': 'Сообщения чата (Collab)',
|
||||
@@ -451,6 +454,28 @@ const ru: Record<string, string> = {
|
||||
'login.oidcFailed': 'Ошибка входа через OIDC',
|
||||
'login.usernameRequired': 'Имя пользователя обязательно',
|
||||
'login.passwordMinLength': 'Пароль должен содержать не менее 8 символов',
|
||||
'login.forgotPassword': 'Забыли пароль?',
|
||||
'login.forgotPasswordTitle': 'Сброс пароля',
|
||||
'login.forgotPasswordBody': 'Введите e-mail, с которым вы регистрировались. Если аккаунт найдём — отправим ссылку для сброса.',
|
||||
'login.forgotPasswordSubmit': 'Отправить ссылку',
|
||||
'login.forgotPasswordSentTitle': 'Проверьте почту',
|
||||
'login.forgotPasswordSentBody': 'Если аккаунт существует, ссылка для сброса уже летит к вам. Она действительна 60 минут.',
|
||||
'login.forgotPasswordSmtpHintOff': 'Обратите внимание: администратор не настроил SMTP, поэтому ссылка для сброса будет записана в консоль сервера, а не отправлена по почте.',
|
||||
'login.backToLogin': 'Вернуться ко входу',
|
||||
'login.newPassword': 'Новый пароль',
|
||||
'login.confirmPassword': 'Подтвердите новый пароль',
|
||||
'login.passwordsDontMatch': 'Пароли не совпадают',
|
||||
'login.mfaCode': 'Код 2FA',
|
||||
'login.resetPasswordTitle': 'Задайте новый пароль',
|
||||
'login.resetPasswordBody': 'Выберите надёжный пароль, который вы здесь ещё не использовали. Минимум 8 символов.',
|
||||
'login.resetPasswordMfaBody': 'Введите код 2FA или резервный код, чтобы завершить сброс.',
|
||||
'login.resetPasswordSubmit': 'Сбросить пароль',
|
||||
'login.resetPasswordVerify': 'Проверить и сбросить',
|
||||
'login.resetPasswordSuccessTitle': 'Пароль обновлён',
|
||||
'login.resetPasswordSuccessBody': 'Теперь вы можете войти с новым паролем.',
|
||||
'login.resetPasswordInvalidLink': 'Неверная ссылка сброса',
|
||||
'login.resetPasswordInvalidLinkBody': 'Ссылка отсутствует или повреждена. Запросите новую, чтобы продолжить.',
|
||||
'login.resetPasswordFailed': 'Сброс не удался. Возможно, срок действия ссылки истёк.',
|
||||
'login.oidc.tokenFailed': 'Аутентификация не удалась.',
|
||||
'login.oidc.invalidState': 'Недействительная сессия. Попробуйте снова.',
|
||||
'login.demoFailed': 'Ошибка демо-входа',
|
||||
@@ -1195,6 +1220,8 @@ const ru: Record<string, string> = {
|
||||
'files.title': 'Файлы',
|
||||
'files.pageTitle': 'Файлы и документы',
|
||||
'files.subtitle': '{count} файлов для {trip}',
|
||||
'files.download': 'Скачать',
|
||||
'files.openError': 'Не удалось открыть файл',
|
||||
'files.downloadPdf': 'Скачать PDF',
|
||||
'files.count': '{count} файлов',
|
||||
'files.countSingular': '1 файл',
|
||||
@@ -1594,6 +1621,7 @@ const ru: Record<string, string> = {
|
||||
'memories.immichAutoUpload': 'Дублировать фото journey в Immich при загрузке',
|
||||
'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo',
|
||||
'memories.testConnection': 'Проверить подключение',
|
||||
'memories.testShort': 'Проверить',
|
||||
'memories.testFirst': 'Сначала проверьте подключение',
|
||||
'memories.connected': 'Подключено',
|
||||
'memories.disconnected': 'Не подключено',
|
||||
@@ -1914,6 +1942,8 @@ const ru: Record<string, string> = {
|
||||
'notif.booking_change.text': '{actor} обновил бронирование в {trip}',
|
||||
'notif.trip_reminder.title': 'Напоминание о поездке',
|
||||
'notif.trip_reminder.text': 'Ваша поездка {trip} скоро начнётся!',
|
||||
'notif.todo_due.title': 'Задача к сроку',
|
||||
'notif.todo_due.text': '{todo} в {trip} — срок {due}',
|
||||
'notif.vacay_invite.title': 'Приглашение Vacay Fusion',
|
||||
'notif.vacay_invite.text': '{actor} приглашает вас объединить планы отпуска',
|
||||
'notif.photos_shared.title': 'Фото опубликованы',
|
||||
@@ -2314,9 +2344,9 @@ const ru: Record<string, string> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Личное слово от меня',
|
||||
'system_notice.v3_thankyou.body': 'Прежде чем продолжить — хочу остановиться на мгновение.\n\nTREK начинался как сторонний проект, который я создал для собственных поездок. Я никогда не думал, что он вырастет во что-то, чему 4 000 из вас доверяют планирование своих приключений. Каждая звёздочка, каждый issue, каждый запрос на фичу — я читаю их все, и именно они поддерживают меня в поздние ночи между основной работой и университетом.\n\nХочу, чтобы вы знали: TREK всегда будет open source, всегда self-hosted, всегда вашим. Никакого отслеживания, никаких подписок, никаких подвохов. Просто инструмент, созданный человеком, который любит путешествовать так же, как и вы.\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': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.addTransport': 'Добавить транспорт',
|
||||
'transport.modalTitle.create': 'Добавить транспорт',
|
||||
'transport.modalTitle.edit': 'Изменить транспорт',
|
||||
'transport.title': 'Транспорт',
|
||||
'transport.addManual': 'Ручной транспорт',
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ const zh: Record<string, string> = {
|
||||
'common.none': '无',
|
||||
'common.date': '日期',
|
||||
'common.rename': '重命名',
|
||||
'common.discardChanges': '放弃更改',
|
||||
'common.discard': '放弃',
|
||||
'common.name': '名称',
|
||||
'common.email': '邮箱',
|
||||
'common.password': '密码',
|
||||
@@ -199,6 +201,7 @@ const zh: Record<string, string> = {
|
||||
'settings.notifyTripInvite': '旅行邀请',
|
||||
'settings.notifyBookingChange': '预订变更',
|
||||
'settings.notifyTripReminder': '旅行提醒',
|
||||
'settings.notifyTodoDue': '待办事项即将到期',
|
||||
'settings.notifyVacayInvite': 'Vacay 融合邀请',
|
||||
'settings.notifyPhotosShared': '共享照片 (Immich)',
|
||||
'settings.notifyCollabMessage': '聊天消息 (Collab)',
|
||||
@@ -451,6 +454,28 @@ const zh: Record<string, string> = {
|
||||
'login.oidcFailed': 'OIDC 登录失败',
|
||||
'login.usernameRequired': '用户名为必填项',
|
||||
'login.passwordMinLength': '密码至少需要8个字符',
|
||||
'login.forgotPassword': '忘记密码?',
|
||||
'login.forgotPasswordTitle': '重置密码',
|
||||
'login.forgotPasswordBody': '输入您注册时使用的邮箱地址。若账户存在,我们将发送重置链接。',
|
||||
'login.forgotPasswordSubmit': '发送重置链接',
|
||||
'login.forgotPasswordSentTitle': '请查看邮箱',
|
||||
'login.forgotPasswordSentBody': '若该邮箱存在账户,重置链接正在发送中。链接将在 60 分钟后失效。',
|
||||
'login.forgotPasswordSmtpHintOff': '提示:管理员未配置 SMTP,重置链接将被写入服务器控制台,而不是通过电子邮件发送。',
|
||||
'login.backToLogin': '返回登录',
|
||||
'login.newPassword': '新密码',
|
||||
'login.confirmPassword': '确认新密码',
|
||||
'login.passwordsDontMatch': '两次输入的密码不一致',
|
||||
'login.mfaCode': '二步验证码',
|
||||
'login.resetPasswordTitle': '设置新密码',
|
||||
'login.resetPasswordBody': '请选择您在此处未使用过的强密码。至少 8 位。',
|
||||
'login.resetPasswordMfaBody': '输入您的二步验证码或备用代码以完成重置。',
|
||||
'login.resetPasswordSubmit': '重置密码',
|
||||
'login.resetPasswordVerify': '验证并重置',
|
||||
'login.resetPasswordSuccessTitle': '密码已更新',
|
||||
'login.resetPasswordSuccessBody': '您现在可以使用新密码登录了。',
|
||||
'login.resetPasswordInvalidLink': '无效的重置链接',
|
||||
'login.resetPasswordInvalidLinkBody': '此链接已丢失或损坏。请重新申请以继续。',
|
||||
'login.resetPasswordFailed': '重置失败。链接可能已过期。',
|
||||
'login.oidc.tokenFailed': '认证失败。',
|
||||
'login.oidc.invalidState': '会话无效,请重试。',
|
||||
'login.demoFailed': '演示登录失败',
|
||||
@@ -1195,6 +1220,8 @@ const zh: Record<string, string> = {
|
||||
'files.title': '文件',
|
||||
'files.pageTitle': '文件与文档',
|
||||
'files.subtitle': '{trip} 的 {count} 个文件',
|
||||
'files.download': '下载',
|
||||
'files.openError': '无法打开文件',
|
||||
'files.downloadPdf': '下载 PDF',
|
||||
'files.count': '{count} 个文件',
|
||||
'files.countSingular': '1 个文件',
|
||||
@@ -1594,6 +1621,7 @@ const zh: Record<string, string> = {
|
||||
'memories.immichAutoUpload': '上传 Journey 照片时同步到 Immich',
|
||||
'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo',
|
||||
'memories.testConnection': '测试连接',
|
||||
'memories.testShort': '测试',
|
||||
'memories.testFirst': '请先测试连接',
|
||||
'memories.connected': '已连接',
|
||||
'memories.disconnected': '未连接',
|
||||
@@ -1914,6 +1942,8 @@ const zh: Record<string, string> = {
|
||||
'notif.booking_change.text': '{actor} 更新了 {trip} 中的预订',
|
||||
'notif.trip_reminder.title': '旅行提醒',
|
||||
'notif.trip_reminder.text': '您的旅行 {trip} 即将开始!',
|
||||
'notif.todo_due.title': '待办事项即将到期',
|
||||
'notif.todo_due.text': '{trip} 中的 {todo} 将于 {due} 到期',
|
||||
'notif.vacay_invite.title': 'Vacay 融合邀请',
|
||||
'notif.vacay_invite.text': '{actor} 邀请您合并假期计划',
|
||||
'notif.photos_shared.title': '照片已分享',
|
||||
@@ -2314,9 +2344,9 @@ const zh: Record<string, string> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': '来自我的一封私人信',
|
||||
'system_notice.v3_thankyou.body': '在你继续之前——我想停下来说几句。\n\nTREK 最初只是我为自己的旅行而做的一个业余项目。我从未想过它会成长为 4,000 人信赖的冒险规划工具。每一颗星标、每一个 issue、每一个功能请求——我都会读,它们在全职工作和大学学业之间的深夜里支撑着我继续前行。\n\n我想让你们知道:TREK 将永远开源,永远可自托管,永远属于你们。没有追踪,没有订阅,没有任何附加条件。只是一个热爱旅行的人为同样热爱旅行的你们打造的工具。\n\n特别感谢 [jubnl](https://github.com/jubnl)——你已经成为一位不可思议的合作者。3.0 版本中许多精彩之处都留下了你的印记。感谢你在这个项目还很粗糙的时候就选择了相信它。\n\n也感谢你们每一位——报告了 bug、翻译了文本、向朋友分享了 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': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.addTransport': '添加交通',
|
||||
'transport.modalTitle.create': '添加交通',
|
||||
'transport.modalTitle.edit': '编辑交通',
|
||||
'transport.title': '交通',
|
||||
'transport.addManual': '手动添加交通',
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ const zhTw: Record<string, string> = {
|
||||
'common.none': '無',
|
||||
'common.date': '日期',
|
||||
'common.rename': '重新命名',
|
||||
'common.discardChanges': '捨棄變更',
|
||||
'common.discard': '捨棄',
|
||||
'common.name': '名稱',
|
||||
'common.email': '郵箱',
|
||||
'common.password': '密碼',
|
||||
@@ -199,6 +201,7 @@ const zhTw: Record<string, string> = {
|
||||
'settings.notifyTripInvite': '旅行邀請',
|
||||
'settings.notifyBookingChange': '預訂變更',
|
||||
'settings.notifyTripReminder': '旅行提醒',
|
||||
'settings.notifyTodoDue': '待辦事項即將到期',
|
||||
'settings.notifyVacayInvite': 'Vacay 融合邀請',
|
||||
'settings.notifyPhotosShared': '共享照片 (Immich)',
|
||||
'settings.notifyCollabMessage': '聊天訊息 (Collab)',
|
||||
@@ -510,6 +513,28 @@ const zhTw: Record<string, string> = {
|
||||
'login.oidcFailed': 'OIDC 登入失敗',
|
||||
'login.usernameRequired': '使用者名稱為必填',
|
||||
'login.passwordMinLength': '密碼至少需要8個字元',
|
||||
'login.forgotPassword': '忘記密碼?',
|
||||
'login.forgotPasswordTitle': '重設密碼',
|
||||
'login.forgotPasswordBody': '請輸入您註冊時使用的電子郵件。若帳號存在,我們將傳送重設連結。',
|
||||
'login.forgotPasswordSubmit': '傳送重設連結',
|
||||
'login.forgotPasswordSentTitle': '請查看您的電子郵件',
|
||||
'login.forgotPasswordSentBody': '若此電子郵件存在帳號,重設連結正在傳送中。連結將於 60 分鐘後失效。',
|
||||
'login.forgotPasswordSmtpHintOff': '提醒:管理員尚未設定 SMTP,重設連結將寫入伺服器控制台,而非透過電子郵件寄送。',
|
||||
'login.backToLogin': '返回登入',
|
||||
'login.newPassword': '新密碼',
|
||||
'login.confirmPassword': '確認新密碼',
|
||||
'login.passwordsDontMatch': '兩次輸入的密碼不一致',
|
||||
'login.mfaCode': '2FA 驗證碼',
|
||||
'login.resetPasswordTitle': '設定新密碼',
|
||||
'login.resetPasswordBody': '請選擇您在此處尚未使用過的強密碼。至少 8 個字元。',
|
||||
'login.resetPasswordMfaBody': '請輸入您的 2FA 驗證碼或備用代碼以完成重設。',
|
||||
'login.resetPasswordSubmit': '重設密碼',
|
||||
'login.resetPasswordVerify': '驗證並重設',
|
||||
'login.resetPasswordSuccessTitle': '密碼已更新',
|
||||
'login.resetPasswordSuccessBody': '您現在可以使用新密碼登入。',
|
||||
'login.resetPasswordInvalidLink': '無效的重設連結',
|
||||
'login.resetPasswordInvalidLinkBody': '此連結遺失或已損壞。請重新申請以繼續。',
|
||||
'login.resetPasswordFailed': '重設失敗。連結可能已過期。',
|
||||
'login.oidc.tokenFailed': '認證失敗。',
|
||||
'login.oidc.invalidState': '會話無效,請重試。',
|
||||
'login.demoFailed': '演示登入失敗',
|
||||
@@ -1255,6 +1280,8 @@ const zhTw: Record<string, string> = {
|
||||
'files.title': '檔案',
|
||||
'files.pageTitle': '檔案與文件',
|
||||
'files.subtitle': '{trip} 的 {count} 個檔案',
|
||||
'files.download': '下載',
|
||||
'files.openError': '無法開啟檔案',
|
||||
'files.downloadPdf': '下載 PDF',
|
||||
'files.count': '{count} 個檔案',
|
||||
'files.countSingular': '1 個檔案',
|
||||
@@ -1654,6 +1681,7 @@ const zhTw: Record<string, string> = {
|
||||
'memories.immichAutoUpload': '上傳 Journey 照片時同步到 Immich',
|
||||
'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo',
|
||||
'memories.testConnection': '測試連線',
|
||||
'memories.testShort': '測試',
|
||||
'memories.testFirst': '請先測試連線',
|
||||
'memories.connected': '已連線',
|
||||
'memories.disconnected': '未連線',
|
||||
@@ -2173,6 +2201,8 @@ const zhTw: Record<string, string> = {
|
||||
'notif.booking_change.text': '{actor} 更新了 {trip} 中的預訂',
|
||||
'notif.trip_reminder.title': '旅行提醒',
|
||||
'notif.trip_reminder.text': '你的旅行 {trip} 即將開始!',
|
||||
'notif.todo_due.title': '待辦事項即將到期',
|
||||
'notif.todo_due.text': '{trip} 中的 {todo} 將於 {due} 到期',
|
||||
'notif.vacay_invite.title': 'Vacay Fusion 邀請',
|
||||
'notif.vacay_invite.text': '{actor} 邀請你合併假期計畫',
|
||||
'notif.photos_shared.title': '照片已分享',
|
||||
@@ -2315,9 +2345,9 @@ const zhTw: Record<string, string> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': '來自我的一封私人信',
|
||||
'system_notice.v3_thankyou.body': '在你繼續之前——我想停下來說幾句。\n\nTREK 最初只是我為自己的旅行而做的一個業餘專案。我從未想過它會成長為 4,000 人信賴的冒險規劃工具。每一顆星標、每一個 issue、每一個功能請求——我都會讀,它們在全職工作和大學學業之間的深夜裡支撐著我繼續前行。\n\n我想讓你們知道:TREK 將永遠開源,永遠可自託管,永遠屬於你們。沒有追蹤,沒有訂閱,沒有任何附加條件。只是一個熱愛旅行的人為同樣熱愛旅行的你們打造的工具。\n\n特別感謝 [jubnl](https://github.com/jubnl)——你已經成為一位不可思議的合作者。3.0 版本中許多精彩之處都留下了你的印記。感謝你在這個專案還很粗糙的時候就選擇了相信它。\n\n也感謝你們每一位——回報了 bug、翻譯了文字、向朋友分享了 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': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.addTransport': '新增交通',
|
||||
'transport.modalTitle.create': '新增交通',
|
||||
'transport.modalTitle.edit': '編輯交通',
|
||||
'transport.title': '交通',
|
||||
'transport.addManual': '手動新增交通',
|
||||
}
|
||||
|
||||
@@ -1240,6 +1240,15 @@ interface SidebarContentProps {
|
||||
|
||||
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
|
||||
const { language } = useTranslation()
|
||||
const statsContentRef = useRef<HTMLDivElement>(null)
|
||||
const [statsWidth, setStatsWidth] = useState<number | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const el = statsContentRef.current
|
||||
if (!el || typeof ResizeObserver === 'undefined') return
|
||||
const ro = new ResizeObserver(() => setStatsWidth(el.offsetWidth))
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
|
||||
const tp = dark ? '#f1f5f9' : '#0f172a'
|
||||
const tm = dark ? '#94a3b8' : '#64748b'
|
||||
@@ -1290,7 +1299,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
||||
// Bucket list content
|
||||
const bucketContent = (
|
||||
<>
|
||||
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px' }}>
|
||||
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px', maxWidth: statsWidth, width: '100%' }}>
|
||||
{bucketList.map(item => (
|
||||
<div key={item.id} className="group flex flex-col items-center justify-center shrink-0" style={{ padding: '8px 14px', position: 'relative', minWidth: 80 }}>
|
||||
{(() => {
|
||||
@@ -1400,7 +1409,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
||||
{/* Both tabs always rendered so the wider one sets the panel width */}
|
||||
<div style={{ display: 'grid' }}>
|
||||
<div style={bucketTab === 'bucket' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
|
||||
<div className="flex items-stretch justify-center">
|
||||
<div ref={statsContentRef} className="flex items-stretch justify-center">
|
||||
|
||||
{/* ═══ SECTION 1: Numbers ═══ */}
|
||||
{/* Countries hero */}
|
||||
|
||||
@@ -401,6 +401,10 @@ describe('DashboardPage', () => {
|
||||
const copyButtons = screen.getAllByRole('button', { name: /copy/i });
|
||||
await user.click(copyButtons[0]);
|
||||
|
||||
// Confirm the copy dialog
|
||||
const confirmButton = await screen.findByRole('button', { name: /copy trip/i });
|
||||
await user.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument();
|
||||
});
|
||||
@@ -766,6 +770,10 @@ describe('DashboardPage', () => {
|
||||
expect(copyButtons.length).toBeGreaterThan(0);
|
||||
await user.click(copyButtons[0]);
|
||||
|
||||
// Confirm the copy dialog
|
||||
const confirmButton = await screen.findByRole('button', { name: /copy trip/i });
|
||||
await user.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
|
||||
import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
|
||||
import TripFormModal from '../components/Trips/TripFormModal'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import CopyTripDialog from '../components/shared/CopyTripDialog'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { useCountUp } from '../hooks/useCountUp'
|
||||
import {
|
||||
@@ -699,6 +700,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile' | 'mobile-currency' | 'mobile-timezone'>(false)
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
|
||||
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
||||
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
|
||||
|
||||
const toggleViewMode = () => {
|
||||
setViewMode(prev => {
|
||||
@@ -815,14 +817,18 @@ export default function DashboardPage(): React.ReactElement {
|
||||
setArchivedTrips(prev => prev.map(update))
|
||||
}
|
||||
|
||||
const handleCopy = async (trip: DashboardTrip) => {
|
||||
const handleCopy = (trip: DashboardTrip) => setCopyTrip(trip)
|
||||
|
||||
const confirmCopy = async () => {
|
||||
if (!copyTrip) return
|
||||
try {
|
||||
const data = await tripsApi.copy(trip.id, { title: `${trip.title} (${t('dashboard.copySuffix')})` })
|
||||
const data = await tripsApi.copy(copyTrip.id, { title: `${copyTrip.title} (${t('dashboard.copySuffix')})` })
|
||||
setTrips(prev => sortTrips([data.trip, ...prev]))
|
||||
toast.success(t('dashboard.toast.copied'))
|
||||
} catch {
|
||||
toast.error(t('dashboard.toast.copyError'))
|
||||
}
|
||||
setCopyTrip(null)
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
@@ -1205,6 +1211,13 @@ export default function DashboardPage(): React.ReactElement {
|
||||
message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })}
|
||||
/>
|
||||
|
||||
<CopyTripDialog
|
||||
isOpen={!!copyTrip}
|
||||
tripTitle={copyTrip?.title || ''}
|
||||
onClose={() => setCopyTrip(null)}
|
||||
onConfirm={confirmCopy}
|
||||
/>
|
||||
|
||||
<style>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1 }
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import React, { useState, useEffect, FormEvent, ChangeEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Mail, ArrowLeft, CheckCircle2, Terminal } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { authApi } from '../api/client'
|
||||
|
||||
const inputBase: React.CSSProperties = {
|
||||
width: '100%', padding: '11px 12px 11px 38px', borderRadius: 12,
|
||||
border: '1px solid #e5e7eb', fontSize: 14, fontFamily: 'inherit',
|
||||
outline: 'none', transition: 'border-color 120ms',
|
||||
background: 'white', color: '#111827',
|
||||
}
|
||||
|
||||
const ForgotPasswordPage: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [smtpConfigured, setSmtpConfigured] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Probe whether SMTP is configured so we can warn the user up-front
|
||||
// that the link will land in the server console instead of their
|
||||
// inbox. Null while pending — hint is hidden until we know.
|
||||
authApi.getAppConfig?.()
|
||||
.then((cfg: any) => {
|
||||
const hasEmail = !!cfg?.available_channels?.email
|
||||
setSmtpConfigured(hasEmail)
|
||||
})
|
||||
.catch(() => setSmtpConfigured(null))
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (isLoading) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await authApi.forgotPassword({ email: email.trim() })
|
||||
} catch {
|
||||
// Enumeration-safe: success UX regardless of server outcome.
|
||||
}
|
||||
setSubmitted(true)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'linear-gradient(180deg, #f9fafb, #ffffff)', padding: 24, fontFamily: 'inherit',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '100%', maxWidth: 420, background: 'white', borderRadius: 20,
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.06), 0 2px 8px rgba(0,0,0,0.04)',
|
||||
padding: '32px 28px',
|
||||
}}>
|
||||
<button type="button" onClick={() => navigate('/login')} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||
color: '#6b7280', fontSize: 13, fontFamily: 'inherit', marginBottom: 22,
|
||||
}}>
|
||||
<ArrowLeft size={14} />{t('login.backToLogin')}
|
||||
</button>
|
||||
|
||||
{submitted ? (
|
||||
<div style={{ textAlign: 'center', padding: '12px 0' }}>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: '50%', background: '#ecfdf5',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#059669', marginBottom: 16,
|
||||
}}>
|
||||
<CheckCircle2 size={28} />
|
||||
</div>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 10px 0' }}>
|
||||
{t('login.forgotPasswordSentTitle')}
|
||||
</h1>
|
||||
<p style={{ fontSize: 14, color: '#4b5563', lineHeight: 1.55, margin: 0 }}>
|
||||
{t('login.forgotPasswordSentBody')}
|
||||
</p>
|
||||
{smtpConfigured === false && (
|
||||
<div style={{
|
||||
marginTop: 18, padding: '12px 14px',
|
||||
background: '#fffbeb', border: '1px solid #fde68a',
|
||||
borderRadius: 10, textAlign: 'left',
|
||||
display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||
}}>
|
||||
<Terminal size={16} style={{ color: '#92400e', marginTop: 1, flexShrink: 0 }} />
|
||||
<p style={{ fontSize: 12.5, color: '#92400e', lineHeight: 1.55, margin: 0 }}>
|
||||
{t('login.forgotPasswordSmtpHintOff')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" onClick={() => navigate('/login')} style={{
|
||||
marginTop: 24, padding: '11px 22px', background: '#111827', color: 'white',
|
||||
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>{t('login.backToLogin')}</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 8px 0' }}>
|
||||
{t('login.forgotPasswordTitle')}
|
||||
</h1>
|
||||
<p style={{ fontSize: 13.5, color: '#6b7280', lineHeight: 1.55, margin: '0 0 16px 0' }}>
|
||||
{t('login.forgotPasswordBody')}
|
||||
</p>
|
||||
{smtpConfigured === false && (
|
||||
<div style={{
|
||||
padding: '10px 12px', marginBottom: 18,
|
||||
background: '#fffbeb', border: '1px solid #fde68a',
|
||||
borderRadius: 10, display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||
}}>
|
||||
<Terminal size={15} style={{ color: '#92400e', marginTop: 1, flexShrink: 0 }} />
|
||||
<p style={{ fontSize: 12.5, color: '#92400e', lineHeight: 1.5, margin: 0 }}>
|
||||
{t('login.forgotPasswordSmtpHintOff')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>
|
||||
{t('common.email')}
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Mail size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||
<input
|
||||
type="email" value={email}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
|
||||
required placeholder={t('login.emailPlaceholder')} style={inputBase}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" disabled={isLoading} style={{
|
||||
width: '100%', padding: '12px', background: '#111827', color: 'white',
|
||||
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
|
||||
cursor: isLoading ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
opacity: isLoading ? 0.7 : 1, transition: 'opacity 0.15s',
|
||||
}}>
|
||||
{isLoading ? t('login.signingIn') : t('login.forgotPasswordSubmit')}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ForgotPasswordPage
|
||||
@@ -1468,7 +1468,7 @@ describe('JourneyDetailPage', () => {
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-074 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-074: Delete share link removes it', () => {
|
||||
it('clicking "Remove share link" calls DELETE and returns to create state', async () => {
|
||||
it('clicking "Delete link" calls DELETE and returns to create state', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
let deleteCalled = false;
|
||||
|
||||
@@ -1493,10 +1493,10 @@ describe('JourneyDetailPage', () => {
|
||||
await openSettingsDialog(user);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Remove share link')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete link')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Remove share link'));
|
||||
await user.click(screen.getByText('Delete link'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteCalled).toBe(true);
|
||||
@@ -2905,7 +2905,7 @@ describe('JourneyDetailPage', () => {
|
||||
|
||||
// The permission toggles show Timeline, Gallery, Map labels within the share section
|
||||
// These reuse the same i18n keys as the main tab bar
|
||||
expect(screen.getByText('Remove share link')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete link')).toBeInTheDocument();
|
||||
expect(screen.getByText('Copy')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { formatLocationName } from '../utils/formatters'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
@@ -8,6 +9,7 @@ import { journeyApi, authApi, addonsApi, mapsApi } from '../api/client'
|
||||
import { addListener, removeListener } from '../api/websocket'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import JourneyMap from '../components/Journey/JourneyMapAuto'
|
||||
import { DAY_COLORS } from '../components/Journey/dayColors'
|
||||
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto'
|
||||
import JournalBody from '../components/Journey/JournalBody'
|
||||
import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
|
||||
@@ -67,11 +69,13 @@ function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
|
||||
return groups
|
||||
}
|
||||
|
||||
function formatDate(d: string): { weekday: string; month: string; day: number } {
|
||||
function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
|
||||
const date = new Date(d + 'T00:00:00')
|
||||
// Pass the app's selected locale so weekday/month follow the UI language
|
||||
// instead of the browser's navigator.language.
|
||||
return {
|
||||
weekday: date.toLocaleDateString(undefined, { weekday: 'long' }),
|
||||
month: date.toLocaleDateString(undefined, { month: 'long' }),
|
||||
weekday: date.toLocaleDateString(locale, { weekday: 'long' }),
|
||||
month: date.toLocaleDateString(locale, { month: 'long' }),
|
||||
day: date.getDate(),
|
||||
}
|
||||
}
|
||||
@@ -84,7 +88,7 @@ export default function JourneyDetailPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const { t, locale } = useTranslation()
|
||||
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore()
|
||||
const mapRef = useRef<JourneyMapHandle>(null)
|
||||
const fullMapRef = useRef<JourneyMapHandle>(null)
|
||||
@@ -186,7 +190,9 @@ export default function JourneyDetailPage() {
|
||||
const winner = lastPast || firstAhead
|
||||
if (winner) {
|
||||
setActiveEntryId(winner.id)
|
||||
mapRef.current?.highlightMarker(winner.id)
|
||||
if (locatedEntryIdsRef.current.has(winner.id)) {
|
||||
mapRef.current?.highlightMarker(winner.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
const onScroll = () => {
|
||||
@@ -277,16 +283,38 @@ export default function JourneyDetailPage() {
|
||||
[current?.entries]
|
||||
)
|
||||
|
||||
const sidebarMapItems = useMemo(() => mapEntries.map(e => ({
|
||||
id: String(e.id),
|
||||
lat: e.location_lat!,
|
||||
lng: e.location_lng!,
|
||||
title: e.title || '',
|
||||
location_name: e.location_name || '',
|
||||
mood: e.mood,
|
||||
created_at: e.entry_date,
|
||||
entry_date: e.entry_date,
|
||||
})), [mapEntries])
|
||||
const sidebarMapItems = useMemo(() => {
|
||||
const allDates = [...new Set(
|
||||
(current?.entries || [])
|
||||
.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]')
|
||||
.map(e => e.entry_date)
|
||||
.sort()
|
||||
)]
|
||||
const sorted = [...mapEntries].sort((a, b) => a.entry_date.localeCompare(b.entry_date))
|
||||
const dayCounters = new Map<string, number>()
|
||||
return sorted.map(e => {
|
||||
const dayIdx = allDates.indexOf(e.entry_date)
|
||||
const dayLabel = (dayCounters.get(e.entry_date) ?? 0) + 1
|
||||
dayCounters.set(e.entry_date, dayLabel)
|
||||
return {
|
||||
id: String(e.id),
|
||||
lat: e.location_lat!,
|
||||
lng: e.location_lng!,
|
||||
title: e.title || '',
|
||||
location_name: e.location_name || '',
|
||||
mood: e.mood,
|
||||
created_at: e.entry_date,
|
||||
entry_date: e.entry_date,
|
||||
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
|
||||
dayLabel,
|
||||
}
|
||||
})
|
||||
}, [mapEntries, current?.entries])
|
||||
|
||||
const locatedEntryIdsRef = useRef(new Set<string>())
|
||||
useEffect(() => {
|
||||
locatedEntryIdsRef.current = new Set(sidebarMapItems.map(m => m.id))
|
||||
}, [sidebarMapItems])
|
||||
|
||||
const tripDates = useMemo(() => {
|
||||
const dates = new Set<string>()
|
||||
@@ -422,7 +450,7 @@ export default function JourneyDetailPage() {
|
||||
? 'max-w-[1440px] mx-auto px-0 pt-0'
|
||||
: 'flex w-full overflow-hidden'
|
||||
}
|
||||
style={!isMobile ? { height: 'calc(100vh - var(--nav-h, 56px))' } : undefined}
|
||||
style={!isMobile ? { height: 'calc(100dvh - var(--nav-h, 56px))' } : undefined}
|
||||
>
|
||||
{/* LEFT column (full width on mobile, scrollable feed on desktop) */}
|
||||
<div
|
||||
@@ -482,7 +510,7 @@ export default function JourneyDetailPage() {
|
||||
>
|
||||
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
<span className="absolute top-full mt-2 left-1/2 -translate-x-1/2 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
|
||||
<span className="absolute top-full mt-2 right-0 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
|
||||
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -575,14 +603,14 @@ export default function JourneyDetailPage() {
|
||||
|
||||
{sortedDates.map((date, dayIdx) => {
|
||||
const entries = dayGroups.get(date)!
|
||||
const fd = formatDate(date)
|
||||
const fd = formatDate(date, locale)
|
||||
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
|
||||
|
||||
return (
|
||||
<div key={date} className="flex flex-col gap-3 trek-stagger">
|
||||
<div className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[13px] font-bold">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-[13px] font-bold text-white" style={{ background: DAY_COLORS[dayIdx % DAY_COLORS.length] }}>
|
||||
{dayIdx + 1}
|
||||
</div>
|
||||
<div>
|
||||
@@ -595,7 +623,11 @@ export default function JourneyDetailPage() {
|
||||
</div>
|
||||
|
||||
{entries.map((entry, idx) => {
|
||||
const canReorder = !isMobile && canEditEntries && entries.length > 1
|
||||
// Skeletons are just "suggested" places pulled
|
||||
// from the linked trip — they aren't real
|
||||
// journey entries until the user edits them,
|
||||
// so reordering them does not make sense.
|
||||
const canReorder = !isMobile && canEditEntries && entries.length > 1 && entry.type !== 'skeleton'
|
||||
const move = (direction: -1 | 1) => {
|
||||
if (!current) return
|
||||
const target = idx + direction
|
||||
@@ -607,7 +639,7 @@ export default function JourneyDetailPage() {
|
||||
.catch(() => toast.error(t('common.errorOccurred')))
|
||||
}
|
||||
return (
|
||||
<div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`}>
|
||||
<div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`} onMouseEnter={() => { setActiveEntryId(String(entry.id)); mapRef.current?.highlightMarker(String(entry.id)) }} style={String(entry.id) === activeEntryId ? { outline: `2px solid ${DAY_COLORS[dayIdx % DAY_COLORS.length]}`, outlineOffset: '3px', borderRadius: '12px' } : undefined}>
|
||||
{canReorder && (
|
||||
<div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1">
|
||||
<button
|
||||
@@ -729,7 +761,8 @@ export default function JourneyDetailPage() {
|
||||
journey={current}
|
||||
onClose={() => setShowSettings(false)}
|
||||
onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }}
|
||||
onOpenInvite={() => { setShowSettings(false); setShowInvite(true) }}
|
||||
onOpenInvite={() => { setShowInvite(true) }}
|
||||
onRefresh={() => loadJourney(Number(id))}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -812,7 +845,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
|
||||
fullMapRef: React.RefObject<JourneyMapHandle | null>
|
||||
onLocationClick: (id: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { t, locale } = useTranslation()
|
||||
// group map entries by date
|
||||
const byDate = new Map<string, { entry: JourneyEntry; globalIdx: number }[]>()
|
||||
mapEntries.forEach((e, i) => {
|
||||
@@ -868,7 +901,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
|
||||
<div className="px-5 pb-5">
|
||||
{dates.map((date, dayIdx) => {
|
||||
const items = byDate.get(date)!
|
||||
const fd = formatDate(date)
|
||||
const fd = formatDate(date, locale)
|
||||
|
||||
return (
|
||||
<div key={date}>
|
||||
@@ -911,7 +944,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
|
||||
<span className="text-[14px] font-semibold text-zinc-900 dark:text-white truncate">{e.title || e.location_name}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 truncate">
|
||||
{e.location_name}{e.entry_time ? ` · ${e.entry_time}` : ''}
|
||||
{formatLocationName(e.location_name)}{e.entry_time ? ` · ${e.entry_time}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -977,9 +1010,16 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
}, [])
|
||||
|
||||
const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = []
|
||||
const seenPhotoIds = new Map<number, number>() // photo_id → index in allPhotos
|
||||
for (const e of entries) {
|
||||
for (const p of e.photos) {
|
||||
allPhotos.push({ photo: p, entry: e })
|
||||
const existing = seenPhotoIds.get(p.photo_id)
|
||||
if (existing === undefined) {
|
||||
seenPhotoIds.set(p.photo_id, allPhotos.length)
|
||||
allPhotos.push({ photo: p, entry: e })
|
||||
} else if (e.title === 'Gallery' && allPhotos[existing].entry.title !== 'Gallery') {
|
||||
allPhotos[existing] = { photo: p, entry: e }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1024,23 +1064,27 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
}
|
||||
|
||||
const handleDeletePhoto = async (photoId: number) => {
|
||||
// Optimistic update — remove photo from local state immediately
|
||||
const store = useJourneyStore.getState()
|
||||
if (store.current) {
|
||||
const updated = {
|
||||
...store.current,
|
||||
entries: store.current.entries.map(e => ({
|
||||
...e,
|
||||
photos: e.photos.filter(p => p.id !== photoId),
|
||||
})).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story),
|
||||
}
|
||||
useJourneyStore.setState({ current: updated })
|
||||
if (!store.current) return
|
||||
const target = store.current.entries.flatMap(e => e.photos).find(p => p.id === photoId)
|
||||
if (!target) return
|
||||
const siblingIds = store.current.entries.flatMap(e => e.photos).filter(p => p.photo_id === target.photo_id).map(p => p.id)
|
||||
|
||||
// Optimistic update — remove every row with this photo_id
|
||||
const updated = {
|
||||
...store.current,
|
||||
entries: store.current.entries.map(e => ({
|
||||
...e,
|
||||
photos: e.photos.filter(p => p.photo_id !== target.photo_id),
|
||||
})).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story),
|
||||
}
|
||||
useJourneyStore.setState({ current: updated })
|
||||
|
||||
try {
|
||||
await journeyApi.deletePhoto(photoId)
|
||||
await Promise.all(siblingIds.map(id => journeyApi.deletePhoto(id)))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
onRefresh() // Revert on error
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1354,7 +1398,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
{entry.location_name && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide max-w-full overflow-hidden">
|
||||
<MapPin size={10} className="flex-shrink-0" />
|
||||
<span className="truncate">{entry.location_name}</span>
|
||||
<span className="truncate">{formatLocationName(entry.location_name)}</span>
|
||||
</span>
|
||||
)}
|
||||
{entry.entry_time && (
|
||||
@@ -1397,7 +1441,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1 mr-2">
|
||||
{entry.location_name && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500 max-w-full overflow-hidden">
|
||||
<MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{entry.location_name}</span>
|
||||
<MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{formatLocationName(entry.location_name)}</span>
|
||||
</span>
|
||||
)}
|
||||
{entry.entry_time && (
|
||||
@@ -1476,7 +1520,7 @@ function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () =>
|
||||
{entry.title || t('journey.detail.newEntry')}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 mt-0.5">
|
||||
{entry.location_name || ''}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
|
||||
{formatLocationName(entry.location_name)}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 font-medium flex-shrink-0">
|
||||
@@ -1760,11 +1804,11 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
: t('journey.picker.newGallery')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[85vh] flex flex-col overflow-hidden">
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
||||
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
|
||||
</h2>
|
||||
@@ -1774,7 +1818,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1.5 mb-3">
|
||||
{[
|
||||
@@ -1860,7 +1904,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
</div>
|
||||
|
||||
{/* Add-to entry selector */}
|
||||
<div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
|
||||
<button
|
||||
@@ -1913,7 +1957,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
|
||||
if (selectable.length === 0) return null
|
||||
return (
|
||||
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900">
|
||||
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
@@ -1938,7 +1982,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
})()}
|
||||
|
||||
{/* Photo grid */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain p-4 min-h-0">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
|
||||
@@ -2011,7 +2055,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
|
||||
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
|
||||
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
|
||||
@@ -2210,6 +2254,9 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
pendingLinkIds.length > 0
|
||||
)
|
||||
|
||||
const uniqueGalleryPhotos = Array.from(new Map(galleryPhotos.map(gp => [gp.photo_id, gp])).values())
|
||||
const availableGalleryPhotos = uniqueGalleryPhotos.filter(gp => !photos.some(p => p.photo_id === gp.photo_id))
|
||||
|
||||
const handleClose = () => {
|
||||
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
|
||||
onClose()
|
||||
@@ -2312,11 +2359,14 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gallery picker — directly below buttons */}
|
||||
{/* Gallery picker — directly below buttons. Safari collapses
|
||||
`aspect-square` items inside an overflow-scroll grid, so
|
||||
the square is enforced with a padding-top spacer + an
|
||||
absolutely positioned image (works across all browsers). */}
|
||||
{showGalleryPick && (
|
||||
<div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto">
|
||||
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).map(gp => (
|
||||
{availableGalleryPhotos.map(gp => (
|
||||
<div
|
||||
key={gp.id}
|
||||
onClick={async () => {
|
||||
@@ -2330,12 +2380,13 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
setPhotos(prev => [...prev, gp])
|
||||
}
|
||||
}}
|
||||
className="aspect-square rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
|
||||
className="relative w-full rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
|
||||
style={{ paddingTop: '100%' }}
|
||||
>
|
||||
<img src={photoUrl(gp)} alt="" className="w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
|
||||
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
|
||||
</div>
|
||||
))}
|
||||
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && (
|
||||
{availableGalleryPhotos.length === 0 && (
|
||||
<div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2944,7 +2995,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
|
||||
onClick={deleteLink}
|
||||
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
|
||||
>
|
||||
Remove share link
|
||||
{t('share.deleteLink')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -2952,11 +3003,12 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: {
|
||||
journey: JourneyDetail
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
onOpenInvite: () => void
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [title, setTitle] = useState(journey.title)
|
||||
@@ -2964,6 +3016,10 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showAddTrip, setShowAddTrip] = useState(false)
|
||||
const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null)
|
||||
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)
|
||||
|
||||
const isDirty = title !== journey.title || subtitle !== (journey.subtitle || '')
|
||||
const handleClose = () => { if (isDirty) setShowDiscardConfirm(true); else onClose() }
|
||||
const coverRef = useRef<HTMLInputElement>(null)
|
||||
const toast = useToast()
|
||||
const navigate = useNavigate()
|
||||
@@ -3022,12 +3078,12 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={handleClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
|
||||
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<button onClick={handleClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -3123,7 +3179,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
try {
|
||||
await journeyApi.removeContributor(journey.id, c.user_id)
|
||||
toast.success(t('journey.contributors.removed'))
|
||||
onSaved()
|
||||
onRefresh()
|
||||
} catch {
|
||||
toast.error(t('journey.contributors.removeFailed'))
|
||||
}
|
||||
@@ -3174,7 +3230,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
{journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />}
|
||||
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
|
||||
</button>
|
||||
<button onClick={onClose} className="h-9 px-3.5 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||
<button onClick={handleClose} className="h-9 px-3.5 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||
<button onClick={handleSave} disabled={saving || !title.trim()} className="h-9 px-3.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
@@ -3221,6 +3277,16 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showDiscardConfirm}
|
||||
onClose={() => setShowDiscardConfirm(false)}
|
||||
onConfirm={() => { setShowDiscardConfirm(false); onClose() }}
|
||||
title={t('common.discardChanges')}
|
||||
message={t('journey.editor.discardChangesConfirm')}
|
||||
confirmLabel={t('common.discard')}
|
||||
danger
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -234,28 +234,20 @@ describe('JourneyPublicPage', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-PAGE-PUBLICJOURNEY-009: map tab switches view', async () => {
|
||||
it('FE-PAGE-PUBLICJOURNEY-009: map is always visible in desktop two-column layout', async () => {
|
||||
setupSuccess();
|
||||
render(<JourneyPublicPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const mapBtn = buttons.find(
|
||||
btn => btn.textContent && /map/i.test(btn.textContent),
|
||||
);
|
||||
expect(mapBtn).toBeDefined();
|
||||
if (mapBtn) {
|
||||
fireEvent.click(mapBtn);
|
||||
// After clicking map tab, the timeline entries should no longer be visible
|
||||
// and the map view content should be rendered (even if JourneyMap errors internally
|
||||
// due to jsdom limitations, the tab state switches)
|
||||
await waitFor(() => {
|
||||
// Shibuya Crossing (timeline-only) should not appear once map is active
|
||||
expect(screen.queryByText('Shibuya Crossing')).not.toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
// Desktop two-column: map sidebar is always rendered alongside the timeline;
|
||||
// there is no standalone "Map" tab button on desktop.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('journey-map')).toBeInTheDocument();
|
||||
});
|
||||
// Timeline entries remain visible (two-column shows both simultaneously)
|
||||
expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-PUBLICJOURNEY-010: shows journey stats', async () => {
|
||||
@@ -303,24 +295,18 @@ describe('JourneyPublicPage', () => {
|
||||
});
|
||||
|
||||
// FE-PAGE-PUBLICJOURNEY-012
|
||||
it('FE-PAGE-PUBLICJOURNEY-012: tab switching from timeline to map shows map component', async () => {
|
||||
const user = userEvent.setup();
|
||||
it('FE-PAGE-PUBLICJOURNEY-012: map component renders with located entries in desktop two-column layout', async () => {
|
||||
setupSuccess();
|
||||
render(<JourneyPublicPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const mapBtn = screen.getAllByRole('button').find(
|
||||
btn => btn.textContent && /map/i.test(btn.textContent),
|
||||
);
|
||||
expect(mapBtn).toBeDefined();
|
||||
await user.click(mapBtn!);
|
||||
|
||||
// Desktop two-column: map sidebar is always rendered; no tab click required.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('journey-map')).toBeInTheDocument();
|
||||
});
|
||||
// Map receives entries with lat/lng
|
||||
// Both fixture entries have coordinates → map receives 2 located entries
|
||||
expect(screen.getByTestId('journey-map').textContent).toContain('2');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useEffect, useState, useMemo, useRef, useCallback } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { journeyApi } from '../api/client'
|
||||
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { List, Grid, MapPin, Camera, BookOpen, Image } from 'lucide-react'
|
||||
import {
|
||||
List, Grid, MapPin, Camera, BookOpen, Image, Clock,
|
||||
Laugh, Smile, Meh, Frown,
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
|
||||
ThumbsUp, ThumbsDown,
|
||||
} from 'lucide-react'
|
||||
import JourneyMap from '../components/Journey/JourneyMap'
|
||||
import type { JourneyMapHandle } from '../components/Journey/JourneyMap'
|
||||
import JournalBody from '../components/Journey/JournalBody'
|
||||
import PhotoLightbox from '../components/Journey/PhotoLightbox'
|
||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||
import { useIsMobile } from '../hooks/useIsMobile'
|
||||
import { formatLocationName } from '../utils/formatters'
|
||||
import { DAY_COLORS } from '../components/Journey/dayColors'
|
||||
|
||||
interface PublicEntry {
|
||||
id: number
|
||||
@@ -36,15 +44,31 @@ interface PublicPhoto {
|
||||
caption?: string | null
|
||||
}
|
||||
|
||||
function photoUrl(p: PublicPhoto, shareToken: string): string {
|
||||
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/original`
|
||||
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
|
||||
amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
|
||||
good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
|
||||
neutral: { icon: Meh, label: 'Neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' },
|
||||
rough: { icon: Frown, label: 'Rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' },
|
||||
}
|
||||
|
||||
function formatDate(d: string): { weekday: string; month: string; day: number } {
|
||||
const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
|
||||
sunny: { icon: Sun, label: 'Sunny' },
|
||||
partly: { icon: CloudSun, label: 'Partly cloudy' },
|
||||
cloudy: { icon: Cloud, label: 'Cloudy' },
|
||||
rainy: { icon: CloudRain, label: 'Rainy' },
|
||||
stormy: { icon: CloudLightning, label: 'Stormy' },
|
||||
cold: { icon: Snowflake, label: 'Cold' },
|
||||
}
|
||||
|
||||
function photoUrl(p: PublicPhoto, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
|
||||
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`
|
||||
}
|
||||
|
||||
function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
|
||||
const date = new Date(d + 'T00:00:00')
|
||||
return {
|
||||
weekday: date.toLocaleDateString('en', { weekday: 'long' }),
|
||||
month: date.toLocaleDateString('en', { month: 'long' }),
|
||||
weekday: date.toLocaleDateString(locale || 'en', { weekday: 'long' }),
|
||||
month: date.toLocaleDateString(locale || 'en', { month: 'long' }),
|
||||
day: date.getDate(),
|
||||
}
|
||||
}
|
||||
@@ -70,6 +94,15 @@ export default function JourneyPublicPage() {
|
||||
const { t } = useTranslation()
|
||||
const [showLangPicker, setShowLangPicker] = useState(false)
|
||||
const locale = useSettingsStore(s => s.settings.language) || 'en'
|
||||
const mapRef = useRef<JourneyMapHandle>(null)
|
||||
const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
|
||||
|
||||
const handleMarkerClick = useCallback((entryId: string) => {
|
||||
setActiveEntryId(entryId)
|
||||
mapRef.current?.highlightMarker(entryId)
|
||||
document.querySelector(`[data-entry-id="${entryId}"]`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
@@ -84,17 +117,55 @@ export default function JourneyPublicPage() {
|
||||
const journey = data?.journey || {}
|
||||
const stats = data?.stats || {}
|
||||
|
||||
const groupedEntries = useMemo(() => groupByDate(entries), [entries])
|
||||
const timelineEntries = useMemo(
|
||||
() => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'),
|
||||
[entries],
|
||||
)
|
||||
const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries])
|
||||
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
|
||||
const mapEntries = useMemo(() => entries.filter(e => e.location_lat && e.location_lng), [entries])
|
||||
const mapEntries = useMemo(
|
||||
() => timelineEntries.filter(e => e.location_lat && e.location_lng),
|
||||
[timelineEntries],
|
||||
)
|
||||
const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries])
|
||||
|
||||
// Map entries with day color/label for colored markers.
|
||||
// dayIdx is derived from sortedDates (ALL timeline dates) so marker colors
|
||||
// stay in sync with the timeline day headers even when some days have no locations.
|
||||
const sidebarMapItems = useMemo(() => {
|
||||
const counters = new Map<string, number>()
|
||||
return mapEntries.map(e => {
|
||||
const dayIdx = sortedDates.indexOf(e.entry_date)
|
||||
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
|
||||
counters.set(e.entry_date, dayLabel)
|
||||
return {
|
||||
id: String(e.id),
|
||||
lat: e.location_lat!,
|
||||
lng: e.location_lng!,
|
||||
title: e.title || '',
|
||||
mood: e.mood,
|
||||
created_at: e.entry_date,
|
||||
entry_date: e.entry_date,
|
||||
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
|
||||
dayLabel,
|
||||
}
|
||||
})
|
||||
}, [mapEntries, sortedDates])
|
||||
|
||||
// Two-column desktop layout: timeline feed left + sticky map right
|
||||
const desktopTwoColumn = !isMobile && perms.share_timeline && perms.share_map
|
||||
|
||||
// Set default view based on permissions
|
||||
useEffect(() => {
|
||||
if (!perms.share_timeline && perms.share_gallery) setView('gallery')
|
||||
else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map')
|
||||
}, [perms])
|
||||
|
||||
// When switching to desktop two-column, 'map' standalone tab no longer exists
|
||||
useEffect(() => {
|
||||
if (desktopTwoColumn && view === 'map') setView('timeline')
|
||||
}, [desktopTwoColumn, view])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
|
||||
@@ -114,21 +185,262 @@ export default function JourneyPublicPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// In desktop two-column mode the map is always visible — exclude the standalone 'map' tab
|
||||
const availableViews = [
|
||||
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
||||
perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
||||
perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
||||
!desktopTwoColumn && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
||||
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]
|
||||
|
||||
// Shared timeline renderer used in both layout modes
|
||||
const renderTimeline = () => (
|
||||
<div className="flex flex-col gap-6">
|
||||
{sortedDates.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
|
||||
<BookOpen size={24} className="text-zinc-400" />
|
||||
</div>
|
||||
<p className="text-[15px] font-medium text-zinc-700 dark:text-zinc-300">No entries yet</p>
|
||||
</div>
|
||||
)}
|
||||
{sortedDates.map((date, dayIdx) => {
|
||||
const dayEntries = groupedEntries.get(date)!
|
||||
const fd = formatDate(date, locale)
|
||||
const dayColor = DAY_COLORS[dayIdx % DAY_COLORS.length]
|
||||
return (
|
||||
<div key={date}>
|
||||
{/* Day header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-[14px] font-bold text-white flex-shrink-0"
|
||||
style={{ background: dayColor }}
|
||||
>
|
||||
{dayIdx + 1}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div>
|
||||
<div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entries */}
|
||||
<div className="flex flex-col gap-4 pl-[52px]">
|
||||
{dayEntries.map(entry => {
|
||||
const photos = entry.photos || []
|
||||
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
|
||||
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
|
||||
const prosArr = entry.pros_cons?.pros ?? []
|
||||
const consArr = entry.pros_cons?.cons ?? []
|
||||
const hasProscons = prosArr.length > 0 || consArr.length > 0
|
||||
const lightboxPhotos = photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption }))
|
||||
|
||||
const isActive = activeEntryId === String(entry.id)
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
data-entry-id={String(entry.id)}
|
||||
onMouseEnter={() => {
|
||||
if (!desktopTwoColumn) return
|
||||
setActiveEntryId(String(entry.id))
|
||||
mapRef.current?.highlightMarker(String(entry.id))
|
||||
}}
|
||||
style={isActive && desktopTwoColumn ? { outline: `2px solid ${dayColor}`, outlineOffset: '3px', borderRadius: '16px' } : undefined}
|
||||
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden">
|
||||
|
||||
{/* Photo area */}
|
||||
{photos.length === 1 && (
|
||||
<div className="relative cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}>
|
||||
<img src={photoUrl(photos[0], token!)} className="w-full h-64 object-cover" alt="" />
|
||||
<div className="absolute inset-x-0 bottom-0 pointer-events-none" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 60%, transparent 100%)', height: '65%' }} />
|
||||
{entry.location_name && (
|
||||
<div className="absolute top-3 left-4">
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white">
|
||||
<MapPin size={10} className="flex-shrink-0" />
|
||||
<span className="truncate max-w-[200px]">{formatLocationName(entry.location_name)}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.title && (
|
||||
<div className="absolute bottom-4 left-5 right-5 pointer-events-none">
|
||||
<h3 className="text-[18px] font-bold text-white drop-shadow-sm leading-tight">{entry.title}</h3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{photos.length === 2 && (
|
||||
<div className="grid grid-cols-2 gap-0.5 overflow-hidden">
|
||||
{photos.slice(0, 2).map((p, i) => (
|
||||
<img
|
||||
key={p.id}
|
||||
src={photoUrl(p, token!, 'thumbnail')}
|
||||
alt=""
|
||||
className="w-full h-52 object-cover cursor-pointer"
|
||||
onClick={() => setLightbox({ photos: lightboxPhotos, index: i })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{photos.length >= 3 && (
|
||||
<div className="overflow-hidden flex" style={{ height: 280, gap: 2 }}>
|
||||
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}>
|
||||
<img src={photoUrl(photos[0], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col" style={{ gap: 2 }}>
|
||||
<div className="flex-1 min-h-0 cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 1 })}>
|
||||
<img src={photoUrl(photos[1], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 relative cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 2 })}>
|
||||
<img src={photoUrl(photos[2], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
|
||||
{photos.length > 3 && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<span className="text-white text-[13px] font-semibold flex items-center gap-1">
|
||||
<Image size={13} /> +{photos.length - 3}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-5 pt-4 pb-5">
|
||||
{/* Title (only when no single photo — photo has it in overlay) */}
|
||||
{photos.length !== 1 && entry.title && (
|
||||
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-2">{entry.title}</h3>
|
||||
)}
|
||||
|
||||
{/* Location + time badges */}
|
||||
{(entry.location_name || entry.entry_time) && photos.length !== 1 && (
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
{entry.location_name && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-zinc-500">
|
||||
<MapPin size={11} className="flex-shrink-0" />
|
||||
{formatLocationName(entry.location_name)}
|
||||
</span>
|
||||
)}
|
||||
{entry.entry_time && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-zinc-400">
|
||||
<Clock size={11} />
|
||||
{entry.entry_time.slice(0, 5)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{entry.entry_time && photos.length === 1 && (
|
||||
<div className="flex items-center gap-1 text-[11px] text-zinc-400 mb-2">
|
||||
<Clock size={11} />
|
||||
{entry.entry_time.slice(0, 5)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Story */}
|
||||
{entry.story && (
|
||||
<div className="text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed">
|
||||
<JournalBody text={entry.story} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pros & Cons */}
|
||||
{hasProscons && (
|
||||
<div className={`grid gap-3 mt-4 ${prosArr.length > 0 && consArr.length > 0 ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{prosArr.length > 0 && (
|
||||
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-3" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
|
||||
<div className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-green-700 mb-2">
|
||||
<ThumbsUp size={10} /> Pros
|
||||
</div>
|
||||
{prosArr.map((p, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-[12px] text-green-900 mb-1">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[6px]" />{p}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{consArr.length > 0 && (
|
||||
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-3" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
|
||||
<div className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-red-700 mb-2">
|
||||
<ThumbsDown size={10} /> Cons
|
||||
</div>
|
||||
{consArr.map((c, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-[12px] text-red-900 mb-1">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[6px]" />{c}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mood + weather */}
|
||||
{(mood || weather) && (
|
||||
<div className="flex items-center gap-1.5 pt-3 mt-3 border-t border-zinc-100 dark:border-zinc-800">
|
||||
{mood && (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium ${mood.bg} ${mood.text}`}>
|
||||
<mood.icon size={11} /> {mood.label}
|
||||
</span>
|
||||
)}
|
||||
{weather && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
|
||||
<weather.icon size={11} /> {weather.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Shared gallery renderer
|
||||
const renderGallery = () => (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
|
||||
{allPhotos.map(({ photo }, idx) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
|
||||
onClick={() => setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })}
|
||||
>
|
||||
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Shared view tab bar
|
||||
const renderTabs = (views: typeof availableViews) => views.length > 1 && (
|
||||
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden mb-6 w-fit">
|
||||
{views.map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
||||
view === v.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<v.icon size={13} />
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
{/* Hero */}
|
||||
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}>
|
||||
{/* Cover image background */}
|
||||
{journey.cover_image && (
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
|
||||
)}
|
||||
{/* Decorative circles */}
|
||||
<div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: 'rgba(255,255,255,0.03)' }} />
|
||||
<div style={{ position: 'absolute', bottom: -40, left: -40, width: 150, height: 150, borderRadius: '50%', background: 'rgba(255,255,255,0.02)' }} />
|
||||
|
||||
@@ -183,160 +495,98 @@ export default function JourneyPublicPage() {
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6">
|
||||
|
||||
{/* View tabs */}
|
||||
{availableViews.length > 1 && (
|
||||
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden mb-6 w-fit">
|
||||
{availableViews.map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
||||
view === v.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<v.icon size={13} />
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
{desktopTwoColumn ? (
|
||||
// ── Desktop two-column: scrollable timeline feed + sticky map ──────────
|
||||
<div className="max-w-[1440px] mx-auto flex" style={{ alignItems: 'flex-start' }}>
|
||||
{/* Left: feed */}
|
||||
<div className="flex-1 min-w-0 px-8 py-6">
|
||||
{renderTabs(availableViews)}
|
||||
{view === 'timeline' && perms.share_timeline && renderTimeline()}
|
||||
{view === 'gallery' && perms.share_gallery && renderGallery()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile combined map+timeline (public, read-only) */}
|
||||
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
|
||||
<MobileMapTimeline
|
||||
entries={entries}
|
||||
mapEntries={mapEntries.map(e => ({ id: String(e.id), lat: e.location_lat!, lng: e.location_lng!, title: e.title, mood: e.mood, entry_date: e.entry_date }))}
|
||||
dark={document.documentElement.classList.contains('dark')}
|
||||
readOnly
|
||||
onEntryClick={() => {}}
|
||||
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
|
||||
/>
|
||||
)}
|
||||
{/* Right: sticky map — matches auth page aside proportions */}
|
||||
<aside
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
width: '44%', minWidth: 420, maxWidth: 760,
|
||||
position: 'sticky', top: 0, height: '100dvh',
|
||||
padding: '16px 16px 16px 0',
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className="h-full rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<JourneyMap
|
||||
ref={mapRef}
|
||||
checkins={[]}
|
||||
entries={sidebarMapItems as any}
|
||||
height={9999}
|
||||
fullScreen
|
||||
activeMarkerId={activeEntryId ?? undefined}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
) : (
|
||||
// ── Single-column layout (mobile + desktop-without-map) ───────────────
|
||||
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6">
|
||||
|
||||
{/* Timeline (desktop, or mobile without map permission) */}
|
||||
{(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{sortedDates.map(date => {
|
||||
const dayEntries = groupedEntries.get(date)!
|
||||
const fd = formatDate(date)
|
||||
return (
|
||||
<div key={date}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[14px] font-bold">{fd.day}</div>
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div>
|
||||
<div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 pl-[52px]">
|
||||
{dayEntries.map(entry => (
|
||||
<div key={entry.id} className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden">
|
||||
{entry.photos.length > 0 && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={photoUrl(entry.photos[0], token!)}
|
||||
className="w-full h-52 object-cover cursor-pointer"
|
||||
alt=""
|
||||
onClick={() => setLightbox({ photos: entry.photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: 0 })}
|
||||
/>
|
||||
{entry.photos.length > 1 && (
|
||||
<div className="absolute bottom-2 right-2 bg-black/60 backdrop-blur text-white rounded-full px-2 py-0.5 text-[10px] font-semibold flex items-center gap-1">
|
||||
<Image size={10} /> +{entry.photos.length - 1}
|
||||
</div>
|
||||
)}
|
||||
{entry.title && (
|
||||
<div className="absolute inset-x-0 bottom-0 p-4" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 100%)' }}>
|
||||
<h3 className="text-[18px] font-bold text-white drop-shadow-sm">{entry.title}</h3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-5 py-4">
|
||||
{!entry.photos.length && entry.title && (
|
||||
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white mb-1">{entry.title}</h3>
|
||||
)}
|
||||
{entry.location_name && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-zinc-500 mb-2">
|
||||
<MapPin size={11} /> {entry.location_name}
|
||||
</div>
|
||||
)}
|
||||
{entry.story && (
|
||||
<div className="text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed">
|
||||
<JournalBody text={entry.story} />
|
||||
</div>
|
||||
)}
|
||||
{entry.pros_cons && ((entry.pros_cons.pros?.length ?? 0) > 0 || (entry.pros_cons.cons?.length ?? 0) > 0) && (
|
||||
<div className="grid grid-cols-2 gap-3 mt-4">
|
||||
{(entry.pros_cons.pros?.length ?? 0) > 0 && (
|
||||
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-3" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
|
||||
<div className="text-[10px] font-bold uppercase tracking-wide text-green-700 mb-2">{t('journey.editor.pros')}</div>
|
||||
{entry.pros_cons.pros!.map((p, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-[12px] text-green-900 mb-1">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[6px]" />{p}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(entry.pros_cons.cons?.length ?? 0) > 0 && (
|
||||
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-3" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
|
||||
<div className="text-[10px] font-bold uppercase tracking-wide text-red-700 mb-2">{t('journey.editor.cons')}</div>
|
||||
{entry.pros_cons.cons!.map((c, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-[12px] text-red-900 mb-1">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[6px]" />{c}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery */}
|
||||
{view === 'gallery' && perms.share_gallery && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
|
||||
{allPhotos.map(({ photo }, idx) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
|
||||
onClick={() => setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: idx })}
|
||||
>
|
||||
<img src={photoUrl(photo, token!)} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
|
||||
{/* Floating view toggle — visible above the fullscreen map on mobile */}
|
||||
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && availableViews.length > 1 && (
|
||||
<div className="fixed left-0 right-0 z-50 flex justify-center px-4" style={{ top: 'calc(env(safe-area-inset-top, 0px) + 12px)' }}>
|
||||
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
|
||||
{availableViews.map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
||||
view === v.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<v.icon size={13} />
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map */}
|
||||
{view === 'map' && perms.share_map && (
|
||||
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700">
|
||||
<JourneyMap
|
||||
checkins={[]}
|
||||
entries={mapEntries.map(e => ({
|
||||
id: String(e.id),
|
||||
lat: e.location_lat!,
|
||||
lng: e.location_lng!,
|
||||
title: e.title || '',
|
||||
mood: e.mood,
|
||||
created_at: e.entry_date,
|
||||
entry_date: e.entry_date,
|
||||
})) as any}
|
||||
height={500}
|
||||
{renderTabs(availableViews)}
|
||||
|
||||
{/* Mobile combined map+timeline (public, read-only) */}
|
||||
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
|
||||
<MobileMapTimeline
|
||||
entries={timelineEntries}
|
||||
mapEntries={sidebarMapItems as any}
|
||||
dark={document.documentElement.classList.contains('dark')}
|
||||
readOnly
|
||||
onEntryClick={() => {}}
|
||||
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
|
||||
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline (desktop, or mobile without map permission) */}
|
||||
{(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && renderTimeline()}
|
||||
|
||||
{/* Gallery */}
|
||||
{view === 'gallery' && perms.share_gallery && renderGallery()}
|
||||
|
||||
{/* Map (standalone tab — only in single-column mode) */}
|
||||
{view === 'map' && perms.share_map && (
|
||||
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700">
|
||||
<JourneyMap
|
||||
checkins={[]}
|
||||
entries={sidebarMapItems as any}
|
||||
height={500}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Powered by */}
|
||||
<div className="flex flex-col items-center py-8 gap-2">
|
||||
|
||||
@@ -781,6 +781,17 @@ export default function LoginPage(): React.ReactElement {
|
||||
}} />
|
||||
</button>
|
||||
</div>
|
||||
{mode === 'login' && (
|
||||
<div style={{ textAlign: 'right', marginTop: 6 }}>
|
||||
<button type="button" onClick={() => navigate('/forgot-password')} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||
color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit',
|
||||
}}
|
||||
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.color = '#111827' }}
|
||||
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.color = '#6b7280' }}
|
||||
>{t('login.forgotPassword')}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useEffect, FormEvent, ChangeEvent } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Lock, KeyRound, CheckCircle2, AlertTriangle, Eye, EyeOff } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { authApi } from '../api/client'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
|
||||
const inputBase: React.CSSProperties = {
|
||||
width: '100%', padding: '11px 44px 11px 38px', borderRadius: 12,
|
||||
border: '1px solid #e5e7eb', fontSize: 14, fontFamily: 'inherit',
|
||||
outline: 'none', transition: 'border-color 120ms',
|
||||
background: 'white', color: '#111827',
|
||||
}
|
||||
|
||||
const ResetPasswordPage: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [params] = useSearchParams()
|
||||
const token = params.get('token') || ''
|
||||
|
||||
const [pw, setPw] = useState('')
|
||||
const [pw2, setPw2] = useState('')
|
||||
const [showPw, setShowPw] = useState(false)
|
||||
const [mfaCode, setMfaCode] = useState('')
|
||||
const [mfaRequired, setMfaRequired] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) setError(t('login.resetPasswordInvalidLink'))
|
||||
}, [token, t])
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (isLoading) return
|
||||
setError('')
|
||||
if (!token) return
|
||||
if (pw.length < 8) { setError(t('login.passwordMinLength')); return }
|
||||
if (pw !== pw2) { setError(t('login.passwordsDontMatch')); return }
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await authApi.resetPassword({
|
||||
token,
|
||||
new_password: pw,
|
||||
...(mfaRequired && mfaCode ? { mfa_code: mfaCode.trim() } : {}),
|
||||
})
|
||||
if (res.mfa_required) {
|
||||
setMfaRequired(true)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
if (res.success) {
|
||||
setSuccess(true)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, t('login.resetPasswordFailed')))
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const shell = (inner: React.ReactNode) => (
|
||||
<div style={{
|
||||
minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'linear-gradient(180deg, #f9fafb, #ffffff)', padding: 24, fontFamily: 'inherit',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '100%', maxWidth: 440, background: 'white', borderRadius: 20,
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.06), 0 2px 8px rgba(0,0,0,0.04)',
|
||||
padding: '32px 28px',
|
||||
}}>{inner}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (success) {
|
||||
return shell(
|
||||
<div style={{ textAlign: 'center', padding: '12px 0' }}>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: '50%', background: '#ecfdf5',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#059669', marginBottom: 16,
|
||||
}}><CheckCircle2 size={28} /></div>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 10px 0' }}>
|
||||
{t('login.resetPasswordSuccessTitle')}
|
||||
</h1>
|
||||
<p style={{ fontSize: 14, color: '#4b5563', lineHeight: 1.55, margin: 0 }}>
|
||||
{t('login.resetPasswordSuccessBody')}
|
||||
</p>
|
||||
<button type="button" onClick={() => navigate('/login')} style={{
|
||||
marginTop: 24, padding: '11px 22px', background: '#111827', color: 'white',
|
||||
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>{t('login.signIn')}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return shell(
|
||||
<div style={{ textAlign: 'center', padding: '12px 0' }}>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: '50%', background: '#fef2f2',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#dc2626', marginBottom: 16,
|
||||
}}><AlertTriangle size={28} /></div>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 10px 0' }}>
|
||||
{t('login.resetPasswordInvalidLink')}
|
||||
</h1>
|
||||
<p style={{ fontSize: 14, color: '#4b5563', lineHeight: 1.55, margin: 0 }}>
|
||||
{t('login.resetPasswordInvalidLinkBody')}
|
||||
</p>
|
||||
<button type="button" onClick={() => navigate('/forgot-password')} style={{
|
||||
marginTop: 24, padding: '11px 22px', background: '#111827', color: 'white',
|
||||
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>{t('login.forgotPasswordSubmit')}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return shell(
|
||||
<>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 8px 0' }}>
|
||||
{t('login.resetPasswordTitle')}
|
||||
</h1>
|
||||
<p style={{ fontSize: 13.5, color: '#6b7280', lineHeight: 1.55, margin: '0 0 22px 0' }}>
|
||||
{mfaRequired ? t('login.resetPasswordMfaBody') : t('login.resetPasswordBody')}
|
||||
</p>
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '10px 12px', background: '#fef2f2', border: '1px solid #fecaca',
|
||||
borderRadius: 10, color: '#991b1b', fontSize: 13, marginBottom: 14,
|
||||
}}>{error}</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{!mfaRequired && (
|
||||
<>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>
|
||||
{t('login.newPassword')}
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'} value={pw}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setPw(e.target.value)}
|
||||
required placeholder="••••••••" style={inputBase}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }}
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPw(v => !v)} style={{
|
||||
position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#9ca3af',
|
||||
}}>{showPw ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>
|
||||
{t('login.confirmPassword')}
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'} value={pw2}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setPw2(e.target.value)}
|
||||
required placeholder="••••••••" style={inputBase}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{mfaRequired && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>
|
||||
{t('login.mfaCode')}
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<KeyRound size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||
<input
|
||||
type="text" inputMode="numeric" value={mfaCode}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value)}
|
||||
required placeholder="123456 or backup-code" style={{ ...inputBase, paddingRight: 12 }}
|
||||
autoFocus
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button type="submit" disabled={isLoading} style={{
|
||||
width: '100%', padding: '12px', background: '#111827', color: 'white',
|
||||
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
|
||||
cursor: isLoading ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
}}>
|
||||
{isLoading ? '…' : (mfaRequired ? t('login.resetPasswordVerify') : t('login.resetPasswordSubmit'))}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResetPasswordPage
|
||||
@@ -642,6 +642,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
|
||||
toast.success(t('trip.toast.reservationUpdated'))
|
||||
setShowReservationModal(false)
|
||||
setEditingReservation(null)
|
||||
if (data.type === 'hotel') {
|
||||
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
||||
}
|
||||
|
||||
@@ -171,6 +171,8 @@ export interface Reservation {
|
||||
place_id?: number | null
|
||||
assignment_id?: number | null
|
||||
accommodation_id?: number | null
|
||||
accommodation_start_day_id?: number | null
|
||||
accommodation_end_day_id?: number | null
|
||||
day_plan_position?: number | null
|
||||
metadata?: Record<string, string> | string | null
|
||||
needs_review?: number
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
import type { AssignmentsMap } from '../types'
|
||||
|
||||
// Collapses verbose Nominatim display_name strings (e.g. "Place, 1, Road, Neighbourhood,
|
||||
// City, County, State, Country, Postcode, Country") into "Place, Postcode, Country".
|
||||
// Clean short names (≤3 parts) pass through untouched.
|
||||
export function formatLocationName(raw: string | null | undefined): string {
|
||||
if (!raw) return ''
|
||||
const parts = raw.split(',').map(p => p.trim()).filter(Boolean)
|
||||
if (parts.length <= 3) return raw.trim()
|
||||
|
||||
// Dedup preserving insertion order
|
||||
const seen = new Set<string>()
|
||||
const unique: string[] = []
|
||||
for (const p of parts) {
|
||||
if (!seen.has(p.toLowerCase())) { seen.add(p.toLowerCase()); unique.push(p) }
|
||||
}
|
||||
if (unique.length <= 3) return unique.join(', ')
|
||||
|
||||
const name = unique[0]
|
||||
const last = unique[unique.length - 1]
|
||||
const secondLast = unique.length >= 2 ? unique[unique.length - 2] : null
|
||||
|
||||
// Detect postcode at tail: short alphanumeric with at least one digit, ≤10 chars
|
||||
const postalRe = /^[A-Z0-9][A-Z0-9\s\-]{1,8}$/i
|
||||
const isLastPostal = postalRe.test(last) && /\d/.test(last) && last.length <= 10
|
||||
const postcode = isLastPostal ? last : null
|
||||
const country = isLastPostal ? secondLast : last
|
||||
|
||||
const result: string[] = [name]
|
||||
if (postcode && postcode !== name) result.push(postcode)
|
||||
if (country && country !== name && country !== postcode) result.push(country)
|
||||
|
||||
return result.join(', ')
|
||||
}
|
||||
|
||||
const ZERO_DECIMAL_CURRENCIES = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'HUF'])
|
||||
|
||||
export function currencyDecimals(currency: string): number {
|
||||
|
||||
@@ -64,11 +64,13 @@ class _MockIntersectionObserver {
|
||||
globalThis.IntersectionObserver = _MockIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||
|
||||
// ResizeObserver — used by resizable panels
|
||||
globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
})) as unknown as typeof ResizeObserver;
|
||||
class _MockResizeObserver {
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
constructor(_callback: ResizeObserverCallback) {}
|
||||
}
|
||||
globalThis.ResizeObserver = _MockResizeObserver as unknown as typeof ResizeObserver;
|
||||
|
||||
// URL.createObjectURL / revokeObjectURL — Node 22 URL.createObjectURL requires
|
||||
// a native node:buffer Blob; passing a jsdom Blob throws ERR_INVALID_ARG_TYPE.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// FE-CLIENT-INTERCEPTOR-001 to FE-CLIENT-INTERCEPTOR-012
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isAuthPublicPath } from '../../../src/api/client'
|
||||
|
||||
describe('FE-CLIENT-INTERCEPTOR: 401 AUTH_REQUIRED redirect allowlist', () => {
|
||||
describe('exact-match public paths — no redirect', () => {
|
||||
it('FE-CLIENT-INTERCEPTOR-001: /login', () => {
|
||||
expect(isAuthPublicPath('/login')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-CLIENT-INTERCEPTOR-002: /register', () => {
|
||||
expect(isAuthPublicPath('/register')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-CLIENT-INTERCEPTOR-003: /forgot-password', () => {
|
||||
expect(isAuthPublicPath('/forgot-password')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-CLIENT-INTERCEPTOR-004: /reset-password', () => {
|
||||
expect(isAuthPublicPath('/reset-password')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prefix-match public paths — no redirect', () => {
|
||||
it('FE-CLIENT-INTERCEPTOR-005: /shared/:token', () => {
|
||||
expect(isAuthPublicPath('/shared/abc123token')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-CLIENT-INTERCEPTOR-006: /public/journey/:token', () => {
|
||||
expect(isAuthPublicPath('/public/journey/xyz789')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('paths that matched via includes() before fix — must redirect', () => {
|
||||
it('FE-CLIENT-INTERCEPTOR-007: /admin/login', () => {
|
||||
expect(isAuthPublicPath('/admin/login')).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-CLIENT-INTERCEPTOR-008: /admin/register', () => {
|
||||
expect(isAuthPublicPath('/admin/register')).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-CLIENT-INTERCEPTOR-009: /some-login-page', () => {
|
||||
expect(isAuthPublicPath('/some-login-page')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('paths that matched via loose startsWith before fix — must redirect', () => {
|
||||
it('FE-CLIENT-INTERCEPTOR-010: /reset-password-extra', () => {
|
||||
expect(isAuthPublicPath('/reset-password-extra')).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-CLIENT-INTERCEPTOR-011: /forgot-password-extra', () => {
|
||||
expect(isAuthPublicPath('/forgot-password-extra')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('private app paths — must redirect', () => {
|
||||
it('FE-CLIENT-INTERCEPTOR-012: /dashboard', () => {
|
||||
expect(isAuthPublicPath('/dashboard')).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-CLIENT-INTERCEPTOR-013: /trips/123', () => {
|
||||
expect(isAuthPublicPath('/trips/123')).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-CLIENT-INTERCEPTOR-014: / (root)', () => {
|
||||
expect(isAuthPublicPath('/')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 428 KiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 792 KiB |
|
After Width: | Height: | Size: 908 KiB |
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 928 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
@@ -0,0 +1,146 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 820" role="img" aria-label="TREK feature tiles">
|
||||
<g transform="translate(0 0)">
|
||||
<defs>
|
||||
<clipPath id="clip-planner"><rect width="520" height="260" rx="22"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip-planner)">
|
||||
<rect width="520" height="260" fill="#FFE8D9"/>
|
||||
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
|
||||
<g transform="translate(390 22) scale(6)" fill="none" stroke="#B5492D" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M8 2v4M16 2v4M3 10h18"/></g>
|
||||
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
|
||||
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#B5492D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M8 2v4M16 2v4M3 10h18"/></g>
|
||||
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#B5492D">TRIP PLANNER</text>
|
||||
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Drag and drop</text>
|
||||
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#B5492D">day by day</text>
|
||||
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#7C3B2A">Reorder, move across days, optimise</text>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(540 0)">
|
||||
<defs>
|
||||
<clipPath id="clip-maps"><rect width="520" height="260" rx="22"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip-maps)">
|
||||
<rect width="520" height="260" fill="#DDEDFB"/>
|
||||
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
|
||||
<g transform="translate(390 22) scale(6)" fill="none" stroke="#1E5AA8" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M1 6l7-3 8 3 7-3v15l-7 3-8-3-7 3zM8 3v15M16 6v15"/></g>
|
||||
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
|
||||
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#1E5AA8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 6l7-3 8 3 7-3v15l-7 3-8-3-7 3zM8 3v15M16 6v15"/></g>
|
||||
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#1E5AA8">MAPS</text>
|
||||
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">See it all</text>
|
||||
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1E5AA8">on the map</text>
|
||||
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#3B4D66">Leaflet + Mapbox GL, 3D buildings</text>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(1080 0)">
|
||||
<defs>
|
||||
<clipPath id="clip-collab"><rect width="520" height="260" rx="22"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip-collab)">
|
||||
<rect width="520" height="260" fill="#D6F0E3"/>
|
||||
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
|
||||
<g transform="translate(390 22) scale(6)" fill="none" stroke="#1F7A4E" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></g>
|
||||
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
|
||||
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#1F7A4E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></g>
|
||||
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#1F7A4E">COLLAB</text>
|
||||
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Plan together</text>
|
||||
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1F7A4E">in real time</text>
|
||||
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#325646">WebSocket sync, chat, polls, notes</text>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(0 280)">
|
||||
<defs>
|
||||
<clipPath id="clip-budget"><rect width="520" height="260" rx="22"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip-budget)">
|
||||
<rect width="520" height="260" fill="#FDF0C7"/>
|
||||
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
|
||||
<g transform="translate(390 22) scale(6)" fill="none" stroke="#8A6A1E" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M20 12V8a2 2 0 0 0-2-2H4a2 2 0 0 1 0-4h14v4"/><path d="M2 6v14a2 2 0 0 0 2 2h18v-8H16a2 2 0 0 1 0-4h6"/></g>
|
||||
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
|
||||
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#8A6A1E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 12V8a2 2 0 0 0-2-2H4a2 2 0 0 1 0-4h14v4"/><path d="M2 6v14a2 2 0 0 0 2 2h18v-8H16a2 2 0 0 1 0-4h6"/></g>
|
||||
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#8A6A1E">BUDGET</text>
|
||||
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Track costs</text>
|
||||
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#8A6A1E">per person</text>
|
||||
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#6B5523">Pie chart, multi-currency, splits</text>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(540 280)">
|
||||
<defs>
|
||||
<clipPath id="clip-packing"><rect width="520" height="260" rx="22"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip-packing)">
|
||||
<rect width="520" height="260" fill="#E4DEF6"/>
|
||||
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
|
||||
<g transform="translate(390 22) scale(6)" fill="none" stroke="#5443A5" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></g>
|
||||
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
|
||||
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#5443A5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></g>
|
||||
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#5443A5">PACKING</text>
|
||||
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Lists, sorted.</text>
|
||||
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#5443A5">by category</text>
|
||||
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#4A3E72">Templates, bag tracking, weights</text>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(1080 280)">
|
||||
<defs>
|
||||
<clipPath id="clip-journal"><rect width="520" height="260" rx="22"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip-journal)">
|
||||
<rect width="520" height="260" fill="#FBDAE4"/>
|
||||
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
|
||||
<g transform="translate(390 22) scale(6)" fill="none" stroke="#9A2F58" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></g>
|
||||
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
|
||||
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#9A2F58" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></g>
|
||||
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#9A2F58">JOURNEY</text>
|
||||
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">A journal for</text>
|
||||
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#9A2F58">every trip</text>
|
||||
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#6E3345">Magazine entries, photos, maps</text>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(0 560)">
|
||||
<defs>
|
||||
<clipPath id="clip-vacay"><rect width="520" height="260" rx="22"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip-vacay)">
|
||||
<rect width="520" height="260" fill="#D4DCF7"/>
|
||||
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
|
||||
<g transform="translate(390 22) scale(6)" fill="none" stroke="#2F3FA4" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></g>
|
||||
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
|
||||
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#2F3FA4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></g>
|
||||
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#2F3FA4">VACAY</text>
|
||||
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Vacation days,</text>
|
||||
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#2F3FA4">visualised</text>
|
||||
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#3B4471">Calendar, 100+ country holidays</text>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(540 560)">
|
||||
<defs>
|
||||
<clipPath id="clip-mcp"><rect width="520" height="260" rx="22"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip-mcp)">
|
||||
<rect width="520" height="260" fill="#CFEDE6"/>
|
||||
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
|
||||
<g transform="translate(390 22) scale(6)" fill="none" stroke="#116E64" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4M8 16h.01M16 16h.01"/></g>
|
||||
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
|
||||
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#116E64" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4M8 16h.01M16 16h.01"/></g>
|
||||
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#116E64">AI / MCP</text>
|
||||
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Let AI plan</text>
|
||||
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#116E64">your trips</text>
|
||||
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#2C524C">80+ tools, OAuth 2.1, Claude-ready</text>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(1080 560)">
|
||||
<defs>
|
||||
<clipPath id="clip-selfhost"><rect width="520" height="260" rx="22"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip-selfhost)">
|
||||
<rect width="520" height="260" fill="#E4E6EB"/>
|
||||
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
|
||||
<g transform="translate(390 22) scale(6)" fill="none" stroke="#2D3544" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></g>
|
||||
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
|
||||
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#2D3544" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></g>
|
||||
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#2D3544">SELF-HOSTED</text>
|
||||
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Runs on</text>
|
||||
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#2D3544">your server</text>
|
||||
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#414958">Docker, SQLite, AGPL — your data, yours</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,130 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1060 1100" role="img" aria-label="TREK feature tiles">
|
||||
<g transform="translate(0 0)">
|
||||
<defs>
|
||||
<clipPath id="clip-planner"><rect width="520" height="260" rx="22"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip-planner)">
|
||||
<rect width="520" height="260" fill="#FFE8D9"/>
|
||||
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
|
||||
<g transform="translate(390 22) scale(6)" fill="none" stroke="#B5492D" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M8 2v4M16 2v4M3 10h18"/></g>
|
||||
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
|
||||
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#B5492D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M8 2v4M16 2v4M3 10h18"/></g>
|
||||
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#B5492D">TRIP PLANNER</text>
|
||||
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Drag and drop</text>
|
||||
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#B5492D">day by day</text>
|
||||
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#7C3B2A">Reorder, move across days, optimise</text>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(540 0)">
|
||||
<defs>
|
||||
<clipPath id="clip-maps"><rect width="520" height="260" rx="22"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip-maps)">
|
||||
<rect width="520" height="260" fill="#DDEDFB"/>
|
||||
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
|
||||
<g transform="translate(390 22) scale(6)" fill="none" stroke="#1E5AA8" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M1 6l7-3 8 3 7-3v15l-7 3-8-3-7 3zM8 3v15M16 6v15"/></g>
|
||||
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
|
||||
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#1E5AA8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 6l7-3 8 3 7-3v15l-7 3-8-3-7 3zM8 3v15M16 6v15"/></g>
|
||||
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#1E5AA8">MAPS</text>
|
||||
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">See it all</text>
|
||||
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1E5AA8">on the map</text>
|
||||
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#3B4D66">Leaflet + Mapbox GL, 3D buildings</text>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(0 280)">
|
||||
<defs>
|
||||
<clipPath id="clip-collab"><rect width="520" height="260" rx="22"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip-collab)">
|
||||
<rect width="520" height="260" fill="#D6F0E3"/>
|
||||
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
|
||||
<g transform="translate(390 22) scale(6)" fill="none" stroke="#1F7A4E" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></g>
|
||||
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
|
||||
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#1F7A4E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></g>
|
||||
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#1F7A4E">COLLAB</text>
|
||||
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Plan together</text>
|
||||
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1F7A4E">in real time</text>
|
||||
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#325646">WebSocket sync, chat, polls, notes</text>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(540 280)">
|
||||
<defs>
|
||||
<clipPath id="clip-budget"><rect width="520" height="260" rx="22"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip-budget)">
|
||||
<rect width="520" height="260" fill="#FDF0C7"/>
|
||||
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
|
||||
<g transform="translate(390 22) scale(6)" fill="none" stroke="#8A6A1E" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M20 12V8a2 2 0 0 0-2-2H4a2 2 0 0 1 0-4h14v4"/><path d="M2 6v14a2 2 0 0 0 2 2h18v-8H16a2 2 0 0 1 0-4h6"/></g>
|
||||
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
|
||||
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#8A6A1E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 12V8a2 2 0 0 0-2-2H4a2 2 0 0 1 0-4h14v4"/><path d="M2 6v14a2 2 0 0 0 2 2h18v-8H16a2 2 0 0 1 0-4h6"/></g>
|
||||
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#8A6A1E">BUDGET</text>
|
||||
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Track costs</text>
|
||||
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#8A6A1E">per person</text>
|
||||
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#6B5523">Pie chart, multi-currency, splits</text>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(0 560)">
|
||||
<defs>
|
||||
<clipPath id="clip-packing"><rect width="520" height="260" rx="22"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip-packing)">
|
||||
<rect width="520" height="260" fill="#E4DEF6"/>
|
||||
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
|
||||
<g transform="translate(390 22) scale(6)" fill="none" stroke="#5443A5" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></g>
|
||||
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
|
||||
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#5443A5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></g>
|
||||
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#5443A5">PACKING</text>
|
||||
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Lists, sorted.</text>
|
||||
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#5443A5">by category</text>
|
||||
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#4A3E72">Templates, bag tracking, weights</text>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(540 560)">
|
||||
<defs>
|
||||
<clipPath id="clip-journal"><rect width="520" height="260" rx="22"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip-journal)">
|
||||
<rect width="520" height="260" fill="#FBDAE4"/>
|
||||
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
|
||||
<g transform="translate(390 22) scale(6)" fill="none" stroke="#9A2F58" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></g>
|
||||
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
|
||||
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#9A2F58" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></g>
|
||||
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#9A2F58">JOURNEY</text>
|
||||
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">A journal for</text>
|
||||
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#9A2F58">every trip</text>
|
||||
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#6E3345">Magazine entries, photos, maps</text>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(0 840)">
|
||||
<defs>
|
||||
<clipPath id="clip-vacay"><rect width="520" height="260" rx="22"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip-vacay)">
|
||||
<rect width="520" height="260" fill="#D4DCF7"/>
|
||||
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
|
||||
<g transform="translate(390 22) scale(6)" fill="none" stroke="#2F3FA4" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></g>
|
||||
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
|
||||
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#2F3FA4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></g>
|
||||
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#2F3FA4">VACAY</text>
|
||||
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Vacation days,</text>
|
||||
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#2F3FA4">visualised</text>
|
||||
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#3B4471">Calendar, 100+ country holidays</text>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(540 840)">
|
||||
<defs>
|
||||
<clipPath id="clip-mcp"><rect width="520" height="260" rx="22"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip-mcp)">
|
||||
<rect width="520" height="260" fill="#CFEDE6"/>
|
||||
<circle cx="520" cy="0" r="220" fill="#FFFFFF" opacity="0.35"/>
|
||||
<g transform="translate(390 22) scale(6)" fill="none" stroke="#116E64" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity="0.10"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4M8 16h.01M16 16h.01"/></g>
|
||||
<rect x="436" y="28" width="56" height="56" rx="14" fill="#FFFFFF" opacity="0.7"/>
|
||||
<g transform="translate(450 42) scale(1.15)" fill="none" stroke="#116E64" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4M8 16h.01M16 16h.01"/></g>
|
||||
<text x="36" y="62" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="18" font-weight="800" letter-spacing="2.8" fill="#116E64">AI / MCP</text>
|
||||
<text x="36" y="132" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#1A1A1F">Let AI plan</text>
|
||||
<text x="36" y="184" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, Segoe UI, Roboto, sans-serif" font-size="48" font-weight="800" letter-spacing="-1.2" fill="#116E64">your trips</text>
|
||||
<text x="36" y="224" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, Segoe UI, Roboto, sans-serif" font-size="20" font-weight="500" fill="#2C524C">80+ tools, OAuth 2.1, Claude-ready</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -1,7 +0,0 @@
|
||||
# Release Notes
|
||||
|
||||
## v2.9.11
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **OIDC-only mode: resolved login/logout loop** — When password authentication is disabled, logging out no longer triggers an immediate re-authentication loop. After logout, users land on the login page and must manually click "Sign in with {provider}" to start the OIDC flow. Also fixed a secondary loop that could occur on the OIDC callback page under React 18 StrictMode, where the auth code exchange would be interrupted before completing, causing the app to redirect back to the identity provider instead of landing on the dashboard. (#491)
|
||||
@@ -237,8 +237,8 @@ async function main() {
|
||||
}
|
||||
|
||||
db.transaction(() => {
|
||||
// --- app_settings: oidc_client_secret, smtp_pass ---
|
||||
for (const key of ['oidc_client_secret', 'smtp_pass']) {
|
||||
// --- app_settings: oidc_client_secret, smtp_pass, admin_webhook_url, admin_ntfy_token ---
|
||||
for (const key of ['oidc_client_secret', 'smtp_pass', 'admin_webhook_url', 'admin_ntfy_token']) {
|
||||
const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined;
|
||||
if (!row?.value) continue;
|
||||
const newVal = migrateApiKeyValue(row.value, `app_settings.${key}`);
|
||||
@@ -247,8 +247,8 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- users: maps_api_key, openweather_api_key, immich_api_key ---
|
||||
const apiKeyColumns = ['maps_api_key', 'openweather_api_key', 'immich_api_key'];
|
||||
// --- users: api key columns + synology credentials ---
|
||||
const apiKeyColumns = ['maps_api_key', 'openweather_api_key', 'immich_api_key', 'synology_password', 'synology_sid', 'synology_did'];
|
||||
const users = db.prepare('SELECT id FROM users').all() as { id: number }[];
|
||||
|
||||
for (const user of users) {
|
||||
@@ -271,6 +271,37 @@ async function main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- settings: per-user encrypted keys ---
|
||||
const encryptedSettingKeys = ['webhook_url', 'ntfy_token', 'mapbox_access_token'];
|
||||
const settingRows = db.prepare(
|
||||
`SELECT user_id, key, value FROM settings WHERE key IN (${encryptedSettingKeys.map(() => '?').join(', ')})`
|
||||
).all(...encryptedSettingKeys) as { user_id: number; key: string; value: string }[];
|
||||
|
||||
for (const row of settingRows) {
|
||||
if (!row.value) continue;
|
||||
const newVal = migrateApiKeyValue(row.value, `settings[user=${row.user_id}].${row.key}`);
|
||||
if (newVal !== null) {
|
||||
db.prepare('UPDATE settings SET value = ? WHERE user_id = ? AND key = ?').run(newVal, row.user_id, row.key);
|
||||
}
|
||||
}
|
||||
|
||||
// --- trip_album_links: passphrase ---
|
||||
const albumLinks = db.prepare('SELECT id, passphrase FROM trip_album_links WHERE passphrase IS NOT NULL').all() as { id: number; passphrase: string }[];
|
||||
for (const row of albumLinks) {
|
||||
const newVal = migrateApiKeyValue(row.passphrase, `trip_album_links[${row.id}].passphrase`);
|
||||
if (newVal !== null) {
|
||||
db.prepare('UPDATE trip_album_links SET passphrase = ? WHERE id = ?').run(newVal, row.id);
|
||||
}
|
||||
}
|
||||
|
||||
// --- trek_photos: passphrase ---
|
||||
const photos = db.prepare('SELECT id, passphrase FROM trek_photos WHERE passphrase IS NOT NULL').all() as { id: number; passphrase: string }[];
|
||||
for (const row of photos) {
|
||||
const newVal = migrateApiKeyValue(row.passphrase, `trek_photos[${row.id}].passphrase`);
|
||||
if (newVal !== null) {
|
||||
db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ?').run(newVal, row.id);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -5,11 +5,9 @@ import cookieParser from 'cookie-parser';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { JWT_SECRET } from './config';
|
||||
import { logDebug, logWarn, logError } from './services/auditLog';
|
||||
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
|
||||
import { authenticate } from './middleware/auth';
|
||||
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
|
||||
import { db } from './db/database';
|
||||
|
||||
import authRoutes from './routes/auth';
|
||||
@@ -76,6 +74,19 @@ export function createApp(): express.Application {
|
||||
}
|
||||
|
||||
const shouldForceHttps = process.env.FORCE_HTTPS === 'true';
|
||||
// HSTS is worth enabling any time we're serving production traffic,
|
||||
// not only when FORCE_HTTPS is set. Self-hosters behind Traefik /
|
||||
// Caddy / Cloudflare Tunnel typically leave FORCE_HTTPS unset (the
|
||||
// proxy handles the redirect for them), and the previous "HSTS off by
|
||||
// default" meant those instances never advertised HSTS at all.
|
||||
//
|
||||
// `includeSubDomains` stays OFF by default on purpose: an instance
|
||||
// running on an apex domain would otherwise force HTTPS on every
|
||||
// sibling subdomain the same operator may still be running over plain
|
||||
// HTTP. Operators who want the stricter policy opt in with
|
||||
// `HSTS_INCLUDE_SUBDOMAINS=true`.
|
||||
const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production';
|
||||
const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true';
|
||||
|
||||
// RFC 8414 / RFC 9728: discovery docs are world-readable — open CORS regardless of deployment config
|
||||
app.use(
|
||||
@@ -112,7 +123,7 @@ export function createApp(): express.Application {
|
||||
}
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
hsts: shouldForceHttps ? { maxAge: 31536000, includeSubDomains: false } : false,
|
||||
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
|
||||
}));
|
||||
|
||||
if (shouldForceHttps) {
|
||||
@@ -161,12 +172,33 @@ export function createApp(): express.Application {
|
||||
});
|
||||
}
|
||||
|
||||
// Static: avatars, covers, and journey photos
|
||||
// Static: avatars, covers, and journey photos.
|
||||
//
|
||||
// Security model (audit SEC-M9): these paths are unauthenticated by
|
||||
// design. All filenames are server-chosen UUID v4 (see `uuid()` in
|
||||
// the multer storage config for avatars / covers / journey uploads),
|
||||
// which gives each asset >122 bits of namespace entropy — not
|
||||
// guessable via enumeration. An attacker would need to have already
|
||||
// seen the URL (email, shared journey, etc.) to request the file.
|
||||
//
|
||||
// Moving these behind auth would also break:
|
||||
// - Unauthenticated trip-card rendering on public share links
|
||||
// - Journey public-share pages (/public/journey/:token)
|
||||
// - Email-embedded avatars
|
||||
//
|
||||
// The `/uploads/photos/...` route below is DIFFERENT: photo URLs are
|
||||
// not embedded in unauthenticated UI contexts, so that endpoint IS
|
||||
// gated (session JWT with pv, or a share token scoped to the photo's
|
||||
// trip).
|
||||
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
|
||||
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
|
||||
app.use('/uploads/journey', express.static(path.join(__dirname, '../uploads/journey')));
|
||||
|
||||
// Photos require auth or valid share token
|
||||
// Photos require either a valid logged-in session (via JWT with the
|
||||
// password_version gate) OR a share token that covers the SPECIFIC
|
||||
// photo's trip. Previously any share token for any trip could request
|
||||
// any photo filename by UUID — fine in practice because UUIDs are
|
||||
// unguessable, but the auth model was wrong.
|
||||
app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
|
||||
const safeName = path.basename(req.params.filename);
|
||||
const filePath = path.join(__dirname, '../uploads/photos', safeName);
|
||||
@@ -174,17 +206,28 @@ export function createApp(): express.Application {
|
||||
if (!resolved.startsWith(path.resolve(__dirname, '../uploads/photos'))) {
|
||||
return res.status(403).send('Forbidden');
|
||||
}
|
||||
// existsSync here is cheap and avoids a sendFile error frame; kept
|
||||
// sync because the handler is already short-lived.
|
||||
if (!fs.existsSync(resolved)) return res.status(404).send('Not found');
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = (req.query.token as string) || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null);
|
||||
if (!token) return res.status(401).send('Authentication required');
|
||||
const rawToken = (req.query.token as string) || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null);
|
||||
if (!rawToken) return res.status(401).send('Authentication required');
|
||||
|
||||
try {
|
||||
jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
|
||||
} catch {
|
||||
const shareRow = db.prepare('SELECT id FROM share_tokens WHERE token = ?').get(token);
|
||||
if (!shareRow) return res.status(401).send('Authentication required');
|
||||
// JWT session path (with pv check).
|
||||
const user = verifyJwtAndLoadUser(rawToken);
|
||||
if (user) return res.sendFile(resolved);
|
||||
|
||||
// Share-token path: require the token to cover the exact trip the
|
||||
// photo belongs to. Expired tokens fall through to 401.
|
||||
const photo = db.prepare('SELECT trip_id FROM photos WHERE filename = ?').get(safeName) as { trip_id: number } | undefined;
|
||||
if (!photo) return res.status(401).send('Authentication required');
|
||||
|
||||
const share = db.prepare(
|
||||
"SELECT trip_id FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
|
||||
).get(rawToken) as { trip_id: number } | undefined;
|
||||
if (!share || share.trip_id !== photo.trip_id) {
|
||||
return res.status(401).send('Authentication required');
|
||||
}
|
||||
res.sendFile(resolved);
|
||||
});
|
||||
|
||||
@@ -35,15 +35,6 @@ function initDb(): void {
|
||||
|
||||
initDb();
|
||||
|
||||
if (process.env.DEMO_MODE === 'true') {
|
||||
try {
|
||||
const { seedDemoData } = require('../demo/demo-seed');
|
||||
seedDemoData(_db);
|
||||
} catch (err: unknown) {
|
||||
console.error('[Demo] Seed error:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
const db = new Proxy({} as Database.Database, {
|
||||
get(_, prop: string | symbol) {
|
||||
if (!_db) throw new Error('Database connection is not available (restore in progress?)');
|
||||
@@ -56,6 +47,15 @@ const db = new Proxy({} as Database.Database, {
|
||||
},
|
||||
});
|
||||
|
||||
if (process.env.DEMO_MODE === 'true') {
|
||||
try {
|
||||
const { seedDemoData } = require('../demo/demo-seed');
|
||||
seedDemoData(_db);
|
||||
} catch (err: unknown) {
|
||||
console.error('[Demo] Seed error:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
function closeDb(): void {
|
||||
if (_db) {
|
||||
try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
|
||||
|
||||