Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd3d4263a7 | |||
| 5f4e7f9487 | |||
| e652efee8a | |||
| 47028798b1 | |||
| 6076a482b8 | |||
| a590db8e10 | |||
| 502c334cbf | |||
| 031cc3587b | |||
| f55f5ea449 | |||
| 557de4cd5a | |||
| 544ac796d5 | |||
| 5b6e3d6c1a | |||
| df695ee8d8 | |||
| d845057f84 | |||
| e70fe50ae3 | |||
| 2000371844 | |||
| d45d9c2cfa | |||
| d24f0b3ccd | |||
| c1fb745627 |
@@ -4,6 +4,9 @@ node_modules/
|
|||||||
# Build output
|
# Build output
|
||||||
client/dist/
|
client/dist/
|
||||||
|
|
||||||
|
# Generated PWA icons (built from SVG via prebuild)
|
||||||
|
client/public/icons/*.png
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
# NOMAD
|
<p align="center">
|
||||||
|
<img src="client/public/logo-dark.svg" alt="NOMAD" height="60" />
|
||||||
|
<br />
|
||||||
|
<em>Navigation Organizer for Maps, Activities & Destinations</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
**Navigation Organizer for Maps, Activities & Destinations**
|
<p align="center">
|
||||||
|
<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/nomad"><img src="https://img.shields.io/docker/pulls/mauriceboe/nomad" alt="Docker Pulls" /></a>
|
||||||
|
<a href="https://github.com/mauriceboe/NOMAD"><img src="https://img.shields.io/github/stars/mauriceboe/NOMAD" alt="GitHub Stars" /></a>
|
||||||
|
<a href="https://github.com/mauriceboe/NOMAD/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/NOMAD" alt="Last Commit" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
A self-hosted, real-time collaborative travel planner for organizing trips with interactive maps, budgets, packing lists, and more.
|
<p align="center">
|
||||||
|
A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more.
|
||||||
[](LICENSE)
|
<br />
|
||||||
[](https://hub.docker.com/r/mauriceboe/nomad)
|
<strong><a href="https://demo-nomad.pakulat.org">Live Demo</a></strong> — Try NOMAD without installing. Resets hourly.
|
||||||
[](https://github.com/mauriceboe/NOMAD)
|
</p>
|
||||||
[](https://github.com/mauriceboe/NOMAD/commits)
|
|
||||||
|
|
||||||
**[Live Demo](https://demo-nomad.pakulat.org)** — Try NOMAD without installing. Resets hourly.
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -26,38 +32,52 @@ A self-hosted, real-time collaborative travel planner for organizing trips with
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Real-Time Collaboration** — Plan together via WebSocket live sync — changes appear instantly across all connected users
|
### Trip Planning
|
||||||
- **Interactive Map** — Leaflet map with marker 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)
|
|
||||||
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
|
|
||||||
- **Drag & Drop Planner** — Organize places into day plans with reordering and cross-day moves
|
- **Drag & Drop Planner** — Organize places into day plans with reordering and cross-day moves
|
||||||
- **Weather Forecasts** — Current weather and 5-day forecasts with smart caching (requires API key)
|
- **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** — Current weather and 5-day forecasts with smart caching
|
||||||
|
|
||||||
|
### Travel Management
|
||||||
|
- **Reservations & Bookings** — Track flights, hotels, 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
|
- **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support
|
||||||
- **Packing Lists** — Categorized checklists with progress tracking, color coding, and smart suggestions
|
- **Packing Lists** — Categorized checklists with progress tracking, color coding, and smart suggestions
|
||||||
- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments
|
|
||||||
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
|
- **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 images and notes
|
- **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and NOMAD 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
|
- **Multi-User** — Invite members to collaborate on shared trips with role-based access
|
||||||
- **Addon System** — Modular features that admins can enable/disable: Packing Lists, Budget, Documents, and global addons
|
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||||
- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with WebSocket live sync, and carry-over tracking
|
|
||||||
- **Atlas** — Interactive world map showing visited countries with travel stats, continent breakdown, streak tracking, and country details on click
|
### 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, travel stats, continent breakdown, streak tracking, and liquid glass UI effects
|
||||||
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
|
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
|
||||||
- **Admin Panel** — User management with online status, global categories, addon management, API key configuration, and backups
|
|
||||||
- **Auto-Backups** — Scheduled backups with configurable interval and retention
|
### Customization & Admin
|
||||||
- **Route Optimization** — Auto-optimize place order and export to Google Maps
|
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
|
||||||
- **Day Notes** — Add timestamped notes to individual days
|
|
||||||
- **Dark Mode** — Full light and dark theme support
|
|
||||||
- **Multilingual** — English and German (i18n)
|
- **Multilingual** — English and German (i18n)
|
||||||
- **Mobile Friendly** — Responsive design with touch-optimized controls
|
- **Admin Panel** — User management, global categories, addon management, API keys, and backups
|
||||||
|
- **Auto-Backups** — Scheduled backups with configurable interval and retention
|
||||||
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: Node.js 22 + Express + SQLite (`node:sqlite`)
|
- **Backend**: Node.js 22 + Express + SQLite (`node:sqlite`)
|
||||||
- **Frontend**: React 18 + Vite + Tailwind CSS
|
- **Frontend**: React 18 + Vite + Tailwind CSS
|
||||||
|
- **PWA**: vite-plugin-pwa + Workbox
|
||||||
- **Real-Time**: WebSocket (`ws`)
|
- **Real-Time**: WebSocket (`ws`)
|
||||||
- **State**: Zustand
|
- **State**: Zustand
|
||||||
- **Auth**: JWT
|
- **Auth**: JWT + OIDC
|
||||||
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
||||||
- **Weather**: OpenWeatherMap API (optional)
|
- **Weather**: OpenWeatherMap API (optional)
|
||||||
- **Icons**: lucide-react
|
- **Icons**: lucide-react
|
||||||
@@ -70,6 +90,15 @@ docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads maurice
|
|||||||
|
|
||||||
The app runs on port `3000`. The first user to register becomes the admin.
|
The app runs on port `3000`. The first user to register becomes the admin.
|
||||||
|
|
||||||
|
### Install as App (PWA)
|
||||||
|
|
||||||
|
NOMAD works as a Progressive Web App — no App Store needed:
|
||||||
|
|
||||||
|
1. Open your NOMAD 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. NOMAD launches fullscreen with its own icon, just like a native app
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Docker Compose (recommended for production)</summary>
|
<summary>Docker Compose (recommended for production)</summary>
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,25 @@
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23111827' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M17.8 19.2 16 11l3.5-3.5C21 6 21 4 19 2c-2-2-4-2-5.5-.5L10 5 1.8 6.2c-.5.1-.9.6-.6 1.1l1.9 2.9 2.5-.9 4-4 2.7 2.7-4 4 .9 2.5 2.9 1.9c.5.3 1 0 1.1-.5z'/></svg>" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>NOMAD</title>
|
<title>NOMAD</title>
|
||||||
|
|
||||||
|
<!-- PWA / iOS -->
|
||||||
|
<meta name="theme-color" content="#09090b" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="NOMAD" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
|
<!-- Leaflet -->
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "nomad-client",
|
"name": "nomad-client",
|
||||||
"version": "2.5.0",
|
"version": "2.5.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"prebuild": "node scripts/generate-icons.mjs",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
@@ -30,7 +31,9 @@
|
|||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"autoprefixer": "^10.4.18",
|
"autoprefixer": "^10.4.18",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.35",
|
||||||
|
"sharp": "^0.33.0",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"vite": "^5.1.4"
|
"vite": "^5.1.4",
|
||||||
|
"vite-plugin-pwa": "^0.21.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="a5b4275efd"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="61932b752f"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.753906 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.753906 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#a5b4275efd)"><g clip-path="url(#61932b752f)"><path fill="#000000" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="ff6253e8fa"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="c6b14a8188"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#ff6253e8fa)"><g clip-path="url(#c6b14a8188)"><path fill="#ffffff" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#1e293b"/>
|
||||||
|
<stop offset="100%" stop-color="#0f172a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="icon">
|
||||||
|
<path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" fill="url(#bg)"/>
|
||||||
|
<g transform="translate(56,51) scale(0.267)">
|
||||||
|
<rect width="1500" height="1500" fill="#ffffff" clip-path="url(#icon)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Generates PNG icons for PWA from the master SVG icon.
|
||||||
|
* Run: node scripts/generate-icons.mjs
|
||||||
|
* Called automatically via the "prebuild" npm script.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const iconsDir = join(__dirname, '..', 'public', 'icons');
|
||||||
|
const svgBuffer = readFileSync(join(iconsDir, 'icon.svg'));
|
||||||
|
|
||||||
|
const sizes = [
|
||||||
|
{ name: 'apple-touch-icon-180x180.png', size: 180 },
|
||||||
|
{ name: 'icon-192x192.png', size: 192 },
|
||||||
|
{ name: 'icon-512x512.png', size: 512 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, size } of sizes) {
|
||||||
|
await sharp(svgBuffer, { density: 300 })
|
||||||
|
.resize(size, size)
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toFile(join(iconsDir, name));
|
||||||
|
console.log(` \u2713 ${name} (${size}x${size})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('PWA icons generated.');
|
||||||
@@ -78,13 +78,17 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated])
|
}, [isAuthenticated])
|
||||||
|
|
||||||
// Apply dark mode class to <html>
|
// Apply dark mode class to <html> + update PWA theme-color
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings.dark_mode) {
|
if (settings.dark_mode) {
|
||||||
document.documentElement.classList.add('dark')
|
document.documentElement.classList.add('dark')
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.remove('dark')
|
document.documentElement.classList.remove('dark')
|
||||||
}
|
}
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]')
|
||||||
|
if (meta) {
|
||||||
|
meta.setAttribute('content', settings.dark_mode ? '#09090b' : '#ffffff')
|
||||||
|
}
|
||||||
}, [settings.dark_mode])
|
}, [settings.dark_mode])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -29,10 +29,8 @@ function handleMessage(event) {
|
|||||||
// Store our socket ID from welcome message
|
// Store our socket ID from welcome message
|
||||||
if (parsed.type === 'welcome') {
|
if (parsed.type === 'welcome') {
|
||||||
mySocketId = parsed.socketId
|
mySocketId = parsed.socketId
|
||||||
console.log('[WS] Got socketId:', mySocketId)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.log('[WS] Received:', parsed.type, parsed)
|
|
||||||
listeners.forEach(fn => {
|
listeners.forEach(fn => {
|
||||||
try { fn(parsed) } catch (err) { console.error('WebSocket listener error:', err) }
|
try { fn(parsed) } catch (err) { console.error('WebSocket listener error:', err) }
|
||||||
})
|
})
|
||||||
@@ -61,14 +59,14 @@ function connectInternal(token, isReconnect = false) {
|
|||||||
socket = new WebSocket(url)
|
socket = new WebSocket(url)
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
console.log('[WS] Connected', isReconnect ? '(reconnect)' : '(initial)')
|
// connection established
|
||||||
reconnectDelay = 1000
|
reconnectDelay = 1000
|
||||||
// Join active trips on any connect (initial or reconnect)
|
// Join active trips on any connect (initial or reconnect)
|
||||||
if (activeTrips.size > 0) {
|
if (activeTrips.size > 0) {
|
||||||
activeTrips.forEach(tripId => {
|
activeTrips.forEach(tripId => {
|
||||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
socket.send(JSON.stringify({ type: 'join', tripId }))
|
socket.send(JSON.stringify({ type: 'join', tripId }))
|
||||||
console.log('[WS] Joined trip', tripId)
|
// joined trip room
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// Refetch trip data for active trips
|
// Refetch trip data for active trips
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { adminApi } from '../../api/client'
|
import { adminApi } from '../../api/client'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase } from 'lucide-react'
|
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase } from 'lucide-react'
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ function AddonIcon({ name, size = 20 }) {
|
|||||||
|
|
||||||
export default function AddonManager() {
|
export default function AddonManager() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const dark = useSettingsStore(s => s.settings.dark_mode)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [addons, setAddons] = useState([])
|
const [addons, setAddons] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -67,7 +69,9 @@ export default function AddonManager() {
|
|||||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
<div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
<div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2>
|
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2>
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('admin.addons.subtitle')}</p>
|
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="NOMAD" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{addons.length === 0 ? (
|
{addons.length === 0 ? (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { backupApi } from '../../api/client'
|
import { backupApi } from '../../api/client'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive } from 'lucide-react'
|
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
const INTERVAL_OPTIONS = [
|
const INTERVAL_OPTIONS = [
|
||||||
@@ -29,9 +29,10 @@ export default function BackupPanel() {
|
|||||||
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
|
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
|
||||||
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
||||||
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
||||||
|
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
|
|
||||||
const loadBackups = async () => {
|
const loadBackups = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
@@ -67,32 +68,42 @@ export default function BackupPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRestore = async (filename) => {
|
const handleRestore = (filename) => {
|
||||||
if (!confirm(t('backup.confirm.restore', { name: filename }))) return
|
setRestoreConfirm({ type: 'file', filename })
|
||||||
setRestoringFile(filename)
|
|
||||||
try {
|
|
||||||
await backupApi.restore(filename)
|
|
||||||
toast.success(t('backup.toast.restored'))
|
|
||||||
setTimeout(() => window.location.reload(), 1500)
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err.response?.data?.error || t('backup.toast.restoreError'))
|
|
||||||
setRestoringFile(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUploadRestore = async (e) => {
|
const handleUploadRestore = (e) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
if (!confirm(t('backup.confirm.uploadRestore', { name: file.name }))) return
|
setRestoreConfirm({ type: 'upload', filename: file.name, file })
|
||||||
setIsUploading(true)
|
}
|
||||||
try {
|
|
||||||
await backupApi.uploadRestore(file)
|
const executeRestore = async () => {
|
||||||
toast.success(t('backup.toast.restored'))
|
if (!restoreConfirm) return
|
||||||
setTimeout(() => window.location.reload(), 1500)
|
const { type, filename, file } = restoreConfirm
|
||||||
} catch (err) {
|
setRestoreConfirm(null)
|
||||||
toast.error(err.response?.data?.error || t('backup.toast.uploadError'))
|
|
||||||
setIsUploading(false)
|
if (type === 'file') {
|
||||||
|
setRestoringFile(filename)
|
||||||
|
try {
|
||||||
|
await backupApi.restore(filename)
|
||||||
|
toast.success(t('backup.toast.restored'))
|
||||||
|
setTimeout(() => window.location.reload(), 1500)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.error || t('backup.toast.restoreError'))
|
||||||
|
setRestoringFile(null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsUploading(true)
|
||||||
|
try {
|
||||||
|
await backupApi.uploadRestore(file)
|
||||||
|
toast.success(t('backup.toast.restored'))
|
||||||
|
setTimeout(() => window.location.reload(), 1500)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.error || t('backup.toast.uploadError'))
|
||||||
|
setIsUploading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,6 +368,71 @@ export default function BackupPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Restore Warning Modal */}
|
||||||
|
{restoreConfirm && (
|
||||||
|
<div
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||||
|
onClick={() => setRestoreConfirm(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
|
||||||
|
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
{/* Red header */}
|
||||||
|
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<AlertTriangle size={20} style={{ color: 'white' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>
|
||||||
|
{language === 'de' ? 'Backup wiederherstellen?' : 'Restore Backup?'}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
|
||||||
|
{restoreConfirm.filename}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div style={{ padding: '20px 24px' }}>
|
||||||
|
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||||
|
{language === 'de'
|
||||||
|
? 'Alle aktuellen Daten (Reisen, Orte, Benutzer, Uploads) werden unwiderruflich durch das Backup ersetzt. Dieser Vorgang kann nicht rückgängig gemacht werden.'
|
||||||
|
: 'All current data (trips, places, users, uploads) will be permanently replaced by the backup. This action cannot be undone.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||||
|
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
|
||||||
|
>
|
||||||
|
{language === 'de'
|
||||||
|
? 'Tipp: Erstelle zuerst ein Backup des aktuellen Stands, bevor du wiederherstellst.'
|
||||||
|
: 'Tip: Create a backup of the current state before restoring.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setRestoreConfirm(null)}
|
||||||
|
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{language === 'de' ? 'Abbrechen' : 'Cancel'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={executeRestore}
|
||||||
|
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: '#dc2626', color: 'white' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
|
||||||
|
>
|
||||||
|
{language === 'de' ? 'Ja, wiederherstellen' : 'Yes, restore'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,10 +68,10 @@ function SourceBadge({ icon: Icon, label }) {
|
|||||||
fontSize: 10.5, color: '#4b5563',
|
fontSize: 10.5, color: '#4b5563',
|
||||||
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
|
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
|
||||||
borderRadius: 6, padding: '2px 7px',
|
borderRadius: 6, padding: '2px 7px',
|
||||||
fontWeight: 500, whiteSpace: 'nowrap',
|
fontWeight: 500, maxWidth: '100%', overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
<Icon size={10} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
<Icon size={10} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||||
{label}
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,76 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { Info, Github, Shield, Key, Users, Database, Upload, Clock } from 'lucide-react'
|
import { Info, Github, Shield, Key, Users, Database, Upload, Clock, Puzzle, CalendarDays, Globe, ArrowRightLeft, Map, Briefcase, ListChecks, Wallet, FileText, Plane } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
const texts = {
|
const texts = {
|
||||||
de: {
|
de: {
|
||||||
|
titleBefore: 'Willkommen bei ',
|
||||||
|
titleAfter: '',
|
||||||
title: 'Willkommen zur NOMAD Demo',
|
title: 'Willkommen zur NOMAD Demo',
|
||||||
description: 'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
|
description: 'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
|
||||||
resetIn: 'Naechster Reset in',
|
resetIn: 'Naechster Reset in',
|
||||||
minutes: 'Minuten',
|
minutes: 'Minuten',
|
||||||
uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.',
|
uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.',
|
||||||
fullVersionTitle: 'In der Vollversion zusaetzlich verfuegbar:',
|
fullVersionTitle: 'In der Vollversion zusaetzlich:',
|
||||||
features: [
|
features: [
|
||||||
'Datei-Uploads (Fotos, Dokumente, Reise-Cover)',
|
'Datei-Uploads (Fotos, Dokumente, Cover)',
|
||||||
'API-Schluessel verwalten (Google Maps, Wetter)',
|
'API-Schluessel (Google Maps, Wetter)',
|
||||||
'Benutzer & Rechte verwalten',
|
'Benutzer- & Rechteverwaltung',
|
||||||
'Automatische Backups & Wiederherstellung',
|
'Automatische Backups',
|
||||||
|
'Addon-Verwaltung (aktivieren/deaktivieren)',
|
||||||
|
'OIDC / SSO Single Sign-On',
|
||||||
],
|
],
|
||||||
selfHost: 'NOMAD ist Open Source — ',
|
addonsTitle: 'Modulare Addons (in der Vollversion deaktivierbar)',
|
||||||
|
addons: [
|
||||||
|
['Vacay', 'Urlaubsplaner mit Kalender, Feiertagen & Fusion'],
|
||||||
|
['Atlas', 'Weltkarte mit besuchten Laendern & Reisestatistiken'],
|
||||||
|
['Packliste', 'Checklisten pro Reise'],
|
||||||
|
['Budget', 'Kostenplanung mit Splitting'],
|
||||||
|
['Dokumente', 'Dateien an Reisen anhaengen'],
|
||||||
|
['Widgets', 'Waehrungsrechner & Zeitzonen'],
|
||||||
|
],
|
||||||
|
whatIs: 'Was ist NOMAD?',
|
||||||
|
whatIsDesc: 'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
|
||||||
|
selfHost: 'Open Source — ',
|
||||||
selfHostLink: 'selbst hosten',
|
selfHostLink: 'selbst hosten',
|
||||||
close: 'Verstanden',
|
close: 'Verstanden',
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
|
titleBefore: 'Welcome to ',
|
||||||
|
titleAfter: '',
|
||||||
title: 'Welcome to the NOMAD Demo',
|
title: 'Welcome to the NOMAD Demo',
|
||||||
description: 'You can view, edit and create trips. All changes are automatically reset every hour.',
|
description: 'You can view, edit and create trips. All changes are automatically reset every hour.',
|
||||||
resetIn: 'Next reset in',
|
resetIn: 'Next reset in',
|
||||||
minutes: 'minutes',
|
minutes: 'minutes',
|
||||||
uploadNote: 'File uploads (photos, documents, covers) are disabled in demo mode.',
|
uploadNote: 'File uploads (photos, documents, covers) are disabled in demo mode.',
|
||||||
fullVersionTitle: 'Additionally available in the full version:',
|
fullVersionTitle: 'Additionally in the full version:',
|
||||||
features: [
|
features: [
|
||||||
'File uploads (photos, documents, trip covers)',
|
'File uploads (photos, documents, covers)',
|
||||||
'API key management (Google Maps, Weather)',
|
'API key management (Google Maps, Weather)',
|
||||||
'User & permission management',
|
'User & permission management',
|
||||||
'Automatic backups & restore',
|
'Automatic backups',
|
||||||
|
'Addon management (enable/disable)',
|
||||||
|
'OIDC / SSO single sign-on',
|
||||||
],
|
],
|
||||||
selfHost: 'NOMAD is open source — ',
|
addonsTitle: 'Modular Addons (can be deactivated in full version)',
|
||||||
|
addons: [
|
||||||
|
['Vacay', 'Vacation planner with calendar, holidays & user fusion'],
|
||||||
|
['Atlas', 'World map with visited countries & travel stats'],
|
||||||
|
['Packing', 'Checklists per trip'],
|
||||||
|
['Budget', 'Expense tracking with splitting'],
|
||||||
|
['Documents', 'Attach files to trips'],
|
||||||
|
['Widgets', 'Currency converter & timezones'],
|
||||||
|
],
|
||||||
|
whatIs: 'What is NOMAD?',
|
||||||
|
whatIsDesc: 'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.',
|
||||||
|
selfHost: 'Open source — ',
|
||||||
selfHostLink: 'self-host it',
|
selfHostLink: 'self-host it',
|
||||||
close: 'Got it',
|
close: 'Got it',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const featureIcons = [Upload, Key, Users, Database]
|
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
|
||||||
|
const addonIcons = [CalendarDays, Globe, ListChecks, Wallet, FileText, ArrowRightLeft]
|
||||||
|
|
||||||
export default function DemoBanner() {
|
export default function DemoBanner() {
|
||||||
const [dismissed, setDismissed] = useState(false)
|
const [dismissed, setDismissed] = useState(false)
|
||||||
@@ -57,85 +88,120 @@ export default function DemoBanner() {
|
|||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', inset: 0, zIndex: 9999,
|
position: 'fixed', inset: 0, zIndex: 9999,
|
||||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
padding: 24,
|
padding: 16, overflow: 'auto',
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
}} onClick={() => setDismissed(true)}>
|
}} onClick={() => setDismissed(true)}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'white', borderRadius: 20, padding: '32px 28px 24px',
|
background: 'white', borderRadius: 20, padding: '28px 24px 20px',
|
||||||
maxWidth: 440, width: '100%',
|
maxWidth: 480, width: '100%',
|
||||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||||
|
maxHeight: '90vh', overflow: 'auto',
|
||||||
}} onClick={e => e.stopPropagation()}>
|
}} onClick={e => e.stopPropagation()}>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
{/* Header */}
|
||||||
<div style={{
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||||
width: 36, height: 36, borderRadius: 10,
|
<img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} />
|
||||||
background: 'linear-gradient(135deg, #f59e0b, #d97706)',
|
<h2 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
{t.titleBefore}<img src="/text-dark.svg" alt="NOMAD" style={{ height: 18 }} />{t.titleAfter}
|
||||||
}}>
|
|
||||||
<Info size={20} style={{ color: 'white' }} />
|
|
||||||
</div>
|
|
||||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>
|
|
||||||
{t.title}
|
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style={{ fontSize: 14, color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}>
|
<p style={{ fontSize: 13, color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}>
|
||||||
{t.description}
|
{t.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{
|
{/* Timer + Upload note */}
|
||||||
display: 'flex', alignItems: 'center', gap: 8, margin: '0 0 12px',
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||||
background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '10px 12px',
|
<div style={{
|
||||||
}}>
|
flex: 1, display: 'flex', alignItems: 'center', gap: 6,
|
||||||
<Clock size={15} style={{ flexShrink: 0, color: '#0284c7' }} />
|
background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '8px 10px',
|
||||||
<span style={{ fontSize: 13, color: '#0369a1', fontWeight: 600 }}>
|
}}>
|
||||||
{t.resetIn} {minutesLeft} {t.minutes}
|
<Clock size={13} style={{ flexShrink: 0, color: '#0284c7' }} />
|
||||||
</span>
|
<span style={{ fontSize: 11, color: '#0369a1', fontWeight: 600 }}>
|
||||||
|
{t.resetIn} {minutesLeft} {t.minutes}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
flex: 1, display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '8px 10px',
|
||||||
|
}}>
|
||||||
|
<Upload size={13} style={{ flexShrink: 0, color: '#b45309' }} />
|
||||||
|
<span style={{ fontSize: 11, color: '#b45309' }}>{t.uploadNote}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style={{
|
{/* What is NOMAD */}
|
||||||
fontSize: 13, color: '#b45309', lineHeight: 1.5, margin: '0 0 20px',
|
<div style={{
|
||||||
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '10px 12px',
|
background: '#f8fafc', borderRadius: 12, padding: '12px 14px', marginBottom: 16,
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
border: '1px solid #e2e8f0',
|
||||||
}}>
|
}}>
|
||||||
<Upload size={15} style={{ flexShrink: 0 }} />
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||||
{t.uploadNote}
|
<Map size={14} style={{ color: '#111827' }} />
|
||||||
</p>
|
<span style={{ fontSize: 12, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
{language === 'de' ? 'Was ist ' : 'What is '}<img src="/text-dark.svg" alt="NOMAD" style={{ height: 13, marginRight: -2 }} />?
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p style={{ fontSize: 12, fontWeight: 700, color: '#374151', margin: '0 0 10px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
{/* Addons */}
|
||||||
|
<p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Puzzle size={12} />
|
||||||
|
{t.addonsTitle}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
|
||||||
|
{t.addons.map(([name, desc], i) => {
|
||||||
|
const Icon = addonIcons[i]
|
||||||
|
return (
|
||||||
|
<div key={name} style={{
|
||||||
|
background: '#f8fafc', borderRadius: 10, padding: '8px 10px',
|
||||||
|
border: '1px solid #f1f5f9',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
||||||
|
<Icon size={12} style={{ flexShrink: 0, color: '#111827' }} />
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: '#111827' }}>{name}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 10, color: '#94a3b8', margin: 0, lineHeight: 1.3, paddingLeft: 18 }}>{desc}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full version features */}
|
||||||
|
<p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Shield size={12} />
|
||||||
{t.fullVersionTitle}
|
{t.fullVersionTitle}
|
||||||
</p>
|
</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 20 }}>
|
|
||||||
{t.features.map((text, i) => {
|
{t.features.map((text, i) => {
|
||||||
const Icon = featureIcons[i]
|
const Icon = featureIcons[i]
|
||||||
return (
|
return (
|
||||||
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, color: '#4b5563' }}>
|
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: '#4b5563', padding: '4px 0' }}>
|
||||||
<Icon size={15} style={{ flexShrink: 0, color: '#d97706' }} />
|
<Icon size={13} style={{ flexShrink: 0, color: '#9ca3af' }} />
|
||||||
<span>{text}</span>
|
<span>{text}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
<div style={{
|
<div style={{
|
||||||
paddingTop: 16, borderTop: '1px solid #e5e7eb',
|
paddingTop: 14, borderTop: '1px solid #e5e7eb',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#9ca3af' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
|
||||||
<Github size={14} />
|
<Github size={13} />
|
||||||
<span>{t.selfHost}</span>
|
<span>{t.selfHost}</span>
|
||||||
<a href="https://github.com/mauriceboe/NOMAD" target="_blank" rel="noopener noreferrer"
|
<a href="https://github.com/mauriceboe/NOMAD" target="_blank" rel="noopener noreferrer"
|
||||||
style={{ color: '#d97706', fontWeight: 600, textDecoration: 'none' }}>
|
style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}>
|
||||||
{t.selfHostLink}
|
{t.selfHostLink}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onClick={() => setDismissed(true)} style={{
|
<button onClick={() => setDismissed(true)} style={{
|
||||||
background: '#111827', color: 'white', border: 'none',
|
background: '#111827', color: 'white', border: 'none',
|
||||||
borderRadius: 10, padding: '8px 20px', fontSize: 13,
|
borderRadius: 10, padding: '8px 20px', fontSize: 12,
|
||||||
fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
}}>
|
}}>
|
||||||
{t.close}
|
{t.close}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
@@ -56,7 +57,9 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
|||||||
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
|
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
|
||||||
boxShadow: dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)',
|
boxShadow: dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)',
|
||||||
touchAction: 'manipulation',
|
touchAction: 'manipulation',
|
||||||
}} className="h-14 flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
|
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||||
|
height: 'var(--nav-h)',
|
||||||
|
}} className="flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
|
||||||
{/* Left side */}
|
{/* Left side */}
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
{showBack && (
|
{showBack && (
|
||||||
@@ -70,10 +73,9 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link to="/dashboard" className="flex items-center gap-2 transition-colors flex-shrink-0"
|
<Link to="/dashboard" className="flex items-center transition-colors flex-shrink-0">
|
||||||
style={{ color: 'var(--text-primary)' }}>
|
<img src={dark ? '/icons/icon-white.svg' : '/icons/icon-dark.svg'} alt="NOMAD" className="sm:hidden" style={{ height: 22, width: 22 }} />
|
||||||
<Plane className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
|
<img src={dark ? '/logo-light.svg' : '/logo-dark.svg'} alt="NOMAD" className="hidden sm:block" style={{ height: 28 }} />
|
||||||
<span className="font-bold text-sm hidden sm:inline">NOMAD</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Global addon nav items */}
|
{/* Global addon nav items */}
|
||||||
@@ -167,11 +169,10 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
|||||||
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-faint)' }} />
|
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-faint)' }} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{userMenuOpen && (
|
{userMenuOpen && ReactDOM.createPortal(
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-10" onClick={() => setUserMenuOpen(false)} />
|
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setUserMenuOpen(false)} />
|
||||||
<div className="absolute right-0 top-full mt-2 w-52 rounded-xl shadow-xl border z-20 overflow-hidden"
|
<div className="w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
|
||||||
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
|
||||||
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
|
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
|
||||||
@@ -211,13 +212,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
|||||||
{t('nav.logout')}
|
{t('nav.logout')}
|
||||||
</button>
|
</button>
|
||||||
{appVersion && (
|
{appVersion && (
|
||||||
<div className="px-4 py-1.5 text-center" style={{ fontSize: 10, color: 'var(--text-faint)' }}>
|
<div className="px-4 pt-2 pb-2.5 text-center" style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
||||||
NOMAD v{appVersion}
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
|
||||||
|
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="NOMAD" style={{ height: 10, opacity: 0.5 }} />
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ L.Icon.Default.mergeOptions({
|
|||||||
* Create a round photo-circle marker.
|
* Create a round photo-circle marker.
|
||||||
* Shows image_url if available, otherwise category icon in colored circle.
|
* Shows image_url if available, otherwise category icon in colored circle.
|
||||||
*/
|
*/
|
||||||
|
function escAttr(s) {
|
||||||
|
if (!s) return ''
|
||||||
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
function createPlaceIcon(place, orderNumber, isSelected) {
|
function createPlaceIcon(place, orderNumber, isSelected) {
|
||||||
const size = isSelected ? 44 : 36
|
const size = isSelected ? 44 : 36
|
||||||
const borderColor = isSelected ? '#111827' : 'white'
|
const borderColor = isSelected ? '#111827' : 'white'
|
||||||
@@ -55,7 +60,7 @@ function createPlaceIcon(place, orderNumber, isSelected) {
|
|||||||
cursor:pointer;flex-shrink:0;position:relative;
|
cursor:pointer;flex-shrink:0;position:relative;
|
||||||
">
|
">
|
||||||
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
|
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
|
||||||
<img src="${place.image_url}" style="width:100%;height:100%;object-fit:cover;" />
|
<img src="${escAttr(place.image_url)}" style="width:100%;height:100%;object-fit:cover;" />
|
||||||
</div>
|
</div>
|
||||||
${badgeHtml}
|
${badgeHtml}
|
||||||
</div>`,
|
</div>`,
|
||||||
@@ -92,7 +97,7 @@ function SelectionController({ places, selectedPlaceId }) {
|
|||||||
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
||||||
const place = places.find(p => p.id === selectedPlaceId)
|
const place = places.find(p => p.id === selectedPlaceId)
|
||||||
if (place?.lat && place?.lng) {
|
if (place?.lat && place?.lng) {
|
||||||
map.setView([place.lat, place.lng], Math.max(map.getZoom(), 15), { animate: true, duration: 0.5 })
|
map.panTo([place.lat, place.lng], { animate: true, duration: 0.5 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prev.current = selectedPlaceId
|
prev.current = selectedPlaceId
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
// Trip PDF via browser print window
|
// Trip PDF via browser print window
|
||||||
import { createElement } from 'react'
|
import { createElement } from 'react'
|
||||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||||
|
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark } from 'lucide-react'
|
||||||
import { mapsApi } from '../../api/client'
|
import { mapsApi } from '../../api/client'
|
||||||
|
|
||||||
|
const NOTE_ICON_MAP = { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark }
|
||||||
|
function noteIconSvg(iconId) {
|
||||||
|
if (!_renderToStaticMarkup) return ''
|
||||||
|
const Icon = NOTE_ICON_MAP[iconId] || FileText
|
||||||
|
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' }))
|
||||||
|
}
|
||||||
|
|
||||||
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
|
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
|
||||||
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
|
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
|
||||||
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
|
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
|
||||||
@@ -104,7 +112,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const cost = dayCost(assignments, day.id, loc)
|
const cost = dayCost(assignments, day.id, loc)
|
||||||
|
|
||||||
const merged = []
|
const merged = []
|
||||||
assigned.forEach(a => merged.push({ type: 'place', k: a.sort_order ?? 0, data: a }))
|
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
||||||
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||||
merged.sort((a, b) => a.k - b.k)
|
merged.sort((a, b) => a.k - b.k)
|
||||||
|
|
||||||
@@ -117,12 +125,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
return `
|
return `
|
||||||
<div class="note-card">
|
<div class="note-card">
|
||||||
<div class="note-line"></div>
|
<div class="note-line"></div>
|
||||||
<svg class="note-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.8" stroke-linecap="round">
|
<span class="note-icon">${noteIconSvg(note.icon)}</span>
|
||||||
<rect x="4" y="3" width="16" height="18" rx="2"/>
|
|
||||||
<line x1="8" y1="8" x2="16" y2="8"/>
|
|
||||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
|
||||||
<line x1="8" y1="16" x2="13" y2="16"/>
|
|
||||||
</svg>
|
|
||||||
<div class="note-body">
|
<div class="note-body">
|
||||||
<div class="note-text">${escHtml(note.text)}</div>
|
<div class="note-text">${escHtml(note.text)}</div>
|
||||||
${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''}
|
${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''}
|
||||||
@@ -200,6 +203,24 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
body { font-family: 'Poppins', sans-serif; background: #fff; color: #1e293b; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
body { font-family: 'Poppins', sans-serif; background: #fff; color: #1e293b; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
svg { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
svg { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
|
||||||
|
/* Footer on every printed page */
|
||||||
|
.pdf-footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
.pdf-footer span {
|
||||||
|
font-size: 7px;
|
||||||
|
color: #64748b;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Cover ─────────────────────────────────────── */
|
/* ── Cover ─────────────────────────────────────── */
|
||||||
.cover {
|
.cover {
|
||||||
width: 100%; min-height: 100vh;
|
width: 100%; min-height: 100vh;
|
||||||
@@ -215,8 +236,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
.cover-dim { position: absolute; inset: 0; background: rgba(8,12,28,0.55); }
|
.cover-dim { position: absolute; inset: 0; background: rgba(8,12,28,0.55); }
|
||||||
.cover-brand {
|
.cover-brand {
|
||||||
position: absolute; top: 36px; right: 52px;
|
position: absolute; top: 36px; right: 52px;
|
||||||
font-size: 9px; font-weight: 600; letter-spacing: 2.5px;
|
z-index: 2;
|
||||||
color: rgba(255,255,255,0.3); text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
.cover-body { position: relative; z-index: 1; }
|
.cover-body { position: relative; z-index: 1; }
|
||||||
.cover-circle {
|
.cover-circle {
|
||||||
@@ -316,11 +336,17 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<!-- Footer on every page -->
|
||||||
|
<div class="pdf-footer">
|
||||||
|
<span>made with</span>
|
||||||
|
<img src="${absUrl('/logo-dark.svg')}" style="height:10px;opacity:0.6;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Cover -->
|
<!-- Cover -->
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(coverImg)}')"></div>` : ''}
|
${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(coverImg)}')"></div>` : ''}
|
||||||
<div class="cover-dim"></div>
|
<div class="cover-dim"></div>
|
||||||
<div class="cover-brand">NOMAD</div>
|
<div class="cover-brand"><img src="${absUrl('/logo-light.svg')}" style="height:28px;opacity:0.5;" /></div>
|
||||||
<div class="cover-body">
|
<div class="cover-body">
|
||||||
${coverImg
|
${coverImg
|
||||||
? `<div class="cover-circle"><img src="${escHtml(coverImg)}" /></div>`
|
? `<div class="cover-circle"><img src="${escHtml(coverImg)}" /></div>`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, AlertCircle, CheckCircle2, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown } from 'lucide-react'
|
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, AlertCircle, CheckCircle2, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock } from 'lucide-react'
|
||||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
@@ -79,7 +79,7 @@ export default function DayPlanSidebar({
|
|||||||
onAddReservation,
|
onAddReservation,
|
||||||
}) {
|
}) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||||
const tripStore = useTripStore()
|
const tripStore = useTripStore()
|
||||||
|
|
||||||
@@ -97,6 +97,8 @@ export default function DayPlanSidebar({
|
|||||||
const [isCalculating, setIsCalculating] = useState(false)
|
const [isCalculating, setIsCalculating] = useState(false)
|
||||||
const [routeInfo, setRouteInfo] = useState(null)
|
const [routeInfo, setRouteInfo] = useState(null)
|
||||||
const [draggingId, setDraggingId] = useState(null)
|
const [draggingId, setDraggingId] = useState(null)
|
||||||
|
const [lockedIds, setLockedIds] = useState(new Set())
|
||||||
|
const [lockHoverId, setLockHoverId] = useState(null)
|
||||||
const [dropTargetKey, setDropTargetKey] = useState(null)
|
const [dropTargetKey, setDropTargetKey] = useState(null)
|
||||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
const [dragOverDayId, setDragOverDayId] = useState(null)
|
||||||
const [hoveredId, setHoveredId] = useState(null)
|
const [hoveredId, setHoveredId] = useState(null)
|
||||||
@@ -205,16 +207,17 @@ export default function DayPlanSidebar({
|
|||||||
catch (err) { toast.error(err.message) }
|
catch (err) { toast.error(err.message) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId) => {
|
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
|
||||||
const m = getMergedItems(dayId)
|
const m = getMergedItems(dayId)
|
||||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
||||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
||||||
|
|
||||||
// Neue Reihenfolge erstellen — VOR dem Ziel einfügen (Standardkonvention)
|
// Neue Reihenfolge erstellen — VOR dem Ziel einfügen (Standard), oder NACH dem Ziel wenn insertAfter
|
||||||
const newOrder = [...m]
|
const newOrder = [...m]
|
||||||
const [moved] = newOrder.splice(fromIdx, 1)
|
const [moved] = newOrder.splice(fromIdx, 1)
|
||||||
const adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx
|
let adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx
|
||||||
|
if (insertAfter) adjustedTo += 1
|
||||||
newOrder.splice(adjustedTo, 0, moved)
|
newOrder.splice(adjustedTo, 0, moved)
|
||||||
|
|
||||||
// Orte: neuer order_index über onReorder
|
// Orte: neuer order_index über onReorder
|
||||||
@@ -290,15 +293,44 @@ export default function DayPlanSidebar({
|
|||||||
finally { setIsCalculating(false) }
|
finally { setIsCalculating(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleLock = (assignmentId) => {
|
||||||
|
setLockedIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(assignmentId)) next.delete(assignmentId)
|
||||||
|
else next.add(assignmentId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleOptimize = async () => {
|
const handleOptimize = async () => {
|
||||||
if (!selectedDayId) return
|
if (!selectedDayId) return
|
||||||
const da = getDayAssignments(selectedDayId)
|
const da = getDayAssignments(selectedDayId)
|
||||||
if (da.length < 3) return
|
if (da.length < 3) return
|
||||||
const withCoords = da.map(a => a.place).filter(p => p?.lat && p?.lng)
|
|
||||||
const optimized = optimizeRoute(withCoords)
|
// Separate locked (stay at their index) and unlocked assignments
|
||||||
const reorderedIds = optimized.map(p => da.find(a => a.place?.id === p.id)?.id).filter(Boolean)
|
const locked = new Map() // index -> assignment
|
||||||
for (const a of da) { if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id) }
|
const unlocked = []
|
||||||
await onReorder(selectedDayId, reorderedIds)
|
da.forEach((a, i) => {
|
||||||
|
if (lockedIds.has(a.id)) locked.set(i, a)
|
||||||
|
else unlocked.push(a)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Optimize only unlocked places
|
||||||
|
const unlockedWithCoords = unlocked.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||||
|
const optimized = unlockedWithCoords.length >= 2 ? optimizeRoute(unlockedWithCoords) : unlockedWithCoords
|
||||||
|
const optimizedQueue = optimized.map(p => unlocked.find(a => a.place?.id === p.id)).filter(Boolean)
|
||||||
|
// Add unlocked without coords at the end
|
||||||
|
for (const a of unlocked) { if (!optimizedQueue.includes(a)) optimizedQueue.push(a) }
|
||||||
|
|
||||||
|
// Merge: locked stay at their index, fill gaps with optimized
|
||||||
|
const result = new Array(da.length)
|
||||||
|
locked.forEach((a, i) => { result[i] = a })
|
||||||
|
let qi = 0
|
||||||
|
for (let i = 0; i < result.length; i++) {
|
||||||
|
if (!result[i]) result[i] = optimizedQueue[qi++]
|
||||||
|
}
|
||||||
|
|
||||||
|
await onReorder(selectedDayId, result.map(a => a.id))
|
||||||
toast.success(t('dayplan.toast.routeOptimized'))
|
toast.success(t('dayplan.toast.routeOptimized'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,7 +462,7 @@ export default function DayPlanSidebar({
|
|||||||
outlineOffset: -2,
|
outlineOffset: -2,
|
||||||
borderRadius: isDragTarget ? 8 : 0,
|
borderRadius: isDragTarget ? 8 : 0,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
|
||||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
|
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
|
||||||
>
|
>
|
||||||
{/* Tages-Badge */}
|
{/* Tages-Badge */}
|
||||||
@@ -504,7 +536,7 @@ export default function DayPlanSidebar({
|
|||||||
{/* Aufgeklappte Orte + Notizen */}
|
{/* Aufgeklappte Orte + Notizen */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div
|
<div
|
||||||
style={{ background: 'var(--bg-hover)' }}
|
style={{ background: 'var(--bg-hover)', paddingTop: 6 }}
|
||||||
onDragOver={e => { e.preventDefault(); if (draggingId) setDropTargetKey(`end-${day.id}`) }}
|
onDragOver={e => { e.preventDefault(); if (draggingId) setDropTargetKey(`end-${day.id}`) }}
|
||||||
onDrop={e => {
|
onDrop={e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -522,9 +554,9 @@ export default function DayPlanSidebar({
|
|||||||
if (m.length === 0) return
|
if (m.length === 0) return
|
||||||
const lastItem = m[m.length - 1]
|
const lastItem = m[m.length - 1]
|
||||||
if (assignmentId && String(lastItem?.data?.id) !== assignmentId)
|
if (assignmentId && String(lastItem?.data?.id) !== assignmentId)
|
||||||
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id)
|
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true)
|
||||||
else if (noteId && String(lastItem?.data?.id) !== noteId)
|
else if (noteId && String(lastItem?.data?.id) !== noteId)
|
||||||
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id)
|
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{merged.length === 0 && !dayNoteUi ? (
|
{merged.length === 0 && !dayNoteUi ? (
|
||||||
@@ -582,7 +614,7 @@ export default function DayPlanSidebar({
|
|||||||
dragDataRef.current = { assignmentId: String(assignment.id), fromDayId: String(day.id) }
|
dragDataRef.current = { assignmentId: String(assignment.id), fromDayId: String(day.id) }
|
||||||
setDraggingId(assignment.id)
|
setDraggingId(assignment.id)
|
||||||
}}
|
}}
|
||||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); setDropTargetKey(`place-${assignment.id}`) }}
|
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); if (dropTargetKey !== `place-${assignment.id}`) setDropTargetKey(`place-${assignment.id}`) }}
|
||||||
onDrop={e => {
|
onDrop={e => {
|
||||||
e.preventDefault(); e.stopPropagation()
|
e.preventDefault(); e.stopPropagation()
|
||||||
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
|
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
|
||||||
@@ -607,25 +639,61 @@ export default function DayPlanSidebar({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||||
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id); if (!isPlaceSelected) onSelectDay(day.id) }}
|
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
|
||||||
onMouseEnter={() => setHoveredId(assignment.id)}
|
onMouseEnter={() => setHoveredId(assignment.id)}
|
||||||
onMouseLeave={() => setHoveredId(null)}
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
padding: '7px 8px 7px 10px',
|
padding: '7px 8px 7px 10px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
background: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
|
background: lockedIds.has(assignment.id)
|
||||||
borderLeft: hasReservation
|
? 'rgba(220,38,38,0.08)'
|
||||||
? `3px solid ${isConfirmed ? '#10b981' : '#f59e0b'}`
|
: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
|
||||||
: '3px solid transparent',
|
borderLeft: lockedIds.has(assignment.id)
|
||||||
transition: 'background 0.1s',
|
? '3px solid #dc2626'
|
||||||
|
: hasReservation
|
||||||
|
? `3px solid ${isConfirmed ? '#10b981' : '#f59e0b'}`
|
||||||
|
: '3px solid transparent',
|
||||||
|
transition: 'background 0.15s, border-color 0.15s',
|
||||||
opacity: isDraggingThis ? 0.4 : 1,
|
opacity: isDraggingThis ? 0.4 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||||
<GripVertical size={13} strokeWidth={1.8} />
|
<GripVertical size={13} strokeWidth={1.8} />
|
||||||
</div>
|
</div>
|
||||||
<PlaceAvatar place={place} category={cat} size={28} />
|
<div
|
||||||
|
onClick={e => { e.stopPropagation(); toggleLock(assignment.id) }}
|
||||||
|
onMouseEnter={e => { e.stopPropagation(); setLockHoverId(assignment.id) }}
|
||||||
|
onMouseLeave={() => setLockHoverId(null)}
|
||||||
|
style={{ position: 'relative', flexShrink: 0, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<PlaceAvatar place={place} category={cat} size={28} />
|
||||||
|
{/* Hover/locked overlay */}
|
||||||
|
{(lockHoverId === assignment.id || lockedIds.has(assignment.id)) && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, borderRadius: '50%',
|
||||||
|
background: lockedIds.has(assignment.id) ? 'rgba(220,38,38,0.6)' : 'rgba(220,38,38,0.4)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}>
|
||||||
|
<Lock size={14} strokeWidth={2.5} style={{ color: 'white', filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Custom tooltip */}
|
||||||
|
{lockHoverId === assignment.id && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: '100%', top: '50%', transform: 'translateY(-50%)',
|
||||||
|
marginLeft: 8, whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 50,
|
||||||
|
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||||
|
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||||
|
}}>
|
||||||
|
{lockedIds.has(assignment.id)
|
||||||
|
? (language === 'de' ? 'Klicken zum Entsperren' : 'Click to unlock')
|
||||||
|
: (language === 'de' ? 'Position bei Routenoptimierung beibehalten' : 'Keep position during route optimization')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
|
||||||
{cat && (() => {
|
{cat && (() => {
|
||||||
@@ -686,7 +754,7 @@ export default function DayPlanSidebar({
|
|||||||
draggable
|
draggable
|
||||||
onDragStart={e => { e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
|
onDragStart={e => { 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}`) }}
|
||||||
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDropTargetKey(`note-${note.id}`) }}
|
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
||||||
onDrop={e => {
|
onDrop={e => {
|
||||||
e.preventDefault(); e.stopPropagation()
|
e.preventDefault(); e.stopPropagation()
|
||||||
const { noteId: fromNoteId, assignmentId: fromAssignmentId, fromDayId } = getDragData(e)
|
const { noteId: fromNoteId, assignmentId: fromAssignmentId, fromDayId } = getDragData(e)
|
||||||
@@ -749,10 +817,40 @@ export default function DayPlanSidebar({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
{/* Drop-Indikator am Listenende */}
|
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
|
||||||
{!!draggingId && dropTargetKey === `end-${day.id}` && (
|
<div
|
||||||
<div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />
|
style={{ minHeight: 12, padding: '2px 8px' }}
|
||||||
)}
|
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `end-${day.id}`) setDropTargetKey(`end-${day.id}`) }}
|
||||||
|
onDrop={e => {
|
||||||
|
e.preventDefault(); e.stopPropagation()
|
||||||
|
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||||
|
// Neuer Ort von der Orte-Liste
|
||||||
|
if (placeId) {
|
||||||
|
onAssignToDay?.(parseInt(placeId), day.id)
|
||||||
|
setDropTargetKey(null); window.__dragData = null; return
|
||||||
|
}
|
||||||
|
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||||
|
if (assignmentId && fromDayId !== day.id) {
|
||||||
|
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch(err => toast.error(err.message))
|
||||||
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||||
|
}
|
||||||
|
if (noteId && fromDayId !== day.id) {
|
||||||
|
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch(err => toast.error(err.message))
|
||||||
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||||
|
}
|
||||||
|
const m = getMergedItems(day.id)
|
||||||
|
if (m.length === 0) return
|
||||||
|
const lastItem = m[m.length - 1]
|
||||||
|
if (assignmentId && String(lastItem?.data?.id) !== assignmentId)
|
||||||
|
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true)
|
||||||
|
else if (noteId && String(lastItem?.data?.id) !== noteId)
|
||||||
|
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dropTargetKey === `end-${day.id}` && (
|
||||||
|
<div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1 }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
||||||
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
||||||
@@ -778,15 +876,6 @@ export default function DayPlanSidebar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
<button onClick={handleCalculateRoute} disabled={isCalculating} style={{
|
|
||||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
|
||||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
|
||||||
background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
opacity: isCalculating ? 0.6 : 1,
|
|
||||||
}}>
|
|
||||||
<Navigation size={12} strokeWidth={2} />
|
|
||||||
{isCalculating ? t('dayplan.calculating') : t('dayplan.route')}
|
|
||||||
</button>
|
|
||||||
<button onClick={handleOptimize} style={{
|
<button onClick={handleOptimize} style={{
|
||||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
||||||
|
|||||||
@@ -138,14 +138,14 @@ const de = {
|
|||||||
'settings.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein',
|
'settings.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein',
|
||||||
'settings.passwordMismatch': 'Passwörter stimmen nicht überein',
|
'settings.passwordMismatch': 'Passwörter stimmen nicht überein',
|
||||||
'settings.passwordChanged': 'Passwort erfolgreich geändert',
|
'settings.passwordChanged': 'Passwort erfolgreich geändert',
|
||||||
'settings.deleteAccount': 'Account löschen',
|
'settings.deleteAccount': 'Löschen',
|
||||||
'settings.deleteAccountTitle': 'Account wirklich löschen?',
|
'settings.deleteAccountTitle': 'Account wirklich löschen?',
|
||||||
'settings.deleteAccountWarning': 'Dein Account und alle deine Reisen, Orte und Dateien werden unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.',
|
'settings.deleteAccountWarning': 'Dein Account und alle deine Reisen, Orte und Dateien werden unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||||
'settings.deleteAccountConfirm': 'Endgültig löschen',
|
'settings.deleteAccountConfirm': 'Endgültig löschen',
|
||||||
'settings.deleteBlockedTitle': 'Löschung nicht möglich',
|
'settings.deleteBlockedTitle': 'Löschung nicht möglich',
|
||||||
'settings.deleteBlockedMessage': 'Du bist der einzige Administrator. Ernenne zuerst einen anderen Benutzer zum Admin, bevor du deinen Account löschen kannst.',
|
'settings.deleteBlockedMessage': 'Du bist der einzige Administrator. Ernenne zuerst einen anderen Benutzer zum Admin, bevor du deinen Account löschen kannst.',
|
||||||
'settings.roleUser': 'Benutzer',
|
'settings.roleUser': 'Benutzer',
|
||||||
'settings.saveProfile': 'Profil speichern',
|
'settings.saveProfile': 'Speichern',
|
||||||
'settings.toast.mapSaved': 'Karteneinstellungen gespeichert',
|
'settings.toast.mapSaved': 'Karteneinstellungen gespeichert',
|
||||||
'settings.toast.keysSaved': 'API-Schlüssel gespeichert',
|
'settings.toast.keysSaved': 'API-Schlüssel gespeichert',
|
||||||
'settings.toast.displaySaved': 'Anzeigeeinstellungen gespeichert',
|
'settings.toast.displaySaved': 'Anzeigeeinstellungen gespeichert',
|
||||||
@@ -252,6 +252,8 @@ const de = {
|
|||||||
'admin.tabs.addons': 'Addons',
|
'admin.tabs.addons': 'Addons',
|
||||||
'admin.addons.title': 'Addons',
|
'admin.addons.title': 'Addons',
|
||||||
'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um NOMAD nach deinen Wünschen anzupassen.',
|
'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um NOMAD nach deinen Wünschen anzupassen.',
|
||||||
|
'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ',
|
||||||
|
'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.',
|
||||||
'admin.addons.enabled': 'Aktiviert',
|
'admin.addons.enabled': 'Aktiviert',
|
||||||
'admin.addons.disabled': 'Deaktiviert',
|
'admin.addons.disabled': 'Deaktiviert',
|
||||||
'admin.addons.type.trip': 'Trip',
|
'admin.addons.type.trip': 'Trip',
|
||||||
@@ -414,6 +416,9 @@ const de = {
|
|||||||
'dayplan.optimize': 'Optimieren',
|
'dayplan.optimize': 'Optimieren',
|
||||||
'dayplan.optimized': 'Route optimiert',
|
'dayplan.optimized': 'Route optimiert',
|
||||||
'dayplan.routeError': 'Fehler bei der Routenberechnung',
|
'dayplan.routeError': 'Fehler bei der Routenberechnung',
|
||||||
|
'dayplan.toast.needTwoPlaces': 'Mindestens zwei Orte für Routenoptimierung nötig',
|
||||||
|
'dayplan.toast.routeOptimized': 'Route optimiert',
|
||||||
|
'dayplan.toast.noGeoPlaces': 'Keine Orte mit Koordinaten für Routenberechnung gefunden',
|
||||||
'dayplan.confirmed': 'Bestätigt',
|
'dayplan.confirmed': 'Bestätigt',
|
||||||
'dayplan.pendingRes': 'Ausstehend',
|
'dayplan.pendingRes': 'Ausstehend',
|
||||||
'dayplan.pdf': 'PDF',
|
'dayplan.pdf': 'PDF',
|
||||||
|
|||||||
@@ -252,6 +252,8 @@ const en = {
|
|||||||
'admin.tabs.addons': 'Addons',
|
'admin.tabs.addons': 'Addons',
|
||||||
'admin.addons.title': 'Addons',
|
'admin.addons.title': 'Addons',
|
||||||
'admin.addons.subtitle': 'Enable or disable features to customize your NOMAD experience.',
|
'admin.addons.subtitle': 'Enable or disable features to customize your NOMAD experience.',
|
||||||
|
'admin.addons.subtitleBefore': 'Enable or disable features to customize your ',
|
||||||
|
'admin.addons.subtitleAfter': ' experience.',
|
||||||
'admin.addons.enabled': 'Enabled',
|
'admin.addons.enabled': 'Enabled',
|
||||||
'admin.addons.disabled': 'Disabled',
|
'admin.addons.disabled': 'Disabled',
|
||||||
'admin.addons.type.trip': 'Trip',
|
'admin.addons.type.trip': 'Trip',
|
||||||
@@ -414,6 +416,9 @@ const en = {
|
|||||||
'dayplan.optimize': 'Optimize',
|
'dayplan.optimize': 'Optimize',
|
||||||
'dayplan.optimized': 'Route optimized',
|
'dayplan.optimized': 'Route optimized',
|
||||||
'dayplan.routeError': 'Failed to calculate route',
|
'dayplan.routeError': 'Failed to calculate route',
|
||||||
|
'dayplan.toast.needTwoPlaces': 'At least two places needed for route optimization',
|
||||||
|
'dayplan.toast.routeOptimized': 'Route optimized',
|
||||||
|
'dayplan.toast.noGeoPlaces': 'No places with coordinates found for route calculation',
|
||||||
'dayplan.confirmed': 'Confirmed',
|
'dayplan.confirmed': 'Confirmed',
|
||||||
'dayplan.pendingRes': 'Pending',
|
'dayplan.pendingRes': 'Pending',
|
||||||
'dayplan.pdf': 'PDF',
|
'dayplan.pdf': 'PDF',
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
html { height: 100%; overflow: hidden; }
|
html { height: 100%; overflow: hidden; background-color: var(--bg-primary); }
|
||||||
body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow-scrolling: touch; }
|
body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow-scrolling: touch; }
|
||||||
|
|
||||||
|
|
||||||
.atlas-tooltip {
|
.atlas-tooltip {
|
||||||
background: rgba(10, 10, 20, 0.6) !important;
|
background: rgba(10, 10, 20, 0.6) !important;
|
||||||
backdrop-filter: blur(20px) saturate(180%) !important;
|
backdrop-filter: blur(20px) saturate(180%) !important;
|
||||||
@@ -138,6 +139,8 @@ html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color
|
|||||||
|
|
||||||
/* ── Design tokens ─────────────────────────────── */
|
/* ── Design tokens ─────────────────────────────── */
|
||||||
:root {
|
:root {
|
||||||
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
--nav-h: calc(56px + var(--safe-top));
|
||||||
--font-system: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
|
--font-system: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
|
||||||
--sp-1: 4px;
|
--sp-1: 4px;
|
||||||
--sp-2: 8px;
|
--sp-2: 8px;
|
||||||
@@ -323,6 +326,15 @@ body {
|
|||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Brand images: no save/copy/drag */
|
||||||
|
img[alt="NOMAD"] {
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Weiche Übergänge */
|
/* Weiche Übergänge */
|
||||||
.transition-smooth {
|
.transition-smooth {
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ export default function AdminPage() {
|
|||||||
<div className="min-h-screen" style={{ background: 'var(--bg-secondary)' }}>
|
<div className="min-h-screen" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div className="pt-14">
|
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ export default function AtlasPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
|
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="pt-14 flex items-center justify-center" style={{ minHeight: 'calc(100vh - 56px)' }}>
|
<div className="flex items-center justify-center" style={{ paddingTop: 'var(--nav-h)', minHeight: 'calc(100vh - var(--nav-h))' }}>
|
||||||
<div className="w-8 h-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
|
<div className="w-8 h-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,7 +280,7 @@ export default function AtlasPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
|
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div style={{ position: 'fixed', top: 56, left: 0, right: 0, bottom: 0 }}>
|
<div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0 }}>
|
||||||
{/* Map */}
|
{/* Map */}
|
||||||
<div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} />
|
<div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} />
|
||||||
|
|
||||||
@@ -421,23 +421,11 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Next trip */}
|
|
||||||
{nextTrip && (
|
|
||||||
<button onClick={() => onTripClick(nextTrip.id)} className="flex items-center gap-2.5 text-left transition-opacity hover:opacity-75">
|
|
||||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'rgba(129,140,248,0.12)' }}>
|
|
||||||
<Calendar size={16} style={{ color: accent }} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: tf }}>{t('atlas.nextTrip')}</p>
|
|
||||||
<p className="text-[13px] font-black" style={{ color: accent }}>{nextTrip.daysUntil} {t('atlas.daysLeft')}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Streak */}
|
{/* Streak */}
|
||||||
{streak > 0 && (
|
{streak > 0 && (
|
||||||
<div className="flex flex-col items-center justify-center px-3">
|
<div className="flex flex-col items-center justify-center px-3">
|
||||||
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{streak}</span>
|
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{streak}</span>
|
||||||
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight" style={{ color: tf }}>
|
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight whitespace-nowrap" style={{ color: tf }}>
|
||||||
{streak === 1 ? t('atlas.yearInRow') : t('atlas.yearsInRow')}
|
{streak === 1 ? t('atlas.yearInRow') : t('atlas.yearsInRow')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -446,7 +434,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
|||||||
{tripsThisYear > 0 && (
|
{tripsThisYear > 0 && (
|
||||||
<div className="flex flex-col items-center justify-center px-3">
|
<div className="flex flex-col items-center justify-center px-3">
|
||||||
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{tripsThisYear}</span>
|
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{tripsThisYear}</span>
|
||||||
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight" style={{ color: tf }}>
|
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight whitespace-nowrap" style={{ color: tf }}>
|
||||||
{tripsThisYear === 1 ? t('atlas.tripIn') : t('atlas.tripsIn')} {thisYear}
|
{tripsThisYear === 1 ? t('atlas.tripIn') : t('atlas.tripsIn')} {thisYear}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -74,8 +74,42 @@ const GRADIENTS = [
|
|||||||
]
|
]
|
||||||
function tripGradient(id) { return GRADIENTS[id % GRADIENTS.length] }
|
function tripGradient(id) { return GRADIENTS[id % GRADIENTS.length] }
|
||||||
|
|
||||||
|
// ── Liquid Glass hover effect ────────────────────────────────────────────────
|
||||||
|
function LiquidGlass({ children, dark, style, className = '', onClick }) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const glareRef = useRef(null)
|
||||||
|
const borderRef = useRef(null)
|
||||||
|
|
||||||
|
const onMove = (e) => {
|
||||||
|
if (!ref.current || !glareRef.current || !borderRef.current) return
|
||||||
|
const rect = ref.current.getBoundingClientRect()
|
||||||
|
const x = e.clientX - rect.left
|
||||||
|
const y = e.clientY - rect.top
|
||||||
|
glareRef.current.style.background = `radial-gradient(circle 250px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'} 0%, transparent 70%)`
|
||||||
|
glareRef.current.style.opacity = '1'
|
||||||
|
borderRef.current.style.opacity = '1'
|
||||||
|
borderRef.current.style.maskImage = `radial-gradient(circle 120px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||||
|
borderRef.current.style.WebkitMaskImage = `radial-gradient(circle 120px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||||
|
}
|
||||||
|
const onLeave = () => {
|
||||||
|
if (glareRef.current) glareRef.current.style.opacity = '0'
|
||||||
|
if (borderRef.current) borderRef.current.style.opacity = '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} onMouseMove={onMove} onMouseLeave={onLeave} onClick={onClick} className={className}
|
||||||
|
style={{ position: 'relative', overflow: 'hidden', ...style }}>
|
||||||
|
<div ref={glareRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.3s', borderRadius: 'inherit', zIndex: 1 }} />
|
||||||
|
<div ref={borderRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.3s', borderRadius: 'inherit', zIndex: 1,
|
||||||
|
border: dark ? '1.5px solid rgba(255,255,255,0.4)' : '1.5px solid rgba(0,0,0,0.12)',
|
||||||
|
}} />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Spotlight Card (next upcoming trip) ─────────────────────────────────────
|
// ── Spotlight Card (next upcoming trip) ─────────────────────────────────────
|
||||||
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) {
|
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark }) {
|
||||||
const status = getTripStatus(trip)
|
const status = getTripStatus(trip)
|
||||||
|
|
||||||
const coverBg = trip.cover_image
|
const coverBg = trip.cover_image
|
||||||
@@ -83,7 +117,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }
|
|||||||
: tripGradient(trip.id)
|
: tripGradient(trip.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 32, borderRadius: 20, overflow: 'hidden', boxShadow: '0 8px 40px rgba(0,0,0,0.13)', position: 'relative', cursor: 'pointer' }}
|
<LiquidGlass dark={dark} style={{ marginBottom: 32, borderRadius: 20, boxShadow: '0 8px 40px rgba(0,0,0,0.13)', cursor: 'pointer' }}
|
||||||
onClick={() => onClick(trip)}>
|
onClick={() => onClick(trip)}>
|
||||||
{/* Cover / Background */}
|
{/* Cover / Background */}
|
||||||
<div style={{ height: 300, background: coverBg, position: 'relative' }}>
|
<div style={{ height: 300, background: coverBg, position: 'relative' }}>
|
||||||
@@ -151,7 +185,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</LiquidGlass>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,9 +204,9 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) {
|
|||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
onClick={() => onClick(trip)}
|
onClick={() => onClick(trip)}
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--bg-card)', borderRadius: 16, overflow: 'hidden', cursor: 'pointer',
|
background: hovered ? 'var(--bg-tertiary)' : 'var(--bg-card)', borderRadius: 16, overflow: 'hidden', cursor: 'pointer',
|
||||||
border: '1px solid var(--border-primary)', transition: 'all 0.18s',
|
border: `1px solid ${hovered ? 'var(--text-faint)' : 'var(--border-primary)'}`, transition: 'all 0.18s',
|
||||||
boxShadow: hovered ? '0 8px 28px rgba(0,0,0,0.10)' : '0 1px 4px rgba(0,0,0,0.04)',
|
boxShadow: hovered ? '0 8px 28px rgba(0,0,0,0.15)' : '0 1px 4px rgba(0,0,0,0.04)',
|
||||||
transform: hovered ? 'translateY(-2px)' : 'none',
|
transform: hovered ? 'translateY(-2px)' : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -354,6 +388,7 @@ export default function DashboardPage() {
|
|||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const { demoMode } = useAuthStore()
|
const { demoMode } = useAuthStore()
|
||||||
const { settings, updateSetting } = useSettingsStore()
|
const { settings, updateSetting } = useSettingsStore()
|
||||||
|
const dark = settings.dark_mode
|
||||||
const showCurrency = settings.dashboard_currency !== 'off'
|
const showCurrency = settings.dashboard_currency !== 'off'
|
||||||
const showTimezone = settings.dashboard_timezone !== 'off'
|
const showTimezone = settings.dashboard_timezone !== 'off'
|
||||||
const showSidebar = showCurrency || showTimezone
|
const showSidebar = showCurrency || showTimezone
|
||||||
@@ -456,7 +491,7 @@ export default function DashboardPage() {
|
|||||||
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', background: 'var(--bg-secondary)', ...font }}>
|
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', background: 'var(--bg-secondary)', ...font }}>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
{demoMode && <DemoBanner />}
|
{demoMode && <DemoBanner />}
|
||||||
<div style={{ flex: 1, overflow: 'auto', overscrollBehavior: 'contain', marginTop: 56 }}>
|
<div style={{ flex: 1, overflow: 'auto', overscrollBehavior: 'contain', marginTop: 'var(--nav-h)' }}>
|
||||||
<div style={{ maxWidth: 1300, margin: '0 auto', padding: '32px 20px 60px' }}>
|
<div style={{ maxWidth: 1300, margin: '0 auto', padding: '32px 20px 60px' }}>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -575,7 +610,7 @@ export default function DashboardPage() {
|
|||||||
{!isLoading && spotlight && (
|
{!isLoading && spotlight && (
|
||||||
<SpotlightCard
|
<SpotlightCard
|
||||||
trip={spotlight}
|
trip={spotlight}
|
||||||
t={t} locale={locale}
|
t={t} locale={locale} dark={dark}
|
||||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onArchive={handleArchive}
|
onArchive={handleArchive}
|
||||||
@@ -635,8 +670,8 @@ export default function DashboardPage() {
|
|||||||
{/* Widgets sidebar */}
|
{/* Widgets sidebar */}
|
||||||
{showSidebar && (
|
{showSidebar && (
|
||||||
<div className="hidden lg:flex flex-col gap-4" style={{ position: 'sticky', top: 80, flexShrink: 0, width: 280 }}>
|
<div className="hidden lg:flex flex-col gap-4" style={{ position: 'sticky', top: 80, flexShrink: 0, width: 280 }}>
|
||||||
{showCurrency && <CurrencyWidget />}
|
{showCurrency && <LiquidGlass dark={dark} style={{ borderRadius: 16 }}><CurrencyWidget /></LiquidGlass>}
|
||||||
{showTimezone && <TimezoneWidget />}
|
{showTimezone && <LiquidGlass dark={dark} style={{ borderRadius: 16 }}><TimezoneWidget /></LiquidGlass>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export default function FilesPage() {
|
|||||||
<div className="min-h-screen bg-slate-50">
|
<div className="min-h-screen bg-slate-50">
|
||||||
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
|
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
|
||||||
|
|
||||||
<div className="pt-14">
|
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||||
<div className="max-w-5xl mx-auto px-4 py-6">
|
<div className="max-w-5xl mx-auto px-4 py-6">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -29,9 +29,11 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle OIDC callback token
|
// Handle OIDC callback token (via URL fragment to avoid logging)
|
||||||
|
const hash = window.location.hash.substring(1)
|
||||||
|
const hashParams = new URLSearchParams(hash)
|
||||||
|
const token = hashParams.get('token')
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
const token = params.get('token')
|
|
||||||
const oidcError = params.get('oidc_error')
|
const oidcError = params.get('oidc_error')
|
||||||
if (token) {
|
if (token) {
|
||||||
localStorage.setItem('auth_token', token)
|
localStorage.setItem('auth_token', token)
|
||||||
@@ -65,6 +67,8 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [showTakeoff, setShowTakeoff] = useState(false)
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
@@ -77,10 +81,10 @@ export default function LoginPage() {
|
|||||||
} else {
|
} else {
|
||||||
await login(email, password)
|
await login(email, password)
|
||||||
}
|
}
|
||||||
navigate('/dashboard')
|
setShowTakeoff(true)
|
||||||
|
setTimeout(() => navigate('/dashboard'), 2600)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || t('login.error'))
|
setError(err.message || t('login.error'))
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,6 +97,157 @@ export default function LoginPage() {
|
|||||||
color: '#111827', background: 'white', boxSizing: 'border-box', transition: 'border-color 0.15s',
|
color: '#111827', background: 'white', boxSizing: 'border-box', transition: 'border-color 0.15s',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showTakeoff) {
|
||||||
|
return (
|
||||||
|
<div className="takeoff-overlay" style={{ position: 'fixed', inset: 0, zIndex: 99999, overflow: 'hidden' }}>
|
||||||
|
{/* Sky gradient */}
|
||||||
|
<div className="takeoff-sky" style={{ position: 'absolute', inset: 0 }} />
|
||||||
|
|
||||||
|
{/* Stars */}
|
||||||
|
{Array.from({ length: 60 }, (_, i) => (
|
||||||
|
<div key={i} className="takeoff-star" style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: Math.random() > 0.7 ? 3 : 1.5,
|
||||||
|
height: Math.random() > 0.7 ? 3 : 1.5,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'white',
|
||||||
|
top: `${Math.random() * 100}%`,
|
||||||
|
left: `${Math.random() * 100}%`,
|
||||||
|
animationDelay: `${0.3 + Math.random() * 0.5}s, ${Math.random() * 1}s`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Clouds rushing past */}
|
||||||
|
{[0, 1, 2, 3, 4].map(i => (
|
||||||
|
<div key={i} className="takeoff-cloud" style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: 120 + i * 40,
|
||||||
|
height: 40 + i * 10,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.15)',
|
||||||
|
filter: 'blur(8px)',
|
||||||
|
right: -200,
|
||||||
|
top: `${25 + i * 12}%`,
|
||||||
|
animationDelay: `${0.3 + i * 0.25}s`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Speed lines */}
|
||||||
|
{Array.from({ length: 12 }, (_, i) => (
|
||||||
|
<div key={i} className="takeoff-speedline" style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: 80 + Math.random() * 120,
|
||||||
|
height: 1.5,
|
||||||
|
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent)',
|
||||||
|
top: `${10 + Math.random() * 80}%`,
|
||||||
|
right: -200,
|
||||||
|
animationDelay: `${0.5 + i * 0.12}s`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Plane */}
|
||||||
|
<div className="takeoff-plane" style={{ position: 'absolute', left: '50%', bottom: '10%', transform: 'translate(-50%, 0)' }}>
|
||||||
|
<svg viewBox="0 0 480 120" style={{ width: 200, filter: 'drop-shadow(0 0 20px rgba(255,255,255,0.3))' }}>
|
||||||
|
<g fill="white" transform="translate(240,60) rotate(-12)">
|
||||||
|
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
||||||
|
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
||||||
|
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
||||||
|
<path d="M-100,-5 L-120,-30 L-108,-30 L-90,-8 Z" />
|
||||||
|
<path d="M-100,5 L-120,30 L-108,30 L-90,8 Z" />
|
||||||
|
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contrail */}
|
||||||
|
<div className="takeoff-trail" style={{
|
||||||
|
position: 'absolute', left: '50%', bottom: '8%',
|
||||||
|
width: 3, height: 0, background: 'linear-gradient(to top, transparent, rgba(255,255,255,0.5))',
|
||||||
|
transformOrigin: 'bottom center',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Logo fade in + burst */}
|
||||||
|
<div className="takeoff-logo" style={{
|
||||||
|
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
|
||||||
|
}}>
|
||||||
|
<img src="/logo-light.svg" alt="NOMAD" style={{ height: 72 }} />
|
||||||
|
<p style={{ margin: 0, fontSize: 20, color: 'rgba(255,255,255,0.6)', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase' }}>{t('login.tagline')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.takeoff-sky {
|
||||||
|
background: linear-gradient(to top, #1a1a2e 0%, #16213e 30%, #0f3460 60%, #0a0a23 100%);
|
||||||
|
animation: skyShift 2.6s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
@keyframes skyShift {
|
||||||
|
0% { background: linear-gradient(to top, #0a0a23 0%, #0f172a 40%, #111827 100%); }
|
||||||
|
100% { background: linear-gradient(to top, #000011 0%, #000016 50%, #000011 100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.takeoff-star {
|
||||||
|
opacity: 0;
|
||||||
|
animation: starAppear 0.5s ease-out forwards, starTwinkle 2s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes starAppear {
|
||||||
|
0% { opacity: 0; transform: scale(0); }
|
||||||
|
100% { opacity: 0.7; transform: scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes starTwinkle {
|
||||||
|
0% { opacity: 0.3; }
|
||||||
|
100% { opacity: 0.9; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.takeoff-cloud {
|
||||||
|
animation: cloudRush 0.6s ease-in forwards;
|
||||||
|
}
|
||||||
|
@keyframes cloudRush {
|
||||||
|
0% { right: -200px; opacity: 0; }
|
||||||
|
20% { opacity: 0.4; }
|
||||||
|
100% { right: 120%; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.takeoff-speedline {
|
||||||
|
animation: speedRush 0.4s ease-in forwards;
|
||||||
|
}
|
||||||
|
@keyframes speedRush {
|
||||||
|
0% { right: -200px; opacity: 0; }
|
||||||
|
30% { opacity: 0.6; }
|
||||||
|
100% { right: 120%; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.takeoff-plane {
|
||||||
|
animation: planeUp 1s ease-in forwards;
|
||||||
|
}
|
||||||
|
@keyframes planeUp {
|
||||||
|
0% { transform: translate(-50%, 0) rotate(0deg) scale(1); bottom: 8%; left: 50%; opacity: 1; }
|
||||||
|
100% { transform: translate(-50%, 0) rotate(-22deg) scale(0.15); bottom: 120%; left: 58%; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.takeoff-trail {
|
||||||
|
animation: trailGrow 0.9s ease-out 0.15s forwards;
|
||||||
|
}
|
||||||
|
@keyframes trailGrow {
|
||||||
|
0% { height: 0; opacity: 0; transform: translateX(-50%) rotate(-5deg); }
|
||||||
|
30% { height: 150px; opacity: 0.6; }
|
||||||
|
60% { height: 350px; opacity: 0.4; }
|
||||||
|
100% { height: 600px; opacity: 0; transform: translateX(-50%) rotate(-8deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.takeoff-logo {
|
||||||
|
opacity: 0;
|
||||||
|
animation: logoReveal 0.5s ease-out 0.9s forwards;
|
||||||
|
}
|
||||||
|
@keyframes logoReveal {
|
||||||
|
0% { opacity: 0; transform: translate(-50%, -40%) scale(0.9); }
|
||||||
|
100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}>
|
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}>
|
||||||
|
|
||||||
@@ -213,14 +368,11 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<div style={{ position: 'relative', zIndex: 1, maxWidth: 560, textAlign: 'center' }}>
|
<div style={{ position: 'relative', zIndex: 1, maxWidth: 560, textAlign: 'center' }}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 48, justifyContent: 'center' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 48 }}>
|
||||||
<div style={{ width: 48, height: 48, background: 'white', borderRadius: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 0 30px rgba(255,255,255,0.1)' }}>
|
<img src="/logo-light.svg" alt="NOMAD" style={{ height: 64 }} />
|
||||||
<Plane size={24} style={{ color: '#0f172a' }} />
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: 28, fontWeight: 800, color: 'white', letterSpacing: '-0.02em' }}>NOMAD</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 style={{ margin: '0 0 12px', fontSize: 36, fontWeight: 800, color: 'white', lineHeight: 1.15, letterSpacing: '-0.02em' }}>
|
<h2 style={{ margin: '0 0 12px', fontSize: 36, fontWeight: 700, color: 'white', lineHeight: 1.15, letterSpacing: '-0.02em', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase' }}>
|
||||||
{t('login.tagline')}
|
{t('login.tagline')}
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ margin: '0 0 44px', fontSize: 15, color: 'rgba(255,255,255,0.5)', lineHeight: 1.7 }}>
|
<p style={{ margin: '0 0 44px', fontSize: 15, color: 'rgba(255,255,255,0.5)', lineHeight: 1.7 }}>
|
||||||
@@ -259,13 +411,11 @@ export default function LoginPage() {
|
|||||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||||
|
|
||||||
{/* Mobile logo */}
|
{/* Mobile logo */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 36, justifyContent: 'center' }}
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, marginBottom: 36 }}
|
||||||
className="mobile-logo">
|
className="mobile-logo">
|
||||||
<style>{`@media(min-width:1024px){.mobile-logo{display:none!important}}`}</style>
|
<style>{`@media(min-width:1024px){.mobile-logo{display:none!important}}`}</style>
|
||||||
<div style={{ width: 36, height: 36, background: '#111827', borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<img src="/logo-dark.svg" alt="NOMAD" style={{ height: 48 }} />
|
||||||
<Plane size={18} style={{ color: 'white' }} />
|
<p style={{ margin: 0, fontSize: 18, color: '#9ca3af', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase' }}>{t('login.tagline')}</p>
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: 22, fontWeight: 800, color: '#111827', letterSpacing: '-0.02em' }}>NOMAD</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ background: 'white', borderRadius: 20, border: '1px solid #e5e7eb', padding: '36px 32px', boxShadow: '0 2px 16px rgba(0,0,0,0.06)' }}>
|
<div style={{ background: 'white', borderRadius: 20, border: '1px solid #e5e7eb', padding: '36px 32px', boxShadow: '0 2px 16px rgba(0,0,0,0.06)' }}>
|
||||||
@@ -344,7 +494,7 @@ export default function LoginPage() {
|
|||||||
>
|
>
|
||||||
{isLoading
|
{isLoading
|
||||||
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : t('login.signingIn')}</>
|
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : t('login.signingIn')}</>
|
||||||
: mode === 'register' ? t('login.createAccount') : t('login.signIn')
|
: <><Plane size={16} />{mode === 'register' ? t('login.createAccount') : t('login.signIn')}</>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -404,7 +554,7 @@ export default function LoginPage() {
|
|||||||
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 12px rgba(245, 158, 11, 0.3)' }}
|
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 12px rgba(245, 158, 11, 0.3)' }}
|
||||||
>
|
>
|
||||||
<Plane size={18} />
|
<Plane size={18} />
|
||||||
Demo ausprobieren — ohne Registrierung
|
{language === 'de' ? 'Demo ausprobieren — ohne Registrierung' : 'Try the demo — no registration needed'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function PhotosPage() {
|
|||||||
<div className="min-h-screen bg-slate-50">
|
<div className="min-h-screen bg-slate-50">
|
||||||
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
|
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
|
||||||
|
|
||||||
<div className="pt-14">
|
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export default function SettingsPage() {
|
|||||||
<div className="min-h-screen" style={{ background: 'var(--bg-secondary)' }}>
|
<div className="min-h-screen" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div className="pt-14">
|
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||||
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6">
|
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('settings.title')}</h1>
|
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('settings.title')}</h1>
|
||||||
|
|||||||
@@ -133,13 +133,8 @@ export default function TripPlannerPage() {
|
|||||||
return places.filter(p => p.lat && p.lng)
|
return places.filter(p => p.lat && p.lng)
|
||||||
}, [places])
|
}, [places])
|
||||||
|
|
||||||
const handleSelectDay = useCallback((dayId) => {
|
const updateRouteForDay = useCallback((dayId) => {
|
||||||
tripStore.setSelectedDay(dayId)
|
if (!dayId) { setRoute(null); setRouteInfo(null); return }
|
||||||
setRouteInfo(null)
|
|
||||||
setFitKey(k => k + 1)
|
|
||||||
setMobileSidebarOpen(null)
|
|
||||||
|
|
||||||
// Auto-show Luftlinien for the selected day
|
|
||||||
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||||
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng)
|
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||||
if (waypoints.length >= 2) {
|
if (waypoints.length >= 2) {
|
||||||
@@ -147,12 +142,22 @@ export default function TripPlannerPage() {
|
|||||||
} else {
|
} else {
|
||||||
setRoute(null)
|
setRoute(null)
|
||||||
}
|
}
|
||||||
|
setRouteInfo(null)
|
||||||
}, [tripStore])
|
}, [tripStore])
|
||||||
|
|
||||||
|
const handleSelectDay = useCallback((dayId, skipFit) => {
|
||||||
|
const changed = dayId !== selectedDayId
|
||||||
|
tripStore.setSelectedDay(dayId)
|
||||||
|
if (changed && !skipFit) setFitKey(k => k + 1)
|
||||||
|
setMobileSidebarOpen(null)
|
||||||
|
updateRouteForDay(dayId)
|
||||||
|
}, [tripStore, updateRouteForDay, selectedDayId])
|
||||||
|
|
||||||
const handlePlaceClick = useCallback((placeId) => {
|
const handlePlaceClick = useCallback((placeId) => {
|
||||||
setSelectedPlaceId(placeId)
|
setSelectedPlaceId(placeId)
|
||||||
if (placeId) { setLeftCollapsed(false); setRightCollapsed(false) }
|
if (placeId) { setLeftCollapsed(false); setRightCollapsed(false) }
|
||||||
}, [])
|
updateRouteForDay(selectedDayId)
|
||||||
|
}, [selectedDayId, updateRouteForDay])
|
||||||
|
|
||||||
const handleMarkerClick = useCallback((placeId) => {
|
const handleMarkerClick = useCallback((placeId) => {
|
||||||
const opening = placeId !== undefined
|
const opening = placeId !== undefined
|
||||||
@@ -189,16 +194,29 @@ export default function TripPlannerPage() {
|
|||||||
try {
|
try {
|
||||||
await tripStore.assignPlaceToDay(tripId, target, placeId, position)
|
await tripStore.assignPlaceToDay(tripId, target, placeId, position)
|
||||||
toast.success(t('trip.toast.assignedToDay'))
|
toast.success(t('trip.toast.assignedToDay'))
|
||||||
|
updateRouteForDay(target)
|
||||||
} catch (err) { toast.error(err.message) }
|
} catch (err) { toast.error(err.message) }
|
||||||
}, [selectedDayId, tripId, tripStore, toast])
|
}, [selectedDayId, tripId, tripStore, toast, updateRouteForDay])
|
||||||
|
|
||||||
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
|
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
|
||||||
try { await tripStore.removeAssignment(tripId, dayId, assignmentId) }
|
try {
|
||||||
|
await tripStore.removeAssignment(tripId, dayId, assignmentId)
|
||||||
|
updateRouteForDay(dayId)
|
||||||
|
}
|
||||||
catch (err) { toast.error(err.message) }
|
catch (err) { toast.error(err.message) }
|
||||||
}, [tripId, tripStore, toast])
|
}, [tripId, tripStore, toast, updateRouteForDay])
|
||||||
|
|
||||||
const handleReorder = useCallback(async (dayId, orderedIds) => {
|
const handleReorder = useCallback(async (dayId, orderedIds) => {
|
||||||
try { await tripStore.reorderAssignments(tripId, dayId, orderedIds) }
|
try {
|
||||||
|
await tripStore.reorderAssignments(tripId, dayId, orderedIds)
|
||||||
|
// Build route directly from orderedIds to avoid stale closure
|
||||||
|
const dayItems = tripStore.assignments[String(dayId)] || []
|
||||||
|
const ordered = orderedIds.map(id => dayItems.find(a => a.id === id)).filter(Boolean)
|
||||||
|
const waypoints = ordered.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||||
|
if (waypoints.length >= 2) setRoute(waypoints.map(p => [p.lat, p.lng]))
|
||||||
|
else setRoute(null)
|
||||||
|
setRouteInfo(null)
|
||||||
|
}
|
||||||
catch { toast.error(t('trip.toast.reorderError')) }
|
catch { toast.error(t('trip.toast.reorderError')) }
|
||||||
}, [tripId, tripStore, toast])
|
}, [tripId, tripStore, toast])
|
||||||
|
|
||||||
@@ -259,11 +277,11 @@ export default function TripPlannerPage() {
|
|||||||
if (!trip) return null
|
if (!trip) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}>
|
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}>
|
||||||
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
|
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', top: 56, left: 0, right: 0, zIndex: 40,
|
position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, zIndex: 40,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
padding: '0 12px',
|
padding: '0 12px',
|
||||||
background: 'var(--bg-elevated)',
|
background: 'var(--bg-elevated)',
|
||||||
@@ -298,8 +316,8 @@ export default function TripPlannerPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Offset by navbar (56px) + tab bar (44px) */}
|
{/* Offset by navbar + tab bar (44px) */}
|
||||||
<div style={{ position: 'fixed', top: 100, left: 0, right: 0, bottom: 0, overflow: 'hidden', overscrollBehavior: 'contain' }}>
|
<div style={{ position: 'fixed', top: 'calc(var(--nav-h) + 44px)', left: 0, right: 0, bottom: 0, overflow: 'hidden', overscrollBehavior: 'contain' }}>
|
||||||
|
|
||||||
{activeTab === 'plan' && (
|
{activeTab === 'plan' && (
|
||||||
<div style={{ position: 'absolute', inset: 0 }}>
|
<div style={{ position: 'absolute', inset: 0 }}>
|
||||||
@@ -333,7 +351,7 @@ export default function TripPlannerPage() {
|
|||||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||||
<button onClick={() => setLeftCollapsed(c => !c)}
|
<button onClick={() => setLeftCollapsed(c => !c)}
|
||||||
style={{
|
style={{
|
||||||
position: leftCollapsed ? 'fixed' : 'absolute', top: leftCollapsed ? 'calc(56px + 44px + 14px)' : 14, left: leftCollapsed ? 10 : undefined, right: leftCollapsed ? undefined : -28, zIndex: 25,
|
position: leftCollapsed ? 'fixed' : 'absolute', top: leftCollapsed ? 'calc(var(--nav-h) + 44px + 14px)' : 14, left: leftCollapsed ? 10 : undefined, right: leftCollapsed ? undefined : -28, zIndex: 25,
|
||||||
width: 36, height: 36, borderRadius: leftCollapsed ? 10 : '0 10px 10px 0',
|
width: 36, height: 36, borderRadius: leftCollapsed ? 10 : '0 10px 10px 0',
|
||||||
background: leftCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
background: leftCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||||
boxShadow: leftCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
boxShadow: leftCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
||||||
@@ -388,7 +406,7 @@ export default function TripPlannerPage() {
|
|||||||
<div className="hidden md:block" style={{ position: 'absolute', right: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
<div className="hidden md:block" style={{ position: 'absolute', right: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||||
<button onClick={() => setRightCollapsed(c => !c)}
|
<button onClick={() => setRightCollapsed(c => !c)}
|
||||||
style={{
|
style={{
|
||||||
position: rightCollapsed ? 'fixed' : 'absolute', top: rightCollapsed ? 'calc(56px + 44px + 14px)' : 14, right: rightCollapsed ? 10 : undefined, left: rightCollapsed ? undefined : -28, zIndex: 25,
|
position: rightCollapsed ? 'fixed' : 'absolute', top: rightCollapsed ? 'calc(var(--nav-h) + 44px + 14px)' : 14, right: rightCollapsed ? 10 : undefined, left: rightCollapsed ? undefined : -28, zIndex: 25,
|
||||||
width: 36, height: 36, borderRadius: rightCollapsed ? 10 : '10px 0 0 10px',
|
width: 36, height: 36, borderRadius: rightCollapsed ? 10 : '10px 0 0 10px',
|
||||||
background: rightCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
background: rightCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||||
boxShadow: rightCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
boxShadow: rightCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
||||||
@@ -436,7 +454,7 @@ export default function TripPlannerPage() {
|
|||||||
|
|
||||||
{/* Mobile sidebar buttons — portal to body to escape Leaflet touch handling */}
|
{/* Mobile sidebar buttons — portal to body to escape Leaflet touch handling */}
|
||||||
{activeTab === 'plan' && !mobileSidebarOpen && !showPlaceForm && !showMembersModal && !showReservationModal && ReactDOM.createPortal(
|
{activeTab === 'plan' && !mobileSidebarOpen && !showPlaceForm && !showMembersModal && !showReservationModal && ReactDOM.createPortal(
|
||||||
<div className="flex md:hidden" style={{ position: 'fixed', top: 112, left: 12, right: 12, justifyContent: 'space-between', zIndex: 100, pointerEvents: 'none' }}>
|
<div className="flex md:hidden" style={{ position: 'fixed', top: 'calc(var(--nav-h) + 44px + 12px)', left: 12, right: 12, justifyContent: 'space-between', zIndex: 100, pointerEvents: 'none' }}>
|
||||||
<button onClick={() => setMobileSidebarOpen('left')}
|
<button onClick={() => setMobileSidebarOpen('left')}
|
||||||
style={{ pointerEvents: 'auto', background: 'var(--bg-card)', color: 'var(--text-primary)', backdropFilter: 'blur(12px)', border: '1px solid var(--border-primary)', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit', touchAction: 'manipulation' }}>
|
style={{ pointerEvents: 'auto', background: 'var(--bg-card)', color: 'var(--text-primary)', backdropFilter: 'blur(12px)', border: '1px solid var(--border-primary)', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit', touchAction: 'manipulation' }}>
|
||||||
{t('trip.mobilePlan')}
|
{t('trip.mobilePlan')}
|
||||||
@@ -468,7 +486,7 @@ export default function TripPlannerPage() {
|
|||||||
|
|
||||||
{mobileSidebarOpen && ReactDOM.createPortal(
|
{mobileSidebarOpen && ReactDOM.createPortal(
|
||||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.3)', zIndex: 9999 }} onClick={() => setMobileSidebarOpen(null)}>
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.3)', zIndex: 9999 }} onClick={() => setMobileSidebarOpen(null)}>
|
||||||
<div style={{ position: 'absolute', top: 56, left: 0, right: 0, bottom: 0, background: 'var(--bg-card)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }} onClick={e => e.stopPropagation()}>
|
<div style={{ position: 'absolute', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0, background: 'var(--bg-card)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }} onClick={e => e.stopPropagation()}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid var(--border-secondary)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid var(--border-secondary)' }}>
|
||||||
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{mobileSidebarOpen === 'left' ? t('trip.mobilePlan') : t('trip.mobilePlaces')}</span>
|
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{mobileSidebarOpen === 'left' ? t('trip.mobilePlan') : t('trip.mobilePlaces')}</span>
|
||||||
<button onClick={() => setMobileSidebarOpen(null)} style={{ background: 'var(--bg-tertiary)', border: 'none', borderRadius: '50%', width: 28, height: 28, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-primary)' }}>
|
<button onClick={() => setMobileSidebarOpen(null)} style={{ background: 'var(--bg-tertiary)', border: 'none', borderRadius: '50%', width: 28, height: 28, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-primary)' }}>
|
||||||
@@ -477,7 +495,7 @@ export default function TripPlannerPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{mobileSidebarOpen === 'left'
|
{mobileSidebarOpen === 'left'
|
||||||
? <DayPlanSidebar trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} />
|
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} />
|
||||||
: <PlacesSidebar places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile />
|
: <PlacesSidebar places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function VacayPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
|
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="pt-14 flex items-center justify-center" style={{ minHeight: 'calc(100vh - 56px)' }}>
|
<div className="flex items-center justify-center" style={{ paddingTop: 'var(--nav-h)', minHeight: 'calc(100vh - var(--nav-h))' }}>
|
||||||
<div className="w-8 h-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
|
<div className="w-8 h-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,7 +119,7 @@ export default function VacayPage() {
|
|||||||
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
|
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div className="pt-14">
|
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||||
<div className="max-w-[1800px] mx-auto px-3 sm:px-4 py-4 sm:py-6">
|
<div className="max-w-[1800px] mx-auto px-3 sm:px-4 py-4 sm:py-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-4 sm:mb-5">
|
<div className="flex items-center justify-between mb-4 sm:mb-5">
|
||||||
|
|||||||
@@ -1,8 +1,91 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
||||||
|
navigateFallback: 'index.html',
|
||||||
|
navigateFallbackDenylist: [/^\/api/, /^\/uploads/],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
// Carto map tiles (default provider)
|
||||||
|
urlPattern: /^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'map-tiles',
|
||||||
|
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// OpenStreetMap tiles (fallback / alternative)
|
||||||
|
urlPattern: /^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'map-tiles',
|
||||||
|
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Leaflet CSS/JS from unpkg CDN
|
||||||
|
urlPattern: /^https:\/\/unpkg\.com\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'cdn-libs',
|
||||||
|
expiration: { maxEntries: 30, maxAgeSeconds: 365 * 24 * 60 * 60 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// API calls — prefer network, fall back to cache
|
||||||
|
urlPattern: /\/api\/.*/i,
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'api-data',
|
||||||
|
expiration: { maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 },
|
||||||
|
networkTimeoutSeconds: 5,
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Uploaded files (photos, covers, documents)
|
||||||
|
urlPattern: /\/uploads\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'user-uploads',
|
||||||
|
expiration: { maxEntries: 300, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
name: 'NOMAD \u2014 Travel Planner',
|
||||||
|
short_name: 'NOMAD',
|
||||||
|
description: 'Navigation Organizer for Maps, Activities & Destinations',
|
||||||
|
theme_color: '#111827',
|
||||||
|
background_color: '#0f172a',
|
||||||
|
display: 'standalone',
|
||||||
|
scope: '/',
|
||||||
|
start_url: '/',
|
||||||
|
orientation: 'any',
|
||||||
|
categories: ['travel', 'navigation'],
|
||||||
|
icons: [
|
||||||
|
{ src: 'icons/apple-touch-icon-180x180.png', sizes: '180x180', type: 'image/png' },
|
||||||
|
{ src: 'icons/icon-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: 'icons/icon-512x512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
{ src: 'icons/icon-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||||
|
{ src: 'icons/icon.svg', sizes: 'any', type: 'image/svg+xml' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- JWT_SECRET=${JWT_SECRET:-change-me-to-a-long-random-string}
|
- JWT_SECRET=${JWT_SECRET:-}
|
||||||
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
|
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "nomad-server",
|
"name": "nomad-server",
|
||||||
"version": "2.4.1",
|
"version": "2.5.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nomad-server",
|
"name": "nomad-server",
|
||||||
"version": "2.4.1",
|
"version": "2.5.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.1",
|
"dotenv": "^16.4.1",
|
||||||
"express": "^4.18.3",
|
"express": "^4.18.3",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
@@ -995,6 +996,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/helmet": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nomad-server",
|
"name": "nomad-server",
|
||||||
"version": "2.5.0",
|
"version": "2.5.2",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --experimental-sqlite src/index.js",
|
"start": "node --experimental-sqlite src/index.js",
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.1",
|
"dotenv": "^16.4.1",
|
||||||
"express": "^4.18.3",
|
"express": "^4.18.3",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
|||||||
@@ -421,6 +421,7 @@ if (process.env.DEMO_MODE === 'true') {
|
|||||||
// without needing a server restart after reinitialize()
|
// without needing a server restart after reinitialize()
|
||||||
const db = new Proxy({}, {
|
const db = new Proxy({}, {
|
||||||
get(_, prop) {
|
get(_, prop) {
|
||||||
|
if (!_db) throw new Error('Database connection is not available (restore in progress?)');
|
||||||
const val = _db[prop];
|
const val = _db[prop];
|
||||||
return typeof val === 'function' ? val.bind(_db) : val;
|
return typeof val === 'function' ? val.bind(_db) : val;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
|
const helmet = require('helmet');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
@@ -42,16 +43,11 @@ app.use(cors({
|
|||||||
origin: corsOrigin,
|
origin: corsOrigin,
|
||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
app.use(express.json());
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: false, // managed by frontend meta tag or reverse proxy
|
||||||
// Security headers
|
crossOriginEmbedderPolicy: false, // allows loading external images (maps, etc.)
|
||||||
app.use((req, res, next) => {
|
}));
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
app.use(express.json({ limit: '100kb' }));
|
||||||
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
||||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
||||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
// Serve uploaded files
|
// Serve uploaded files
|
||||||
|
|||||||
@@ -61,9 +61,12 @@ function getCountryFromAddress(address) {
|
|||||||
'malaysia':'MY','colombia':'CO','kolumbien':'CO','peru':'PE','chile':'CL','iran':'IR',
|
'malaysia':'MY','colombia':'CO','kolumbien':'CO','peru':'PE','chile':'CL','iran':'IR',
|
||||||
'iraq':'IQ','irak':'IQ','pakistan':'PK','kenya':'KE','kenia':'KE','nigeria':'NG',
|
'iraq':'IQ','irak':'IQ','pakistan':'PK','kenya':'KE','kenia':'KE','nigeria':'NG',
|
||||||
'saudi arabia':'SA','saudi-arabien':'SA','albania':'AL','albanien':'AL',
|
'saudi arabia':'SA','saudi-arabien':'SA','albania':'AL','albanien':'AL',
|
||||||
|
'日本':'JP','中国':'CN','한국':'KR','대한민국':'KR','ไทย':'TH',
|
||||||
};
|
};
|
||||||
const normalized = last.toLowerCase();
|
const normalized = last.toLowerCase();
|
||||||
if (NAME_TO_CODE[normalized]) return NAME_TO_CODE[normalized];
|
if (NAME_TO_CODE[normalized]) return NAME_TO_CODE[normalized];
|
||||||
|
// Try original case (for non-Latin scripts like 日本)
|
||||||
|
if (NAME_TO_CODE[last]) return NAME_TO_CODE[last];
|
||||||
// Try 2-letter code directly
|
// Try 2-letter code directly
|
||||||
if (last.length === 2 && last === last.toUpperCase()) return last;
|
if (last.length === 2 && last === last.toUpperCase()) return last;
|
||||||
return null;
|
return null;
|
||||||
@@ -130,12 +133,16 @@ router.get('/stats', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Unique cities (extract city from address — second to last comma segment)
|
// Unique cities (extract city from address — second to last comma segment)
|
||||||
|
// Strip postal codes and normalize to avoid duplicates like "Tokyo" vs "Tokyo 131-0045"
|
||||||
const citySet = new Set();
|
const citySet = new Set();
|
||||||
for (const place of places) {
|
for (const place of places) {
|
||||||
if (place.address) {
|
if (place.address) {
|
||||||
const parts = place.address.split(',').map(s => s.trim()).filter(Boolean);
|
const parts = place.address.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
if (parts.length >= 2) citySet.add(parts[parts.length - 2]);
|
let raw = parts.length >= 2 ? parts[parts.length - 2] : parts[0];
|
||||||
else if (parts.length === 1) citySet.add(parts[0]);
|
if (raw) {
|
||||||
|
const city = raw.replace(/[\d\-−〒]+/g, '').trim().toLowerCase();
|
||||||
|
if (city) citySet.add(city);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const totalCities = citySet.size;
|
const totalCities = citySet.size;
|
||||||
|
|||||||
@@ -138,25 +138,37 @@ async function restoreFromZip(zipPath, res) {
|
|||||||
// Step 1: close DB connection BEFORE touching the file (required on Windows)
|
// Step 1: close DB connection BEFORE touching the file (required on Windows)
|
||||||
closeDb();
|
closeDb();
|
||||||
|
|
||||||
// Step 2: remove WAL/SHM and overwrite DB file
|
try {
|
||||||
const dbDest = path.join(dataDir, 'travel.db');
|
// Step 2: remove WAL/SHM and overwrite DB file
|
||||||
for (const ext of ['', '-wal', '-shm']) {
|
const dbDest = path.join(dataDir, 'travel.db');
|
||||||
try { fs.unlinkSync(dbDest + ext); } catch (e) {}
|
for (const ext of ['', '-wal', '-shm']) {
|
||||||
}
|
try { fs.unlinkSync(dbDest + ext); } catch (e) {}
|
||||||
fs.copyFileSync(extractedDb, dbDest);
|
}
|
||||||
|
fs.copyFileSync(extractedDb, dbDest);
|
||||||
|
|
||||||
// Step 3: restore uploads
|
// Step 3: restore uploads — overwrite in-place instead of rmSync
|
||||||
const extractedUploads = path.join(extractDir, 'uploads');
|
// (rmSync fails with EBUSY because express.static holds the directory)
|
||||||
if (fs.existsSync(extractedUploads)) {
|
const extractedUploads = path.join(extractDir, 'uploads');
|
||||||
if (fs.existsSync(uploadsDir)) fs.rmSync(uploadsDir, { recursive: true, force: true });
|
if (fs.existsSync(extractedUploads)) {
|
||||||
fs.cpSync(extractedUploads, uploadsDir, { recursive: true });
|
// Clear contents of each subdirectory without removing the root uploads dir
|
||||||
|
for (const sub of fs.readdirSync(uploadsDir)) {
|
||||||
|
const subPath = path.join(uploadsDir, sub);
|
||||||
|
if (fs.statSync(subPath).isDirectory()) {
|
||||||
|
for (const file of fs.readdirSync(subPath)) {
|
||||||
|
try { fs.unlinkSync(path.join(subPath, file)); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Copy restored files over
|
||||||
|
fs.cpSync(extractedUploads, uploadsDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Step 4: ALWAYS reopen DB — even if file copy failed, so the server stays functional
|
||||||
|
reinitialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||||
|
|
||||||
// Step 4: reopen DB with restored data
|
|
||||||
reinitialize();
|
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Restore error:', err);
|
console.error('Restore error:', err);
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ const upload = multer({
|
|||||||
'text/plain',
|
'text/plain',
|
||||||
'text/csv',
|
'text/csv',
|
||||||
];
|
];
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
const blockedExts = ['.svg', '.html', '.htm', '.xml'];
|
||||||
|
if (blockedExts.includes(ext) || file.mimetype.includes('svg')) {
|
||||||
|
return cb(new Error('File type not allowed'));
|
||||||
|
}
|
||||||
if (allowed.includes(file.mimetype) || file.mimetype.startsWith('image/')) {
|
if (allowed.includes(file.mimetype) || file.mimetype.startsWith('image/')) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ router.get('/callback', async (req, res) => {
|
|||||||
// Generate JWT and redirect to frontend
|
// Generate JWT and redirect to frontend
|
||||||
const token = generateToken(user);
|
const token = generateToken(user);
|
||||||
// In dev mode, frontend runs on a different port
|
// In dev mode, frontend runs on a different port
|
||||||
res.redirect(frontendUrl(`/login?token=${token}`));
|
res.redirect(frontendUrl(`/login#token=${token}`));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[OIDC] Callback error:', err);
|
console.error('[OIDC] Callback error:', err);
|
||||||
res.redirect(frontendUrl('/login?oidc_error=server_error'));
|
res.redirect(frontendUrl('/login?oidc_error=server_error'));
|
||||||
|
|||||||
@@ -25,10 +25,12 @@ const upload = multer({
|
|||||||
storage,
|
storage,
|
||||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
if (file.mimetype.startsWith('image/')) {
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||||
|
if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowedExts.includes(ext)) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(new Error('Nur Bilddateien sind erlaubt'));
|
cb(new Error('Only jpg, png, gif, webp images allowed'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,8 +24,13 @@ const uploadCover = multer({
|
|||||||
storage: coverStorage,
|
storage: coverStorage,
|
||||||
limits: { fileSize: 20 * 1024 * 1024 },
|
limits: { fileSize: 20 * 1024 * 1024 },
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
if (file.mimetype.startsWith('image/')) cb(null, true);
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
else cb(new Error('Nur Bilder erlaubt'));
|
const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||||
|
if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowedExts.includes(ext)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only jpg, png, gif, webp images allowed'));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||