Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0497032ed7 | |||
| e4607e426c | |||
| faa8c84655 | |||
| 88dca41ef7 | |||
| 33162123af | |||
| 10662e0b63 | |||
| 8286fa8591 | |||
| 5f2bd51824 | |||
| 4d3ee08481 | |||
| aeb530515e | |||
| f4d3542d99 | |||
| d604ad1c5b | |||
| 3919c61eb6 | |||
| 98556c9aaf | |||
| 7e4ec82d3e | |||
| 5f891c83e8 | |||
| 3d6ae6811c | |||
| 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
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ FROM node:22-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Server-Dependencies installieren
|
# Server-Dependencies installieren (better-sqlite3 braucht Build-Tools)
|
||||||
COPY server/package*.json ./
|
COPY server/package*.json ./
|
||||||
RUN npm ci --production
|
RUN apk add --no-cache python3 make g++ && \
|
||||||
|
npm ci --production && \
|
||||||
|
apk del python3 make g++
|
||||||
|
|
||||||
# Server-Code kopieren
|
# Server-Code kopieren
|
||||||
COPY server/ ./
|
COPY server/ ./
|
||||||
@@ -33,4 +35,4 @@ ENV PORT=3000
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["node", "--experimental-sqlite", "src/index.js"]
|
CMD ["node", "src/index.js"]
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
# NOMAD
|
<p align="center">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="client/public/logo-light.svg" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="client/public/logo-dark.svg" />
|
||||||
|
<img src="client/public/logo-light.svg" alt="NOMAD" height="60" />
|
||||||
|
</picture>
|
||||||
|
<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.
|
|
||||||
|
|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>More Screenshots</summary>
|
<summary>More Screenshots</summary>
|
||||||
@@ -19,47 +30,61 @@ A self-hosted, real-time collaborative travel planner for organizing trips with
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
|  |  |
|
|  |  |
|
||||||
|  |  |
|
|  |  |
|
||||||
|  | |
|
|  | |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## 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** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback
|
||||||
|
|
||||||
|
### 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, backups, and GitHub release history
|
||||||
|
- **Auto-Backups** — Scheduled backups with configurable interval and retention
|
||||||
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
- **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 (`better-sqlite3`)
|
||||||
- **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**: Open-Meteo API (free, no key required)
|
||||||
- **Icons**: lucide-react
|
- **Icons**: lucide-react
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -70,6 +95,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.7",
|
||||||
"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.');
|
||||||
@@ -13,19 +13,20 @@ import SettingsPage from './pages/SettingsPage'
|
|||||||
import VacayPage from './pages/VacayPage'
|
import VacayPage from './pages/VacayPage'
|
||||||
import AtlasPage from './pages/AtlasPage'
|
import AtlasPage from './pages/AtlasPage'
|
||||||
import { ToastContainer } from './components/shared/Toast'
|
import { ToastContainer } from './components/shared/Toast'
|
||||||
import { TranslationProvider } from './i18n'
|
import { TranslationProvider, useTranslation } from './i18n'
|
||||||
import DemoBanner from './components/Layout/DemoBanner'
|
import DemoBanner from './components/Layout/DemoBanner'
|
||||||
import { authApi } from './api/client'
|
import { authApi } from './api/client'
|
||||||
|
|
||||||
function ProtectedRoute({ children, adminRequired = false }) {
|
function ProtectedRoute({ children, adminRequired = false }) {
|
||||||
const { isAuthenticated, user, isLoading } = useAuthStore()
|
const { isAuthenticated, user, isLoading } = useAuthStore()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
|
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
|
||||||
<p className="text-slate-500 text-sm">Wird geladen...</p>
|
<p className="text-slate-500 text-sm">{t('common.loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -78,13 +79,24 @@ 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) {
|
const mode = settings.dark_mode
|
||||||
document.documentElement.classList.add('dark')
|
const applyDark = (isDark) => {
|
||||||
} else {
|
document.documentElement.classList.toggle('dark', isDark)
|
||||||
document.documentElement.classList.remove('dark')
|
const meta = document.querySelector('meta[name="theme-color"]')
|
||||||
|
if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode === 'auto') {
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
applyDark(mq.matches)
|
||||||
|
const handler = (e) => applyDark(e.matches)
|
||||||
|
mq.addEventListener('change', handler)
|
||||||
|
return () => mq.removeEventListener('change', handler)
|
||||||
|
}
|
||||||
|
// Support legacy boolean + new string values
|
||||||
|
applyDark(mode === true || mode === 'dark')
|
||||||
}, [settings.dark_mode])
|
}, [settings.dark_mode])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export const assignmentsApi = {
|
|||||||
delete: (tripId, dayId, id) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
|
delete: (tripId, dayId, id) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
|
||||||
reorder: (tripId, dayId, orderedIds) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data),
|
reorder: (tripId, dayId, orderedIds) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data),
|
||||||
move: (tripId, assignmentId, newDayId, orderIndex) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
|
move: (tripId, assignmentId, newDayId, orderIndex) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
|
||||||
|
update: (tripId, dayId, id, data) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const packingApi = {
|
export const packingApi = {
|
||||||
@@ -129,6 +130,8 @@ export const adminApi = {
|
|||||||
updateOidc: (data) => apiClient.put('/admin/oidc', data).then(r => r.data),
|
updateOidc: (data) => apiClient.put('/admin/oidc', data).then(r => r.data),
|
||||||
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
||||||
updateAddon: (id, data) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
updateAddon: (id, data) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
||||||
|
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||||
|
installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addonsApi = {
|
export const addonsApi = {
|
||||||
@@ -165,7 +168,8 @@ export const reservationsApi = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const weatherApi = {
|
export const weatherApi = {
|
||||||
get: (lat, lng, date) => apiClient.get('/weather', { params: { lat, lng, date, units: 'metric' } }).then(r => r.data),
|
get: (lat, lng, date) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
||||||
|
getDetailed: (lat, lng, date, lang) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
@@ -174,6 +178,13 @@ export const settingsApi = {
|
|||||||
setBulk: (settings) => apiClient.post('/settings/bulk', { settings }).then(r => r.data),
|
setBulk: (settings) => apiClient.post('/settings/bulk', { settings }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const accommodationsApi = {
|
||||||
|
list: (tripId) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
|
||||||
|
create: (tripId, data) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
|
||||||
|
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
|
||||||
|
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
export const dayNotesApi = {
|
export const dayNotesApi = {
|
||||||
list: (tripId, dayId) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data),
|
list: (tripId, dayId) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data),
|
||||||
create: (tripId, dayId, data) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
|
create: (tripId, dayId, data) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
|
||||||
@@ -189,7 +200,7 @@ export const backupApi = {
|
|||||||
const res = await fetch(`/api/backup/download/${filename}`, {
|
const res = await fetch(`/api/backup/download/${filename}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Download fehlgeschlagen')
|
if (!res.ok) throw new Error('Download failed')
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
|
|||||||
@@ -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,8 @@ function AddonIcon({ name, size = 20 }) {
|
|||||||
|
|
||||||
export default function AddonManager() {
|
export default function AddonManager() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const dm = useSettingsStore(s => s.settings.dark_mode)
|
||||||
|
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
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 +70,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,67 @@ 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' }}>
|
||||||
|
{t('backup.restoreConfirmTitle')}
|
||||||
|
</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 }}>
|
||||||
|
{t('backup.restoreWarning')}
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{t('backup.restoreTip')}
|
||||||
|
</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' }}
|
||||||
|
>
|
||||||
|
{t('common.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'}
|
||||||
|
>
|
||||||
|
{t('backup.restoreConfirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
|
const REPO = 'mauriceboe/NOMAD'
|
||||||
|
const PER_PAGE = 10
|
||||||
|
|
||||||
|
export default function GitHubPanel() {
|
||||||
|
const { t, language } = useTranslation()
|
||||||
|
const [releases, setReleases] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [expanded, setExpanded] = useState({})
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
|
||||||
|
const fetchReleases = async (pageNum = 1, append = false) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=${PER_PAGE}&page=${pageNum}`)
|
||||||
|
if (!res.ok) throw new Error(`GitHub API: ${res.status}`)
|
||||||
|
const data = await res.json()
|
||||||
|
setReleases(prev => append ? [...prev, ...data] : data)
|
||||||
|
setHasMore(data.length === PER_PAGE)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
fetchReleases(1).finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleLoadMore = async () => {
|
||||||
|
const next = page + 1
|
||||||
|
setLoadingMore(true)
|
||||||
|
await fetchReleases(next, true)
|
||||||
|
setPage(next)
|
||||||
|
setLoadingMore(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleExpand = (id) => {
|
||||||
|
setExpanded(prev => ({ ...prev, [id]: !prev[id] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return d.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
|
||||||
|
const renderBody = (body) => {
|
||||||
|
if (!body) return null
|
||||||
|
const lines = body.split('\n')
|
||||||
|
const elements = []
|
||||||
|
let listItems = []
|
||||||
|
|
||||||
|
const flushList = () => {
|
||||||
|
if (listItems.length > 0) {
|
||||||
|
elements.push(
|
||||||
|
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
|
||||||
|
{listItems.map((item, i) => (
|
||||||
|
<li key={i} className="flex gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
listItems = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlineFormat = (text) => {
|
||||||
|
return text
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
|
||||||
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">$1</a>')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) { flushList(); continue }
|
||||||
|
|
||||||
|
if (trimmed.startsWith('### ')) {
|
||||||
|
flushList()
|
||||||
|
elements.push(
|
||||||
|
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{trimmed.slice(4)}
|
||||||
|
</h4>
|
||||||
|
)
|
||||||
|
} else if (trimmed.startsWith('## ')) {
|
||||||
|
flushList()
|
||||||
|
elements.push(
|
||||||
|
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{trimmed.slice(3)}
|
||||||
|
</h3>
|
||||||
|
)
|
||||||
|
} else if (/^[-*] /.test(trimmed)) {
|
||||||
|
listItems.push(trimmed.slice(2))
|
||||||
|
} else {
|
||||||
|
flushList()
|
||||||
|
elements.push(
|
||||||
|
<p key={elements.length} className="text-xs my-1" style={{ color: 'var(--text-muted)' }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushList()
|
||||||
|
return elements
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
<div className="p-8 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--text-muted)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
<div className="p-6 text-center">
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.github.error')}</p>
|
||||||
|
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Header card */}
|
||||||
|
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.github.title')}</h2>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`https://github.com/${REPO}/releases`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||||
|
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<div className="relative">
|
||||||
|
{/* Timeline line */}
|
||||||
|
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
|
||||||
|
|
||||||
|
<div className="space-y-0">
|
||||||
|
{releases.map((release, idx) => {
|
||||||
|
const isLatest = idx === 0
|
||||||
|
const isExpanded = expanded[release.id]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={release.id} className="relative pl-8 pb-5">
|
||||||
|
{/* Timeline dot */}
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-1 w-[23px] h-[23px] rounded-full flex items-center justify-center border-2"
|
||||||
|
style={{
|
||||||
|
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||||
|
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Release content */}
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{release.tag_name}
|
||||||
|
</span>
|
||||||
|
{isLatest && (
|
||||||
|
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
||||||
|
style={{ background: 'rgba(34,197,94,0.12)', color: '#16a34a' }}>
|
||||||
|
{t('admin.github.latest')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{release.prerelease && (
|
||||||
|
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
||||||
|
style={{ background: 'rgba(245,158,11,0.12)', color: '#d97706' }}>
|
||||||
|
{t('admin.github.prerelease')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{release.name && release.name !== release.tag_name && (
|
||||||
|
<p className="text-xs font-medium mt-0.5" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{release.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<Calendar size={10} />
|
||||||
|
{formatDate(release.published_at || release.created_at)}
|
||||||
|
</span>
|
||||||
|
{release.author && (
|
||||||
|
<span className="text-[11px]" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
{t('admin.github.by')} {release.author.login}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable body */}
|
||||||
|
{release.body && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleExpand(release.id)}
|
||||||
|
className="flex items-center gap-1 text-[11px] font-medium transition-colors"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||||
|
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-2 p-3 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
{renderBody(release.body)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Load more */}
|
||||||
|
{hasMore && (
|
||||||
|
<div className="text-center pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors"
|
||||||
|
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
||||||
|
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -163,7 +163,7 @@ export default function BudgetPanel({ tripId }) {
|
|||||||
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
|
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
|
||||||
|
|
||||||
const grouped = useMemo(() => (budgetItems || []).reduce((acc, item) => {
|
const grouped = useMemo(() => (budgetItems || []).reduce((acc, item) => {
|
||||||
const cat = item.category || 'Sonstiges'
|
const cat = item.category || 'Other'
|
||||||
if (!acc[cat]) acc[cat] = []
|
if (!acc[cat]) acc[cat] = []
|
||||||
acc[cat].push(item)
|
acc[cat].push(item)
|
||||||
return acc
|
return acc
|
||||||
@@ -190,7 +190,7 @@ export default function BudgetPanel({ tripId }) {
|
|||||||
const handleAddCategory = () => {
|
const handleAddCategory = () => {
|
||||||
if (!newCategoryName.trim()) return
|
if (!newCategoryName.trim()) return
|
||||||
addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 })
|
addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 })
|
||||||
setNewCategoryName(''); setShowAddCategory(false)
|
setNewCategoryName('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
|
const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ async function loadGeoJson() {
|
|||||||
|
|
||||||
export default function TravelStats() {
|
export default function TravelStats() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const dark = useSettingsStore(s => s.settings.dark_mode)
|
const dm = useSettingsStore(s => s.settings.dark_mode)
|
||||||
|
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
const [stats, setStats] = useState(null)
|
const [stats, setStats] = useState(null)
|
||||||
const [geoData, setGeoData] = useState(null)
|
const [geoData, setGeoData] = useState(null)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useCallback } from 'react'
|
import React, { useState, useCallback } from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket } from 'lucide-react'
|
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket } from 'lucide-react'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
@@ -68,10 +69,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -106,6 +107,23 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
noClick: false,
|
noClick: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Paste support
|
||||||
|
const handlePaste = useCallback((e) => {
|
||||||
|
const items = e.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
const files = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.kind === 'file') {
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (file) files.push(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (files.length > 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
onDrop(files)
|
||||||
|
}
|
||||||
|
}, [onDrop])
|
||||||
|
|
||||||
const filteredFiles = files.filter(f => {
|
const filteredFiles = files.filter(f => {
|
||||||
if (filterType === 'pdf') return f.mime_type === 'application/pdf'
|
if (filterType === 'pdf') return f.mime_type === 'application/pdf'
|
||||||
if (filterType === 'image') return isImage(f.mime_type)
|
if (filterType === 'image') return isImage(f.mime_type)
|
||||||
@@ -134,18 +152,18 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} onPaste={handlePaste} tabIndex={-1}>
|
||||||
{/* Lightbox */}
|
{/* Lightbox */}
|
||||||
{lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />}
|
{lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />}
|
||||||
|
|
||||||
{/* Datei-Vorschau Modal */}
|
{/* Datei-Vorschau Modal — portal to body to escape stacking context */}
|
||||||
{previewFile && (
|
{previewFile && ReactDOM.createPortal(
|
||||||
<div
|
<div
|
||||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 8 }}
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||||
onClick={() => setPreviewFile(null)}
|
onClick={() => setPreviewFile(null)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{ width: '100%', maxWidth: 950, height: '95vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
|
style={{ width: '100%', maxWidth: 950, height: '94vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||||
@@ -176,7 +194,8 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
</p>
|
</p>
|
||||||
</object>
|
</object>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -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,10 +1,11 @@
|
|||||||
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'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { addonsApi } from '../../api/client'
|
import { addonsApi } from '../../api/client'
|
||||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, CalendarDays, Briefcase, Globe } from 'lucide-react'
|
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react'
|
||||||
|
|
||||||
const ADDON_ICONS = { CalendarDays, Briefcase, Globe }
|
const ADDON_ICONS = { CalendarDays, Briefcase, Globe }
|
||||||
|
|
||||||
@@ -17,7 +18,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
|||||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||||
const [appVersion, setAppVersion] = useState(null)
|
const [appVersion, setAppVersion] = useState(null)
|
||||||
const [globalAddons, setGlobalAddons] = useState([])
|
const [globalAddons, setGlobalAddons] = useState([])
|
||||||
const dark = settings.dark_mode
|
const darkMode = settings.dark_mode
|
||||||
|
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
|
||||||
const loadAddons = () => {
|
const loadAddons = () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -45,8 +47,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
|||||||
navigate('/login')
|
navigate('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleDark = () => {
|
const toggleDarkMode = () => {
|
||||||
updateSetting('dark_mode', !dark).catch(() => {})
|
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -56,7 +58,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 +74,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 */}
|
||||||
@@ -137,8 +140,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dark mode toggle */}
|
{/* Dark mode toggle (light ↔ dark, overrides auto) */}
|
||||||
<button onClick={toggleDark} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
|
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
|
||||||
className="p-2 rounded-lg transition-colors flex-shrink-0"
|
className="p-2 rounded-lg transition-colors flex-shrink-0"
|
||||||
style={{ color: 'var(--text-muted)' }}
|
style={{ color: 'var(--text-muted)' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
@@ -167,11 +170,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 +213,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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState, useMemo } from 'react'
|
||||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
|
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
|
||||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
@@ -19,7 +19,12 @@ 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 createPlaceIcon(place, orderNumber, isSelected) {
|
function escAttr(s) {
|
||||||
|
if (!s) return ''
|
||||||
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||||
const size = isSelected ? 44 : 36
|
const size = isSelected ? 44 : 36
|
||||||
const borderColor = isSelected ? '#111827' : 'white'
|
const borderColor = isSelected ? '#111827' : 'white'
|
||||||
const borderWidth = isSelected ? 3 : 2.5
|
const borderWidth = isSelected ? 3 : 2.5
|
||||||
@@ -29,20 +34,23 @@ function createPlaceIcon(place, orderNumber, isSelected) {
|
|||||||
const bgColor = place.category_color || '#6b7280'
|
const bgColor = place.category_color || '#6b7280'
|
||||||
const icon = place.category_icon || '📍'
|
const icon = place.category_icon || '📍'
|
||||||
|
|
||||||
// White semi-transparent number badge (bottom-right), only when orderNumber is set
|
// Number badges (bottom-right), supports multiple numbers for duplicate places
|
||||||
const badgeHtml = orderNumber != null ? `
|
let badgeHtml = ''
|
||||||
<span style="
|
if (orderNumbers && orderNumbers.length > 0) {
|
||||||
position:absolute;bottom:-3px;right:-3px;
|
const label = orderNumbers.join(' · ')
|
||||||
min-width:18px;height:18px;border-radius:9px;
|
badgeHtml = `<span style="
|
||||||
padding:0 3px;
|
position:absolute;bottom:-4px;right:-4px;
|
||||||
background:rgba(255,255,255,0.92);
|
min-width:18px;height:${orderNumbers.length > 1 ? 16 : 18}px;border-radius:${orderNumbers.length > 1 ? 8 : 9}px;
|
||||||
border:1.5px solid rgba(0,0,0,0.18);
|
padding:0 ${orderNumbers.length > 1 ? 4 : 3}px;
|
||||||
|
background:rgba(255,255,255,0.94);
|
||||||
|
border:1.5px solid rgba(0,0,0,0.15);
|
||||||
box-shadow:0 1px 4px rgba(0,0,0,0.18);
|
box-shadow:0 1px 4px rgba(0,0,0,0.18);
|
||||||
display:flex;align-items:center;justify-content:center;
|
display:flex;align-items:center;justify-content:center;
|
||||||
font-size:9px;font-weight:800;color:#111827;
|
font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827;
|
||||||
font-family:-apple-system,system-ui,sans-serif;line-height:1;
|
font-family:-apple-system,system-ui,sans-serif;line-height:1;
|
||||||
box-sizing:border-box;
|
box-sizing:border-box;white-space:nowrap;
|
||||||
">${orderNumber}</span>` : ''
|
">${label}</span>`
|
||||||
|
}
|
||||||
|
|
||||||
if (place.image_url) {
|
if (place.image_url) {
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
@@ -55,7 +63,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>`,
|
||||||
@@ -84,19 +92,26 @@ function createPlaceIcon(place, orderNumber, isSelected) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectionController({ places, selectedPlaceId }) {
|
function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }) {
|
||||||
const map = useMap()
|
const map = useMap()
|
||||||
const prev = useRef(null)
|
const prev = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
||||||
const place = places.find(p => p.id === selectedPlaceId)
|
// Fit all day places into view (so you see context), but ensure selected is visible
|
||||||
if (place?.lat && place?.lng) {
|
const toFit = dayPlaces.length > 0 ? dayPlaces : places.filter(p => p.id === selectedPlaceId)
|
||||||
map.setView([place.lat, place.lng], Math.max(map.getZoom(), 15), { animate: true, duration: 0.5 })
|
const withCoords = toFit.filter(p => p.lat && p.lng)
|
||||||
|
if (withCoords.length > 0) {
|
||||||
|
try {
|
||||||
|
const bounds = L.latLngBounds(withCoords.map(p => [p.lat, p.lng]))
|
||||||
|
if (bounds.isValid()) {
|
||||||
|
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prev.current = selectedPlaceId
|
prev.current = selectedPlaceId
|
||||||
}, [selectedPlaceId, places, map])
|
}, [selectedPlaceId, places, dayPlaces, paddingOpts, map])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -116,7 +131,7 @@ function MapController({ center, zoom }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fit bounds when places change (fitKey triggers re-fit)
|
// Fit bounds when places change (fitKey triggers re-fit)
|
||||||
function BoundsController({ places, fitKey }) {
|
function BoundsController({ places, fitKey, paddingOpts }) {
|
||||||
const map = useMap()
|
const map = useMap()
|
||||||
const prevFitKey = useRef(-1)
|
const prevFitKey = useRef(-1)
|
||||||
|
|
||||||
@@ -126,9 +141,9 @@ function BoundsController({ places, fitKey }) {
|
|||||||
if (places.length === 0) return
|
if (places.length === 0) return
|
||||||
try {
|
try {
|
||||||
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
|
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
|
||||||
if (bounds.isValid()) map.fitBounds(bounds, { padding: [60, 60], maxZoom: 15, animate: true })
|
if (bounds.isValid()) map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [fitKey, places, map])
|
}, [fitKey, places, paddingOpts, map])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -148,6 +163,7 @@ const mapPhotoCache = new Map()
|
|||||||
|
|
||||||
export function MapView({
|
export function MapView({
|
||||||
places = [],
|
places = [],
|
||||||
|
dayPlaces = [],
|
||||||
route = null,
|
route = null,
|
||||||
selectedPlaceId = null,
|
selectedPlaceId = null,
|
||||||
onMarkerClick,
|
onMarkerClick,
|
||||||
@@ -157,7 +173,20 @@ export function MapView({
|
|||||||
tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
||||||
fitKey = 0,
|
fitKey = 0,
|
||||||
dayOrderMap = {},
|
dayOrderMap = {},
|
||||||
|
leftWidth = 0,
|
||||||
|
rightWidth = 0,
|
||||||
|
hasInspector = false,
|
||||||
}) {
|
}) {
|
||||||
|
// Dynamic padding: account for sidebars + bottom inspector
|
||||||
|
const paddingOpts = useMemo(() => {
|
||||||
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||||
|
if (isMobile) return { padding: [40, 20] }
|
||||||
|
const top = 60
|
||||||
|
const bottom = hasInspector ? 320 : 60
|
||||||
|
const left = leftWidth + 40
|
||||||
|
const right = rightWidth + 40
|
||||||
|
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
|
||||||
|
}, [leftWidth, rightWidth, hasInspector])
|
||||||
const [photoUrls, setPhotoUrls] = useState({})
|
const [photoUrls, setPhotoUrls] = useState({})
|
||||||
|
|
||||||
// Fetch Google photos for places that have google_place_id but no image_url
|
// Fetch Google photos for places that have google_place_id but no image_url
|
||||||
@@ -195,8 +224,8 @@ export function MapView({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<MapController center={center} zoom={zoom} />
|
<MapController center={center} zoom={zoom} />
|
||||||
<BoundsController places={places} fitKey={fitKey} />
|
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} />
|
||||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} />
|
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||||
<MapClickHandler onClick={onMapClick} />
|
<MapClickHandler onClick={onMapClick} />
|
||||||
|
|
||||||
<MarkerClusterGroup
|
<MarkerClusterGroup
|
||||||
@@ -206,6 +235,7 @@ export function MapView({
|
|||||||
spiderfyOnMaxZoom
|
spiderfyOnMaxZoom
|
||||||
showCoverageOnHover={false}
|
showCoverageOnHover={false}
|
||||||
zoomToBoundsOnClick
|
zoomToBoundsOnClick
|
||||||
|
singleMarkerMode
|
||||||
iconCreateFunction={(cluster) => {
|
iconCreateFunction={(cluster) => {
|
||||||
const count = cluster.getChildCount()
|
const count = cluster.getChildCount()
|
||||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
||||||
@@ -222,8 +252,8 @@ export function MapView({
|
|||||||
{places.map((place) => {
|
{places.map((place) => {
|
||||||
const isSelected = place.id === selectedPlaceId
|
const isSelected = place.id === selectedPlaceId
|
||||||
const resolvedPhotoUrl = place.image_url || (place.google_place_id && photoUrls[place.google_place_id]) || null
|
const resolvedPhotoUrl = place.image_url || (place.google_place_id && photoUrls[place.google_place_id]) || null
|
||||||
const orderNumber = dayOrderMap[place.id] ?? null
|
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumber, isSelected)
|
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Marker
|
<Marker
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
|||||||
*/
|
*/
|
||||||
export async function calculateRoute(waypoints, profile = 'driving') {
|
export async function calculateRoute(waypoints, profile = 'driving') {
|
||||||
if (!waypoints || waypoints.length < 2) {
|
if (!waypoints || waypoints.length < 2) {
|
||||||
throw new Error('Mindestens 2 Wegpunkte erforderlich')
|
throw new Error('At least 2 waypoints required')
|
||||||
}
|
}
|
||||||
|
|
||||||
const coords = waypoints.map(p => `${p.lng},${p.lat}`).join(';')
|
const coords = waypoints.map(p => `${p.lng},${p.lat}`).join(';')
|
||||||
@@ -18,13 +18,13 @@ export async function calculateRoute(waypoints, profile = 'driving') {
|
|||||||
|
|
||||||
const response = await fetch(url)
|
const response = await fetch(url)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Route konnte nicht berechnet werden')
|
throw new Error('Route could not be calculated')
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
|
if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
|
||||||
throw new Error('Keine Route gefunden')
|
throw new Error('No route found')
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = data.routes[0]
|
const route = data.routes[0]
|
||||||
@@ -74,20 +74,23 @@ export function optimizeRoute(places) {
|
|||||||
const visited = new Set()
|
const visited = new Set()
|
||||||
const result = []
|
const result = []
|
||||||
let current = valid[0]
|
let current = valid[0]
|
||||||
visited.add(current.id)
|
visited.add(0)
|
||||||
result.push(current)
|
result.push(current)
|
||||||
|
|
||||||
while (result.length < valid.length) {
|
while (result.length < valid.length) {
|
||||||
let nearest = null
|
let nearestIdx = -1
|
||||||
let minDist = Infinity
|
let minDist = Infinity
|
||||||
for (const place of valid) {
|
for (let i = 0; i < valid.length; i++) {
|
||||||
if (visited.has(place.id)) continue
|
if (visited.has(i)) continue
|
||||||
const d = Math.sqrt(
|
const d = Math.sqrt(
|
||||||
Math.pow(place.lat - current.lat, 2) + Math.pow(place.lng - current.lng, 2)
|
Math.pow(valid[i].lat - current.lat, 2) + Math.pow(valid[i].lng - current.lng, 2)
|
||||||
)
|
)
|
||||||
if (d < minDist) { minDist = d; nearest = place }
|
if (d < minDist) { minDist = d; nearestIdx = i }
|
||||||
}
|
}
|
||||||
if (nearest) { visited.add(nearest.id); result.push(nearest); current = nearest }
|
if (nearestIdx === -1) break
|
||||||
|
visited.add(nearestIdx)
|
||||||
|
current = valid[nearestIdx]
|
||||||
|
result.push(current)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -103,7 +106,7 @@ function formatDuration(seconds) {
|
|||||||
const h = Math.floor(seconds / 3600)
|
const h = Math.floor(seconds / 3600)
|
||||||
const m = Math.floor((seconds % 3600) / 60)
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
if (h > 0) {
|
if (h > 0) {
|
||||||
return `${h} Std. ${m} Min.`
|
return `${h} h ${m} min`
|
||||||
}
|
}
|
||||||
return `${m} Min.`
|
return `${m} min`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>` : ''}
|
||||||
@@ -141,9 +144,6 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const googleImg = photoMap[place.id] || null
|
const googleImg = photoMap[place.id] || null
|
||||||
const img = directImg || googleImg
|
const img = directImg || googleImg
|
||||||
|
|
||||||
const confirmed = place.reservation_status === 'confirmed'
|
|
||||||
const pending = place.reservation_status === 'pending'
|
|
||||||
|
|
||||||
const iconSvg = categoryIconSvg(cat?.icon, color, 24)
|
const iconSvg = categoryIconSvg(cat?.icon, color, 24)
|
||||||
const thumbHtml = img
|
const thumbHtml = img
|
||||||
? `<img class="place-thumb" src="${escHtml(img)}" />`
|
? `<img class="place-thumb" src="${escHtml(img)}" />`
|
||||||
@@ -154,8 +154,6 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const chips = [
|
const chips = [
|
||||||
place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '',
|
place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '',
|
||||||
place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString('de-DE')} EUR</span>` : '',
|
place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString('de-DE')} EUR</span>` : '',
|
||||||
confirmed ? `<span class="chip chip-green">${svgCheck}${escHtml(tr('reservations.confirmed'))}</span>` : '',
|
|
||||||
pending ? `<span class="chip chip-amber">${svgClock2}${escHtml(tr('reservations.pending'))}</span>` : '',
|
|
||||||
].filter(Boolean).join('')
|
].filter(Boolean).join('')
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -200,6 +198,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 +231,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,17 +331,23 @@ 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>`
|
||||||
: `<div class="cover-circle-ph"></div>`}
|
: `<div class="cover-circle-ph"></div>`}
|
||||||
<div class="cover-label">${escHtml(tr('pdf.travelPlan'))}</div>
|
<div class="cover-label">${escHtml(tr('pdf.travelPlan'))}</div>
|
||||||
<div class="cover-title">${escHtml(trip?.title || 'Meine Reise')}</div>
|
<div class="cover-title">${escHtml(trip?.title || 'My Trip')}</div>
|
||||||
${trip?.description ? `<div class="cover-desc">${escHtml(trip.description)}</div>` : ''}
|
${trip?.description ? `<div class="cover-desc">${escHtml(trip.description)}</div>` : ''}
|
||||||
${range ? `<div class="cover-dates">${range}</div>` : ''}
|
${range ? `<div class="cover-dates">${range}</div>` : ''}
|
||||||
<div class="cover-line"></div>
|
<div class="cover-line"></div>
|
||||||
|
|||||||
@@ -8,36 +8,36 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const VORSCHLAEGE = [
|
const VORSCHLAEGE = [
|
||||||
{ name: 'Reisepass', kategorie: 'Dokumente' },
|
{ name: 'Passport', category: 'Documents' },
|
||||||
{ name: 'Reiseversicherung', kategorie: 'Dokumente' },
|
{ name: 'Travel Insurance', category: 'Documents' },
|
||||||
{ name: 'Visum-Unterlagen', kategorie: 'Dokumente' },
|
{ name: 'Visa Documents', category: 'Documents' },
|
||||||
{ name: 'Flugtickets', kategorie: 'Dokumente' },
|
{ name: 'Flight Tickets', category: 'Documents' },
|
||||||
{ name: 'Hotelbuchungen', kategorie: 'Dokumente' },
|
{ name: 'Hotel Bookings', category: 'Documents' },
|
||||||
{ name: 'Impfpass', kategorie: 'Dokumente' },
|
{ name: 'Vaccination Card', category: 'Documents' },
|
||||||
{ name: 'T-Shirts (5×)', kategorie: 'Kleidung' },
|
{ name: 'T-Shirts (5x)', category: 'Clothing' },
|
||||||
{ name: 'Hosen (2×)', kategorie: 'Kleidung' },
|
{ name: 'Pants (2x)', category: 'Clothing' },
|
||||||
{ name: 'Unterwäsche (7×)', kategorie: 'Kleidung' },
|
{ name: 'Underwear (7x)', category: 'Clothing' },
|
||||||
{ name: 'Socken (7×)', kategorie: 'Kleidung' },
|
{ name: 'Socks (7x)', category: 'Clothing' },
|
||||||
{ name: 'Jacke', kategorie: 'Kleidung' },
|
{ name: 'Jacket', category: 'Clothing' },
|
||||||
{ name: 'Badeanzug / Badehose', kategorie: 'Kleidung' },
|
{ name: 'Swimwear', category: 'Clothing' },
|
||||||
{ name: 'Sportschuhe', kategorie: 'Kleidung' },
|
{ name: 'Sport Shoes', category: 'Clothing' },
|
||||||
{ name: 'Zahnbürste', kategorie: 'Körperpflege' },
|
{ name: 'Toothbrush', category: 'Toiletries' },
|
||||||
{ name: 'Zahnpasta', kategorie: 'Körperpflege' },
|
{ name: 'Toothpaste', category: 'Toiletries' },
|
||||||
{ name: 'Shampoo', kategorie: 'Körperpflege' },
|
{ name: 'Shampoo', category: 'Toiletries' },
|
||||||
{ name: 'Sonnencreme', kategorie: 'Körperpflege' },
|
{ name: 'Sunscreen', category: 'Toiletries' },
|
||||||
{ name: 'Deo', kategorie: 'Körperpflege' },
|
{ name: 'Deodorant', category: 'Toiletries' },
|
||||||
{ name: 'Rasierer', kategorie: 'Körperpflege' },
|
{ name: 'Razor', category: 'Toiletries' },
|
||||||
{ name: 'Ladekabel Handy', kategorie: 'Elektronik' },
|
{ name: 'Phone Charger', category: 'Electronics' },
|
||||||
{ name: 'Reiseadapter', kategorie: 'Elektronik' },
|
{ name: 'Travel Adapter', category: 'Electronics' },
|
||||||
{ name: 'Kopfhörer', kategorie: 'Elektronik' },
|
{ name: 'Headphones', category: 'Electronics' },
|
||||||
{ name: 'Kamera', kategorie: 'Elektronik' },
|
{ name: 'Camera', category: 'Electronics' },
|
||||||
{ name: 'Powerbank', kategorie: 'Elektronik' },
|
{ name: 'Power Bank', category: 'Electronics' },
|
||||||
{ name: 'Erste-Hilfe-Set', kategorie: 'Gesundheit' },
|
{ name: 'First Aid Kit', category: 'Health' },
|
||||||
{ name: 'Verschreibungspflichtige Medikamente', kategorie: 'Gesundheit' },
|
{ name: 'Prescription Medication', category: 'Health' },
|
||||||
{ name: 'Schmerzmittel', kategorie: 'Gesundheit' },
|
{ name: 'Pain Medication', category: 'Health' },
|
||||||
{ name: 'Mückenschutz', kategorie: 'Gesundheit' },
|
{ name: 'Insect Repellent', category: 'Health' },
|
||||||
{ name: 'Bargeld', kategorie: 'Finanzen' },
|
{ name: 'Cash', category: 'Finances' },
|
||||||
{ name: 'Kreditkarte', kategorie: 'Finanzen' },
|
{ name: 'Credit Card', category: 'Finances' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Cycling color palette — works in light & dark mode
|
// Cycling color palette — works in light & dark mode
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { PhotoLightbox } from './PhotoLightbox'
|
|||||||
import { PhotoUpload } from './PhotoUpload'
|
import { PhotoUpload } from './PhotoUpload'
|
||||||
import { Upload, Camera } from 'lucide-react'
|
import { Upload, Camera } from 'lucide-react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }) {
|
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [lightboxIndex, setLightboxIndex] = useState(null)
|
const [lightboxIndex, setLightboxIndex] = useState(null)
|
||||||
const [showUpload, setShowUpload] = useState(false)
|
const [showUpload, setShowUpload] = useState(false)
|
||||||
const [filterDayId, setFilterDayId] = useState('')
|
const [filterDayId, setFilterDayId] = useState('')
|
||||||
@@ -49,7 +51,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
|||||||
onChange={e => setFilterDayId(e.target.value)}
|
onChange={e => setFilterDayId(e.target.value)}
|
||||||
className="border border-gray-200 rounded-lg px-3 py-1.5 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-slate-900"
|
className="border border-gray-200 rounded-lg px-3 py-1.5 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-slate-900"
|
||||||
>
|
>
|
||||||
<option value="">Alle Tage</option>
|
<option value="">{t('photos.allDays')}</option>
|
||||||
{(days || []).map(day => (
|
{(days || []).map(day => (
|
||||||
<option key={day.id} value={day.id}>
|
<option key={day.id} value={day.id}>
|
||||||
Tag {day.day_number}{day.date ? ` · ${formatDate(day.date)}` : ''}
|
Tag {day.day_number}{day.date ? ` · ${formatDate(day.date)}` : ''}
|
||||||
@@ -62,7 +64,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
|||||||
onClick={() => setFilterDayId('')}
|
onClick={() => setFilterDayId('')}
|
||||||
className="text-xs text-gray-500 hover:text-gray-700 underline"
|
className="text-xs text-gray-500 hover:text-gray-700 underline"
|
||||||
>
|
>
|
||||||
Zurücksetzen
|
{t('common.reset')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -80,8 +82,8 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
|||||||
{filteredPhotos.length === 0 ? (
|
{filteredPhotos.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: '#9ca3af' }}>
|
<div style={{ textAlign: 'center', padding: '60px 20px', color: '#9ca3af' }}>
|
||||||
<Camera size={40} style={{ color: '#d1d5db', display: 'block', margin: '0 auto 12px' }} />
|
<Camera size={40} style={{ color: '#d1d5db', display: 'block', margin: '0 auto 12px' }} />
|
||||||
<p style={{ fontSize: 14, fontWeight: 600, color: '#374151', margin: '0 0 4px' }}>Noch keine Fotos</p>
|
<p style={{ fontSize: 14, fontWeight: 600, color: '#374151', margin: '0 0 4px' }}>{t('photos.noPhotos')}</p>
|
||||||
<p style={{ fontSize: 13, color: '#9ca3af', margin: '0 0 20px' }}>Lade deine Reisefotos hoch</p>
|
<p style={{ fontSize: 13, color: '#9ca3af', margin: '0 0 20px' }}>{t('photos.uploadHint')}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowUpload(true)}
|
onClick={() => setShowUpload(true)}
|
||||||
className="flex items-center gap-2 bg-slate-900 text-white px-6 py-3 rounded-xl hover:bg-slate-700 font-medium"
|
className="flex items-center gap-2 bg-slate-900 text-white px-6 py-3 rounded-xl hover:bg-slate-700 font-medium"
|
||||||
@@ -109,7 +111,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
|||||||
className="aspect-square rounded-xl border-2 border-dashed border-gray-200 hover:border-slate-400 flex flex-col items-center justify-center gap-2 text-gray-400 hover:text-slate-700 transition-colors"
|
className="aspect-square rounded-xl border-2 border-dashed border-gray-200 hover:border-slate-400 flex flex-col items-center justify-center gap-2 text-gray-400 hover:text-slate-700 transition-colors"
|
||||||
>
|
>
|
||||||
<Upload className="w-6 h-6" />
|
<Upload className="w-6 h-6" />
|
||||||
<span className="text-xs">Hinzufügen</span>
|
<span className="text-xs">{t('common.add')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { X, ChevronLeft, ChevronRight, Edit2, Trash2, Check } from 'lucide-react'
|
import { X, ChevronLeft, ChevronRight, Edit2, Trash2, Check } from 'lucide-react'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelete, days, places, tripId }) {
|
export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelete, days, places, tripId }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [index, setIndex] = useState(initialIndex || 0)
|
const [index, setIndex] = useState(initialIndex || 0)
|
||||||
const [editCaption, setEditCaption] = useState(false)
|
const [editCaption, setEditCaption] = useState(false)
|
||||||
const [caption, setCaption] = useState('')
|
const [caption, setCaption] = useState('')
|
||||||
@@ -81,7 +83,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
|
|||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className="p-2 text-white/60 hover:text-red-400 hover:bg-white/10 rounded-lg transition-colors"
|
className="p-2 text-white/60 hover:text-red-400 hover:bg-white/10 rounded-lg transition-colors"
|
||||||
title="Löschen"
|
title={t('common.delete')}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React, { useState, useCallback } from 'react'
|
import React, { useState, useCallback } from 'react'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
import { Upload, X, Image } from 'lucide-react'
|
import { Upload, X, Image } from 'lucide-react'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
|
export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [files, setFiles] = useState([])
|
const [files, setFiles] = useState([])
|
||||||
const [dayId, setDayId] = useState('')
|
const [dayId, setDayId] = useState('')
|
||||||
const [placeId, setPlaceId] = useState('')
|
const [placeId, setPlaceId] = useState('')
|
||||||
@@ -78,7 +80,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-gray-600 font-medium">Fotos hier ablegen</p>
|
<p className="text-gray-600 font-medium">Fotos hier ablegen</p>
|
||||||
<p className="text-gray-400 text-sm mt-1">oder klicken zum Auswählen</p>
|
<p className="text-gray-400 text-sm mt-1">{t('photos.clickToSelect')}</p>
|
||||||
<p className="text-gray-400 text-xs mt-2">JPG, PNG, WebP · max. 10 MB · bis zu 30 Fotos</p>
|
<p className="text-gray-400 text-xs mt-2">JPG, PNG, WebP · max. 10 MB · bis zu 30 Fotos</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -128,13 +130,13 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Ort verknüpfen</label>
|
<label className="block text-xs font-medium text-gray-700 mb-1">{t('photos.linkPlace')}</label>
|
||||||
<select
|
<select
|
||||||
value={placeId}
|
value={placeId}
|
||||||
onChange={e => setPlaceId(e.target.value)}
|
onChange={e => setPlaceId(e.target.value)}
|
||||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
|
||||||
>
|
>
|
||||||
<option value="">Kein Ort</option>
|
<option value="">{t('photos.noPlace')}</option>
|
||||||
{(places || []).map(place => (
|
{(places || []).map(place => (
|
||||||
<option key={place.id} value={place.id}>{place.name}</option>
|
<option key={place.id} value={place.id}>{place.name}</option>
|
||||||
))}
|
))}
|
||||||
@@ -175,7 +177,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50"
|
className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
Abbrechen
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleUpload}
|
onClick={handleUpload}
|
||||||
@@ -183,7 +185,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
|
|||||||
className="flex items-center gap-2 px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
|
className="flex items-center gap-2 px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
|
||||||
>
|
>
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
{uploading ? 'Hochladen...' : `${files.length} Foto${files.length !== 1 ? 's' : ''} hochladen`}
|
{uploading ? t('common.uploading') : t('photos.uploadN', { n: files.length })}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useSortable } from '@dnd-kit/sortable'
|
import { useSortable } from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { GripVertical, X, Edit2, Clock, DollarSign, CheckCircle, Clock3, MapPin } from 'lucide-react'
|
import { GripVertical, X, Edit2, Clock, DollarSign, MapPin } from 'lucide-react'
|
||||||
|
|
||||||
export default function AssignedPlaceItem({ assignment, dayId, onRemove, onEdit }) {
|
export default function AssignedPlaceItem({ assignment, dayId, onRemove, onEdit }) {
|
||||||
const { place } = assignment
|
const { place } = assignment
|
||||||
@@ -27,16 +27,6 @@ export default function AssignedPlaceItem({ assignment, dayId, onRemove, onEdit
|
|||||||
transition,
|
transition,
|
||||||
}
|
}
|
||||||
|
|
||||||
const reservationIcon = () => {
|
|
||||||
if (place.reservation_status === 'confirmed') {
|
|
||||||
return <CheckCircle className="w-3.5 h-3.5 text-emerald-500" title="Confirmed" />
|
|
||||||
}
|
|
||||||
if (place.reservation_status === 'pending') {
|
|
||||||
return <Clock3 className="w-3.5 h-3.5 text-amber-500" title="Pending" />
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
@@ -71,7 +61,6 @@ export default function AssignedPlaceItem({ assignment, dayId, onRemove, onEdit
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium text-slate-800 truncate">{place.name}</span>
|
<span className="text-sm font-medium text-slate-800 truncate">{place.name}</span>
|
||||||
{reservationIcon()}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time & price row */}
|
{/* Time & price row */}
|
||||||
|
|||||||
@@ -0,0 +1,537 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind, Droplets, Sunrise, Sunset, Hotel, Calendar, MapPin, LogIn, LogOut, Hash, Pencil, Plane, Utensils, Train, Car, Ship, Ticket, FileText, Users } from 'lucide-react'
|
||||||
|
|
||||||
|
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||||
|
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
|
||||||
|
import { weatherApi, accommodationsApi } from '../../api/client'
|
||||||
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
|
const WEATHER_ICON_MAP = {
|
||||||
|
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
|
||||||
|
Thunderstorm: CloudLightning, Snow: CloudSnow, Mist: Wind, Fog: Wind, Haze: Wind,
|
||||||
|
}
|
||||||
|
|
||||||
|
function WIcon({ main, size = 14 }) {
|
||||||
|
const Icon = WEATHER_ICON_MAP[main] || Cloud
|
||||||
|
return <Icon size={size} strokeWidth={1.8} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function cTemp(c, f) { return Math.round(f ? c * 9 / 5 + 32 : c) }
|
||||||
|
|
||||||
|
function formatTime12(val, is12h) {
|
||||||
|
if (!val) return val
|
||||||
|
const [h, m] = val.split(':').map(Number)
|
||||||
|
if (isNaN(h) || isNaN(m)) return val
|
||||||
|
if (!is12h) return val
|
||||||
|
const period = h >= 12 ? 'PM' : 'AM'
|
||||||
|
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||||
|
return `${h12}:${String(m).padStart(2, '0')} ${period}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange }) {
|
||||||
|
const { t, language } = useTranslation()
|
||||||
|
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||||
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
|
const fmtTime = (v) => formatTime12(v, is12h)
|
||||||
|
const unit = isFahrenheit ? '°F' : '°C'
|
||||||
|
const [weather, setWeather] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [accommodation, setAccommodation] = useState(null)
|
||||||
|
const [accommodations, setAccommodations] = useState([])
|
||||||
|
const [showHotelPicker, setShowHotelPicker] = useState(false)
|
||||||
|
const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id })
|
||||||
|
const [hotelCategoryFilter, setHotelCategoryFilter] = useState('')
|
||||||
|
const [hotelForm, setHotelForm] = useState({ check_in: '', check_out: '', confirmation: '' })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!day?.date || !lat || !lng) { setWeather(null); return }
|
||||||
|
setLoading(true)
|
||||||
|
weatherApi.getDetailed(lat, lng, day.date, language)
|
||||||
|
.then(data => setWeather(data.error ? null : data))
|
||||||
|
.catch(() => setWeather(null))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [day?.date, lat, lng, language])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tripId) return
|
||||||
|
accommodationsApi.list(tripId)
|
||||||
|
.then(data => {
|
||||||
|
setAccommodations(data.accommodations || [])
|
||||||
|
const acc = (data.accommodations || []).find(a =>
|
||||||
|
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||||
|
)
|
||||||
|
setAccommodation(acc || null)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [tripId, day?.id])
|
||||||
|
|
||||||
|
useEffect(() => { if (day) setHotelDayRange({ start: day.id, end: day.id }) }, [day?.id])
|
||||||
|
|
||||||
|
const handleSetAccommodation = async (placeId) => {
|
||||||
|
try {
|
||||||
|
const data = await accommodationsApi.create(tripId, {
|
||||||
|
place_id: placeId,
|
||||||
|
start_day_id: hotelDayRange.start,
|
||||||
|
end_day_id: hotelDayRange.end,
|
||||||
|
check_in: hotelForm.check_in || null,
|
||||||
|
check_out: hotelForm.check_out || null,
|
||||||
|
confirmation: hotelForm.confirmation || null,
|
||||||
|
})
|
||||||
|
setAccommodation(data.accommodation)
|
||||||
|
setAccommodations(prev => [...prev, data.accommodation])
|
||||||
|
setShowHotelPicker(false)
|
||||||
|
setHotelForm({ check_in: '', check_out: '', confirmation: '' })
|
||||||
|
onAccommodationChange?.()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAccommodationField = async (field, value) => {
|
||||||
|
if (!accommodation) return
|
||||||
|
try {
|
||||||
|
const data = await accommodationsApi.update(tripId, accommodation.id, { [field]: value || null })
|
||||||
|
setAccommodation(data.accommodation)
|
||||||
|
onAccommodationChange?.()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveAccommodation = async () => {
|
||||||
|
if (!accommodation) return
|
||||||
|
try {
|
||||||
|
await accommodationsApi.delete(tripId, accommodation.id)
|
||||||
|
setAccommodations(prev => prev.filter(a => a.id !== accommodation.id))
|
||||||
|
setAccommodation(null)
|
||||||
|
onAccommodationChange?.()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!day) return null
|
||||||
|
|
||||||
|
const formattedDate = day.date ? new Date(day.date + 'T00:00:00').toLocaleDateString(
|
||||||
|
language === 'de' ? 'de-DE' : 'en-US',
|
||||||
|
{ weekday: 'long', day: 'numeric', month: 'long' }
|
||||||
|
) : null
|
||||||
|
|
||||||
|
const placesWithCoords = places.filter(p => p.lat && p.lng)
|
||||||
|
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', bottom: 20, left: '50%', transform: 'translateX(-50%)', width: 'min(800px, calc(100vw - 32px))', zIndex: 50, ...font }}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-elevated)',
|
||||||
|
backdropFilter: 'blur(40px) saturate(180%)',
|
||||||
|
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
|
||||||
|
borderRadius: 20,
|
||||||
|
boxShadow: '0 8px 40px rgba(0,0,0,0.14), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||||
|
overflow: 'hidden', maxHeight: '60vh', display: 'flex', flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '18px 16px 14px 20px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||||
|
<div style={{ width: 44, height: 44, borderRadius: 12, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<Calendar size={20} style={{ color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
|
||||||
|
</div>
|
||||||
|
{formattedDate && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 1 }}>{formattedDate}</div>}
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} style={{ background: 'var(--bg-secondary)', border: 'none', borderRadius: 10, width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0 }}>
|
||||||
|
<X size={14} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<div style={{ overflowY: 'auto', padding: '14px 20px 18px' }}>
|
||||||
|
|
||||||
|
{/* ── Weather ── */}
|
||||||
|
{day.date && lat && lng && (
|
||||||
|
loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 16, color: 'var(--text-faint)', fontSize: 12 }}>
|
||||||
|
<div style={{ width: 18, height: 18, border: '2px solid var(--border-primary)', borderTopColor: 'var(--text-primary)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto 6px' }} />
|
||||||
|
</div>
|
||||||
|
) : weather ? (
|
||||||
|
<div>
|
||||||
|
{/* Summary row */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 12, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<WIcon main={weather.main} size={20} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'baseline', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1 }}>
|
||||||
|
{weather.type === 'climate' ? 'Ø ' : ''}{cTemp(weather.temp, isFahrenheit)}{unit}
|
||||||
|
</span>
|
||||||
|
{weather.temp_max != null && (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-faint)' }}>
|
||||||
|
{cTemp(weather.temp_min, isFahrenheit)}° / {cTemp(weather.temp_max, isFahrenheit)}°
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{weather.description && (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)', textTransform: 'capitalize' }}>{weather.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chips row */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: weather.hourly ? 10 : 0 }}>
|
||||||
|
{weather.precipitation_probability_max != null && (
|
||||||
|
<Chip icon={Droplets} value={`${weather.precipitation_probability_max}%`} />
|
||||||
|
)}
|
||||||
|
{weather.precipitation_sum > 0 && (
|
||||||
|
<Chip icon={CloudRain} value={`${weather.precipitation_sum.toFixed(1)} mm`} />
|
||||||
|
)}
|
||||||
|
{weather.wind_max != null && (
|
||||||
|
<Chip icon={Wind} value={isFahrenheit ? `${Math.round(weather.wind_max * 0.621371)} mph` : `${Math.round(weather.wind_max)} km/h`} />
|
||||||
|
)}
|
||||||
|
{weather.sunrise && <Chip icon={Sunrise} value={weather.sunrise} />}
|
||||||
|
{weather.sunset && <Chip icon={Sunset} value={weather.sunset} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hourly scroll */}
|
||||||
|
{weather.hourly?.length > 0 && (
|
||||||
|
<div style={{ overflowX: 'auto', margin: '0 -6px', padding: '0 6px 4px' }}>
|
||||||
|
<div style={{ display: 'inline-flex', gap: 2 }}>
|
||||||
|
{weather.hourly.filter((_, i) => i % 2 === 0).map(h => (
|
||||||
|
<div key={h.hour} style={{
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3,
|
||||||
|
width: 44, padding: '5px 2px', borderRadius: 8,
|
||||||
|
background: h.precipitation_probability > 50 ? 'rgba(59,130,246,0.07)' : 'transparent',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500 }}>{String(h.hour).padStart(2, '0')}</span>
|
||||||
|
<WIcon main={h.main} size={12} />
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-primary)' }}>{cTemp(h.temp, isFahrenheit)}°</span>
|
||||||
|
{h.precipitation_probability > 0 && (
|
||||||
|
<span style={{ fontSize: 8, color: '#3b82f6', fontWeight: 500 }}>{h.precipitation_probability}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{weather.type === 'climate' && (
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 6, fontStyle: 'italic' }}>{t('day.climateHint')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-faint)', textAlign: 'center', padding: 8 }}>{t('day.noWeather')}</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
{day.date && lat && lng && <div style={{ height: 1, background: 'var(--border-faint)', margin: '12px 0' }} />}
|
||||||
|
|
||||||
|
{/* ── Reservations for this day's assignments ── */}
|
||||||
|
{(() => {
|
||||||
|
const dayAssignments = assignments[String(day.id)] || []
|
||||||
|
const dayReservations = reservations.filter(r => dayAssignments.some(a => a.id === r.assignment_id))
|
||||||
|
if (dayReservations.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 0 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>{t('day.reservations')}</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{dayReservations.map(r => {
|
||||||
|
const linkedAssignment = dayAssignments.find(a => a.id === r.assignment_id)
|
||||||
|
const confirmed = r.status === 'confirmed'
|
||||||
|
return (
|
||||||
|
<div key={r.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', borderRadius: 8, background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.15)' : 'rgba(217,119,6,0.15)'}` }}>
|
||||||
|
{(() => { const TIcon = RES_TYPE_ICONS[r.type] || FileText; return <TIcon size={12} style={{ color: RES_TYPE_COLORS[r.type] || 'var(--text-faint)', flexShrink: 0 }} /> })()}
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 6, overflow: 'hidden' }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
|
||||||
|
{linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
|
||||||
|
</div>
|
||||||
|
{r.reservation_time && (
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
|
{new Date(r.reservation_time).toLocaleTimeString(language === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit', hour12: is12h })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Divider before accommodation */}
|
||||||
|
<div style={{ height: 1, background: 'var(--border-faint)', margin: '12px 0' }} />
|
||||||
|
|
||||||
|
{/* ── Accommodation ── */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>{t('day.accommodation')}</div>
|
||||||
|
|
||||||
|
{accommodation ? (
|
||||||
|
<div style={{ borderRadius: 12, background: 'var(--bg-secondary)', overflow: 'hidden' }}>
|
||||||
|
{/* Hotel header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px' }}>
|
||||||
|
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
{accommodation.place_image ? (
|
||||||
|
<img src={accommodation.place_image} style={{ width: '100%', height: '100%', borderRadius: 10, objectFit: 'cover' }} />
|
||||||
|
) : (
|
||||||
|
<Hotel size={16} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{accommodation.place_name}</div>
|
||||||
|
{accommodation.place_address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{accommodation.place_address}</div>}
|
||||||
|
</div>
|
||||||
|
<button onClick={handleRemoveAccommodation} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
||||||
|
<X size={12} style={{ color: 'var(--text-faint)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Details row */}
|
||||||
|
{/* Details grid */}
|
||||||
|
<div style={{ display: 'flex', gap: 0, margin: '0 12px 10px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}>
|
||||||
|
{accommodation.check_in && (
|
||||||
|
<div style={{ flex: 1, padding: '8px 10px', borderRight: '1px solid var(--border-faint)', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{fmtTime(accommodation.check_in)}</div>
|
||||||
|
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
||||||
|
<LogIn size={8} /> {t('day.checkIn')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{accommodation.check_out && (
|
||||||
|
<div style={{ flex: 1, padding: '8px 10px', borderRight: accommodation.confirmation ? '1px solid var(--border-faint)' : 'none', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{fmtTime(accommodation.check_out)}</div>
|
||||||
|
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
||||||
|
<LogOut size={8} /> {t('day.checkOut')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{accommodation.confirmation && (
|
||||||
|
<div style={{ flex: 1, padding: '8px 10px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{accommodation.confirmation}</div>
|
||||||
|
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
||||||
|
<Hash size={8} /> {t('day.confirmation')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button onClick={() => { setHotelForm({ check_in: accommodation.check_in || '', check_out: accommodation.check_out || '', confirmation: accommodation.confirmation || '' }); setShowHotelPicker('edit') }}
|
||||||
|
style={{ padding: '0 8px', background: 'none', border: 'none', borderLeft: '1px solid var(--border-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Pencil size={10} style={{ color: 'var(--text-faint)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setShowHotelPicker(true)} style={{
|
||||||
|
width: '100%', padding: 10, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
|
||||||
|
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
|
fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Hotel size={12} /> {t('day.addAccommodation')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
|
||||||
|
{showHotelPicker && ReactDOM.createPortal(
|
||||||
|
<div style={{ position: 'fixed', inset: 0, zIndex: 99999, background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||||
|
onClick={() => setShowHotelPicker(false)}>
|
||||||
|
<div onClick={e => e.stopPropagation()} style={{
|
||||||
|
width: '100%', maxWidth: 900, borderRadius: 16, overflow: 'hidden',
|
||||||
|
background: 'var(--bg-card)', boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||||
|
...font,
|
||||||
|
}}>
|
||||||
|
{/* Popup Header */}
|
||||||
|
<div style={{ padding: '16px 18px 12px', borderBottom: '1px solid var(--border-faint)', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<Hotel size={16} style={{ color: 'var(--text-primary)' }} />
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', flex: 1 }}>{showHotelPicker === 'edit' ? t('day.editAccommodation') : t('day.addAccommodation')}</span>
|
||||||
|
<button onClick={() => setShowHotelPicker(false)} style={{ background: 'var(--bg-secondary)', border: 'none', borderRadius: 8, width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
|
||||||
|
<X size={12} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day Range (hidden in edit mode) */}
|
||||||
|
{showHotelPicker !== 'edit' && <div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border-faint)', background: 'var(--bg-secondary)' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{t('day.hotelDayRange')}</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<CustomSelect
|
||||||
|
value={hotelDayRange.start}
|
||||||
|
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
|
||||||
|
options={days.map((d, i) => ({
|
||||||
|
value: d.id,
|
||||||
|
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short' })}` : ''}`,
|
||||||
|
}))}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>→</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<CustomSelect
|
||||||
|
value={hotelDayRange.end}
|
||||||
|
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
|
||||||
|
options={days.map((d, i) => ({
|
||||||
|
value: d.id,
|
||||||
|
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short' })}` : ''}`,
|
||||||
|
}))}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setHotelDayRange({ start: days[0]?.id, end: days[days.length - 1]?.id })} style={{
|
||||||
|
padding: '6px 14px', borderRadius: 8, border: 'none', fontSize: 11, fontWeight: 600, cursor: 'pointer', flexShrink: 0,
|
||||||
|
background: hotelDayRange.start === days[0]?.id && hotelDayRange.end === days[days.length - 1]?.id ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||||
|
color: hotelDayRange.start === days[0]?.id && hotelDayRange.end === days[days.length - 1]?.id ? 'var(--bg-card)' : 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
{t('day.allDays')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{/* Check-in / Check-out / Confirmation */}
|
||||||
|
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 100 }}>
|
||||||
|
<label style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.checkIn')}</label>
|
||||||
|
<CustomTimePicker value={hotelForm.check_in} onChange={v => setHotelForm(f => ({ ...f, check_in: v }))} placeholder="14:00" />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 100 }}>
|
||||||
|
<label style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.checkOut')}</label>
|
||||||
|
<CustomTimePicker value={hotelForm.check_out} onChange={v => setHotelForm(f => ({ ...f, check_out: v }))} placeholder="11:00" />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 2, minWidth: 120 }}>
|
||||||
|
<label style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.confirmation')}</label>
|
||||||
|
<input type="text" value={hotelForm.confirmation} onChange={e => setHotelForm(f => ({ ...f, confirmation: e.target.value }))}
|
||||||
|
placeholder="ABC-12345" style={{ width: '100%', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)', fontSize: 13, fontFamily: 'inherit', boxSizing: 'border-box', height: 38 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit mode: save button instead of place list */}
|
||||||
|
{showHotelPicker === 'edit' ? (
|
||||||
|
<div style={{ padding: '14px 18px', display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<button onClick={async () => {
|
||||||
|
await updateAccommodationField('check_in', hotelForm.check_in)
|
||||||
|
await updateAccommodationField('check_out', hotelForm.check_out)
|
||||||
|
await updateAccommodationField('confirmation', hotelForm.confirmation)
|
||||||
|
setShowHotelPicker(false)
|
||||||
|
}} style={{
|
||||||
|
padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||||
|
background: 'var(--text-primary)', color: 'var(--bg-card)',
|
||||||
|
}}>
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : <>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<div style={{ padding: '8px 18px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
<button onClick={() => setHotelCategoryFilter('')} style={{
|
||||||
|
padding: '3px 10px', borderRadius: 6, border: 'none', fontSize: 10, fontWeight: 600, cursor: 'pointer',
|
||||||
|
background: !hotelCategoryFilter ? 'var(--text-primary)' : 'var(--bg-secondary)',
|
||||||
|
color: !hotelCategoryFilter ? 'var(--bg-card)' : 'var(--text-muted)',
|
||||||
|
}}>{t('day.allDays')}</button>
|
||||||
|
|
||||||
|
{categories.map(c => (
|
||||||
|
<button key={c.id} onClick={() => setHotelCategoryFilter(c.id)} style={{
|
||||||
|
padding: '3px 10px', borderRadius: 6, border: 'none', fontSize: 10, fontWeight: 600, cursor: 'pointer',
|
||||||
|
background: hotelCategoryFilter === c.id ? c.color || 'var(--text-primary)' : 'var(--bg-secondary)',
|
||||||
|
color: hotelCategoryFilter === c.id ? '#fff' : 'var(--text-muted)',
|
||||||
|
}}>{c.name}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Place List */}
|
||||||
|
<div style={{ maxHeight: 250, overflowY: 'auto' }}>
|
||||||
|
{(() => {
|
||||||
|
const filtered = hotelCategoryFilter ? places.filter(p => p.category_id === hotelCategoryFilter) : places
|
||||||
|
return filtered.length === 0 ? (
|
||||||
|
<div style={{ padding: 20, textAlign: 'center', fontSize: 12, color: 'var(--text-faint)' }}>{t('day.noPlacesForHotel')}</div>
|
||||||
|
) : filtered.map(p => (
|
||||||
|
<button key={p.id} onClick={() => handleSetAccommodation(p.id)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '10px 18px',
|
||||||
|
border: 'none', borderBottom: '1px solid var(--border-faint)', background: 'none',
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||||
|
>
|
||||||
|
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
{p.image_url ? (
|
||||||
|
<img src={p.image_url} style={{ width: '100%', height: '100%', borderRadius: 8, objectFit: 'cover' }} />
|
||||||
|
) : (
|
||||||
|
<MapPin size={13} style={{ color: 'var(--text-faint)' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</div>
|
||||||
|
{p.address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.address}</div>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chip({ icon: Icon, value }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
<Icon size={11} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||||
|
<span style={{ fontWeight: 500 }}>{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoChip({ icon: Icon, label, value, placeholder, onEdit, type }) {
|
||||||
|
const [editing, setEditing] = React.useState(false)
|
||||||
|
const [val, setVal] = React.useState(value || '')
|
||||||
|
const inputRef = React.useRef(null)
|
||||||
|
|
||||||
|
React.useEffect(() => { setVal(value || '') }, [value])
|
||||||
|
React.useEffect(() => { if (editing && inputRef.current) inputRef.current.focus() }, [editing])
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
setEditing(false)
|
||||||
|
if (val !== (value || '')) onEdit(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
|
||||||
|
cursor: 'pointer', minWidth: 0, flex: type === 'text' ? 1 : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 8, color: 'var(--text-faint)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', lineHeight: 1 }}>{label}</div>
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type={type}
|
||||||
|
value={val}
|
||||||
|
onChange={e => setVal(e.target.value)}
|
||||||
|
onBlur={save}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setVal(value || ''); setEditing(false) } }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
border: 'none', outline: 'none', background: 'none', padding: 0, margin: 0,
|
||||||
|
fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', fontFamily: 'inherit',
|
||||||
|
width: type === 'time' ? 50 : '100%', lineHeight: 1.3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: value ? 'var(--text-primary)' : 'var(--text-faint)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{value || placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
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, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react'
|
||||||
|
|
||||||
|
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||||
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'
|
||||||
@@ -71,32 +73,28 @@ const TYPE_ICONS = {
|
|||||||
export default function DayPlanSidebar({
|
export default function DayPlanSidebar({
|
||||||
tripId,
|
tripId,
|
||||||
trip, days, places, categories, assignments,
|
trip, days, places, categories, assignments,
|
||||||
selectedDayId, selectedPlaceId,
|
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
||||||
onSelectDay, onPlaceClick,
|
onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
|
||||||
onReorder, onUpdateDayTitle, onRouteCalculated,
|
onReorder, onUpdateDayTitle, onRouteCalculated,
|
||||||
onAssignToDay,
|
onAssignToDay,
|
||||||
reservations = [],
|
reservations = [],
|
||||||
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()
|
||||||
|
|
||||||
const TRANSPORT_MODES = [
|
|
||||||
{ value: 'driving', label: t('dayplan.transport.car') },
|
|
||||||
{ value: 'walking', label: t('dayplan.transport.walk') },
|
|
||||||
{ value: 'cycling', label: t('dayplan.transport.bike') },
|
|
||||||
]
|
|
||||||
const dayNotes = tripStore.dayNotes || {}
|
const dayNotes = tripStore.dayNotes || {}
|
||||||
|
|
||||||
const [expandedDays, setExpandedDays] = useState(() => new Set(days.map(d => d.id)))
|
const [expandedDays, setExpandedDays] = useState(() => new Set(days.map(d => d.id)))
|
||||||
const [editingDayId, setEditingDayId] = useState(null)
|
const [editingDayId, setEditingDayId] = useState(null)
|
||||||
const [editTitle, setEditTitle] = useState('')
|
const [editTitle, setEditTitle] = useState('')
|
||||||
const [transportMode, setTransportMode] = useState('driving')
|
|
||||||
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 +203,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
|
||||||
@@ -281,7 +280,7 @@ export default function DayPlanSidebar({
|
|||||||
if (waypoints.length < 2) { toast.error(t('dayplan.toast.needTwoPlaces')); return }
|
if (waypoints.length < 2) { toast.error(t('dayplan.toast.needTwoPlaces')); return }
|
||||||
setIsCalculating(true)
|
setIsCalculating(true)
|
||||||
try {
|
try {
|
||||||
const result = await calculateRoute(waypoints, transportMode)
|
const result = await calculateRoute(waypoints, 'walking')
|
||||||
// Luftlinien zwischen Wegpunkten anzeigen
|
// Luftlinien zwischen Wegpunkten anzeigen
|
||||||
const lineCoords = waypoints.map(p => [p.lat, p.lng])
|
const lineCoords = waypoints.map(p => [p.lat, p.lng])
|
||||||
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
|
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
|
||||||
@@ -290,15 +289,45 @@ 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 assignments (work on assignments, not places)
|
||||||
|
const unlockedWithCoords = unlocked.filter(a => a.place?.lat && a.place?.lng)
|
||||||
|
const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng)
|
||||||
|
const optimizedAssignments = unlockedWithCoords.length >= 2
|
||||||
|
? optimizeRoute(unlockedWithCoords.map(a => ({ ...a.place, _assignmentId: a.id }))).map(p => unlockedWithCoords.find(a => a.id === p._assignmentId)).filter(Boolean)
|
||||||
|
: unlockedWithCoords
|
||||||
|
const optimizedQueue = [...optimizedAssignments, ...unlockedNoCoords]
|
||||||
|
|
||||||
|
// 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'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +444,7 @@ export default function DayPlanSidebar({
|
|||||||
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
||||||
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
||||||
<div
|
<div
|
||||||
onClick={() => onSelectDay(isSelected ? null : day.id)}
|
onClick={() => { onSelectDay(isSelected ? null : day.id); if (onDayDetail) onDayDetail(isSelected ? null : day) }}
|
||||||
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
|
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
|
||||||
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
|
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
|
||||||
onDrop={e => handleDropOnDay(e, day.id)}
|
onDrop={e => handleDropOnDay(e, day.id)}
|
||||||
@@ -430,7 +459,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 */}
|
||||||
@@ -461,8 +490,8 @@ export default function DayPlanSidebar({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
|
||||||
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -471,11 +500,21 @@ export default function DayPlanSidebar({
|
|||||||
>
|
>
|
||||||
<Pencil size={10} strokeWidth={1.8} color="var(--text-secondary)" />
|
<Pencil size={10} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||||
</button>
|
</button>
|
||||||
|
{(() => {
|
||||||
|
const acc = accommodations.find(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
||||||
|
return acc ? (
|
||||||
|
<span onClick={e => { e.stopPropagation(); onPlaceClick(acc.place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)', flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}>
|
||||||
|
<Hotel size={8} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</span>
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2, flexWrap: 'wrap' }}>
|
||||||
{formattedDate && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formattedDate}</span>}
|
{formattedDate && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formattedDate}</span>}
|
||||||
{cost && <span style={{ fontSize: 11, color: '#059669' }}>{cost}</span>}
|
{cost && <span style={{ fontSize: 11, color: '#059669' }}>{cost}</span>}
|
||||||
|
{day.date && anyGeoPlace && <span style={{ width: 1, height: 10, background: 'var(--text-faint)', opacity: 0.3, flexShrink: 0 }} />}
|
||||||
{day.date && anyGeoPlace && (() => {
|
{day.date && anyGeoPlace && (() => {
|
||||||
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
|
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
|
||||||
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
|
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
|
||||||
@@ -504,7 +543,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 +561,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 ? (
|
||||||
@@ -548,9 +587,7 @@ export default function DayPlanSidebar({
|
|||||||
const place = assignment.place
|
const place = assignment.place
|
||||||
if (!place) return null
|
if (!place) return null
|
||||||
const cat = categories.find(c => c.id === place.category_id)
|
const cat = categories.find(c => c.id === place.category_id)
|
||||||
const isPlaceSelected = place.id === selectedPlaceId
|
const isPlaceSelected = selectedAssignmentId ? assignment.id === selectedAssignmentId : place.id === selectedPlaceId
|
||||||
const hasReservation = place.reservation_status && place.reservation_status !== 'none'
|
|
||||||
const isConfirmed = place.reservation_status === 'confirmed'
|
|
||||||
const isDraggingThis = draggingId === assignment.id
|
const isDraggingThis = draggingId === assignment.id
|
||||||
const isHovered = hoveredId === assignment.id
|
const isHovered = hoveredId === assignment.id
|
||||||
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
|
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
|
||||||
@@ -582,7 +619,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 +644,59 @@ 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, isPlaceSelected ? null : assignment.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'),
|
||||||
|
borderLeft: lockedIds.has(assignment.id)
|
||||||
|
? '3px solid #dc2626'
|
||||||
: '3px solid transparent',
|
: '3px solid transparent',
|
||||||
transition: 'background 0.1s',
|
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)
|
||||||
|
? t('planner.clickToUnlock')
|
||||||
|
: t('planner.keepPosition')}
|
||||||
|
</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 && (() => {
|
||||||
@@ -638,28 +709,36 @@ export default function DayPlanSidebar({
|
|||||||
{place.place_time && (
|
{place.place_time && (
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
|
||||||
<Clock size={9} strokeWidth={2} />
|
<Clock size={9} strokeWidth={2} />
|
||||||
{formatTime(place.place_time, locale, timeFormat)}
|
{formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(place.description || place.address || cat?.name) && !hasReservation && (
|
{(place.description || place.address || cat?.name) && (
|
||||||
<div style={{ marginTop: 2 }}>
|
<div style={{ marginTop: 2 }}>
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
|
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
|
||||||
{place.description || place.address || cat?.name}
|
{place.description || place.address || cat?.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasReservation && (
|
{(() => {
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2, marginTop: 2 }}>
|
const res = reservations.find(r => r.assignment_id === assignment.id)
|
||||||
<span style={{ fontSize: 10, color: isConfirmed ? '#059669' : '#d97706', display: 'flex', alignItems: 'center', gap: 2, fontWeight: 600 }}>
|
if (!res) return null
|
||||||
{isConfirmed ? <><CheckCircle2 size={10} />
|
const confirmed = res.status === 'confirmed'
|
||||||
{place.reservation_datetime
|
return (
|
||||||
? `Res. ${formatTime(new Date(place.reservation_datetime).toTimeString().slice(0,5), locale, timeFormat)}`
|
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 9, fontWeight: 600,
|
||||||
: place.place_time ? `Res. ${formatTime(place.place_time, locale, timeFormat)}` : t('dayplan.confirmed')}
|
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)',
|
||||||
</> : <><AlertCircle size={10} />{t('dayplan.pendingRes')}</>}
|
color: confirmed ? '#16a34a' : '#d97706',
|
||||||
</span>
|
}}>
|
||||||
</div>
|
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
||||||
)}
|
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||||
|
{res.reservation_time && (
|
||||||
|
<span style={{ fontWeight: 400 }}>
|
||||||
|
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||||
<button onClick={moveUp} disabled={placeIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === 0 ? 'default' : 'pointer', color: placeIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
<button onClick={moveUp} disabled={placeIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === 0 ? 'default' : 'pointer', color: placeIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||||
@@ -686,7 +765,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,26 +828,44 @@ 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 && (
|
||||||
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||||
<div style={{ display: 'flex', background: 'var(--bg-hover)', borderRadius: 8, padding: 2, gap: 2 }}>
|
|
||||||
{TRANSPORT_MODES.map(m => (
|
|
||||||
<button key={m.value} onClick={() => setTransportMode(m.value)} style={{
|
|
||||||
flex: 1, padding: '4px 0', fontSize: 11, fontWeight: transportMode === m.value ? 600 : 400,
|
|
||||||
background: transportMode === m.value ? 'var(--bg-card)' : 'transparent',
|
|
||||||
border: 'none', borderRadius: 6, cursor: 'pointer', color: transportMode === m.value ? 'var(--text-primary)' : 'var(--text-muted)',
|
|
||||||
boxShadow: transportMode === m.value ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
}}>{m.label}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{routeInfo && (
|
{routeInfo && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
|
||||||
<span>{routeInfo.distance}</span>
|
<span>{routeInfo.distance}</span>
|
||||||
@@ -778,15 +875,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',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { CalendarDays, MapPin, Plus } from 'lucide-react'
|
import { CalendarDays, MapPin, Plus } from 'lucide-react'
|
||||||
import WeatherWidget from '../Weather/WeatherWidget'
|
import WeatherWidget from '../Weather/WeatherWidget'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
if (!dateStr) return null
|
if (!dateStr) return null
|
||||||
@@ -20,6 +21,7 @@ function dayTotal(dayId, assignments) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }) {
|
export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const totalCost = days.reduce((sum, d) => sum + dayTotal(d.id, assignments), 0)
|
const totalCost = days.reduce((sum, d) => sum + dayTotal(d.id, assignments), 0)
|
||||||
const currency = trip?.currency || 'EUR'
|
const currency = trip?.currency || 'EUR'
|
||||||
|
|
||||||
@@ -27,8 +29,8 @@ export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-4 py-3 border-b border-gray-100 flex-shrink-0">
|
<div className="px-4 py-3 border-b border-gray-100 flex-shrink-0">
|
||||||
<h2 className="text-sm font-semibold text-gray-700">Tagesplan</h2>
|
<h2 className="text-sm font-semibold text-gray-700">{t('planner.dayPlan')}</h2>
|
||||||
<p className="text-xs text-gray-400 mt-0.5">{days.length} Tage</p>
|
<p className="text-xs text-gray-400 mt-0.5">{t('planner.dayCount', { n: days.length })}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* All places overview option */}
|
{/* All places overview option */}
|
||||||
@@ -43,9 +45,9 @@ export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }
|
|||||||
<MapPin className={`w-4 h-4 flex-shrink-0 ${selectedDayId === null ? 'text-slate-900' : 'text-gray-400'}`} />
|
<MapPin className={`w-4 h-4 flex-shrink-0 ${selectedDayId === null ? 'text-slate-900' : 'text-gray-400'}`} />
|
||||||
<div>
|
<div>
|
||||||
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
|
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
|
||||||
Alle Orte
|
{t('planner.allPlaces')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-400">Gesamtübersicht</p>
|
<p className="text-xs text-gray-400">{t('planner.overview')}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -54,8 +56,8 @@ export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }
|
|||||||
{days.length === 0 ? (
|
{days.length === 0 ? (
|
||||||
<div className="px-4 py-6 text-center">
|
<div className="px-4 py-6 text-center">
|
||||||
<CalendarDays className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
<CalendarDays className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||||
<p className="text-xs text-gray-400">Noch keine Tage</p>
|
<p className="text-xs text-gray-400">{t('planner.noDays')}</p>
|
||||||
<p className="text-xs text-gray-300 mt-1">Reise bearbeiten um Tage hinzuzufügen</p>
|
<p className="text-xs text-gray-300 mt-1">{t('planner.editTripToAddDays')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
days.map((day, index) => {
|
days.map((day, index) => {
|
||||||
@@ -96,7 +98,7 @@ export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }
|
|||||||
<div className="flex items-center gap-3 mt-1.5">
|
<div className="flex items-center gap-3 mt-1.5">
|
||||||
{placeCount > 0 && (
|
{placeCount > 0 && (
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">
|
||||||
{placeCount} {placeCount === 1 ? 'Ort' : 'Orte'}
|
{placeCount === 1 ? t('planner.placeOne') : t('planner.placeN', { n: placeCount })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{cost > 0 && (
|
{cost > 0 && (
|
||||||
@@ -124,7 +126,7 @@ export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }
|
|||||||
{totalCost > 0 && (
|
{totalCost > 0 && (
|
||||||
<div className="flex-shrink-0 border-t border-gray-100 px-4 py-3 bg-gray-50">
|
<div className="flex-shrink-0 border-t border-gray-100 px-4 py-3 bg-gray-50">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-gray-500">Gesamtkosten</span>
|
<span className="text-xs text-gray-500">{t('planner.totalCost')}</span>
|
||||||
<span className="text-sm font-semibold text-gray-800">
|
<span className="text-sm font-semibold text-gray-800">
|
||||||
{totalCost.toFixed(2)} {currency}
|
{totalCost.toFixed(2)} {currency}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { X, ExternalLink, Phone, MapPin, Clock, Euro, Edit2, Trash2, Plus, Minus } from 'lucide-react'
|
import { X, ExternalLink, Phone, MapPin, Clock, Euro, Edit2, Trash2, Plus, Minus } from 'lucide-react'
|
||||||
import { mapsApi } from '../../api/client'
|
import { mapsApi } from '../../api/client'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
const RESERVATION_STATUS = {
|
|
||||||
none: { label: 'Keine Reservierung', color: 'gray' },
|
|
||||||
pending: { label: 'Res. ausstehend', color: 'yellow' },
|
|
||||||
confirmed: { label: 'Bestätigt', color: 'green' },
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PlaceDetailPanel({
|
export function PlaceDetailPanel({
|
||||||
place, categories, tags, selectedDayId, dayAssignments,
|
place, categories, tags, selectedDayId, dayAssignments,
|
||||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [googlePhoto, setGooglePhoto] = useState(null)
|
const [googlePhoto, setGooglePhoto] = useState(null)
|
||||||
const [photoAttribution, setPhotoAttribution] = useState(null)
|
const [photoAttribution, setPhotoAttribution] = useState(null)
|
||||||
|
|
||||||
@@ -40,8 +36,6 @@ export function PlaceDetailPanel({
|
|||||||
? dayAssignments?.find(a => a.place?.id === place.id)
|
? dayAssignments?.find(a => a.place?.id === place.id)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const status = RESERVATION_STATUS[place.reservation_status] || RESERVATION_STATUS.none
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white">
|
<div className="bg-white">
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
@@ -177,29 +171,6 @@ export function PlaceDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reservation status */}
|
|
||||||
{place.reservation_status && place.reservation_status !== 'none' && (
|
|
||||||
<div className={`rounded-lg px-3 py-2 border ${
|
|
||||||
place.reservation_status === 'confirmed'
|
|
||||||
? 'bg-emerald-50 border-emerald-200'
|
|
||||||
: 'bg-yellow-50 border-yellow-200'
|
|
||||||
}`}>
|
|
||||||
<div className={`text-xs font-semibold ${
|
|
||||||
place.reservation_status === 'confirmed' ? 'text-emerald-700' : 'text-yellow-700'
|
|
||||||
}`}>
|
|
||||||
{place.reservation_status === 'confirmed' ? '✅' : '⏳'} {status.label}
|
|
||||||
</div>
|
|
||||||
{place.reservation_datetime && (
|
|
||||||
<div className="text-xs text-gray-500 mt-0.5">
|
|
||||||
{formatDateTime(place.reservation_datetime)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{place.reservation_notes && (
|
|
||||||
<p className="text-xs text-gray-600 mt-1">{place.reservation_notes}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Day assignment actions */}
|
{/* Day assignment actions */}
|
||||||
{selectedDayId && (
|
{selectedDayId && (
|
||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
@@ -209,7 +180,7 @@ export function PlaceDetailPanel({
|
|||||||
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
|
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
|
||||||
>
|
>
|
||||||
<Minus className="w-4 h-4" />
|
<Minus className="w-4 h-4" />
|
||||||
Aus Tag entfernen
|
{t('planner.removeFromDay')}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -217,7 +188,7 @@ export function PlaceDetailPanel({
|
|||||||
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-white bg-slate-900 rounded-lg hover:bg-slate-700"
|
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-white bg-slate-900 rounded-lg hover:bg-slate-700"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Zum Tag hinzufügen
|
{t('planner.addToThisDay')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -230,7 +201,7 @@ export function PlaceDetailPanel({
|
|||||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-gray-700 border border-gray-200 rounded-lg hover:bg-gray-50"
|
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-gray-700 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
<Edit2 className="w-3.5 h-3.5" />
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
Bearbeiten
|
{t('common.edit')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { mapsApi } from '../../api/client'
|
import { mapsApi } from '../../api/client'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Search } from 'lucide-react'
|
import { Search, Paperclip, X } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
|
|
||||||
|
|
||||||
const TRANSPORT_MODES = [
|
|
||||||
{ value: 'walking', labelKey: 'places.transport.walking' },
|
|
||||||
{ value: 'driving', labelKey: 'places.transport.driving' },
|
|
||||||
{ value: 'cycling', labelKey: 'places.transport.cycling' },
|
|
||||||
{ value: 'transit', labelKey: 'places.transport.transit' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const DEFAULT_FORM = {
|
const DEFAULT_FORM = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -24,11 +16,9 @@ const DEFAULT_FORM = {
|
|||||||
lng: '',
|
lng: '',
|
||||||
category_id: '',
|
category_id: '',
|
||||||
place_time: '',
|
place_time: '',
|
||||||
|
end_time: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
transport_mode: 'walking',
|
transport_mode: 'walking',
|
||||||
reservation_status: 'none',
|
|
||||||
reservation_notes: '',
|
|
||||||
reservation_datetime: '',
|
|
||||||
website: '',
|
website: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +33,8 @@ export default function PlaceFormModal({
|
|||||||
const [newCategoryName, setNewCategoryName] = useState('')
|
const [newCategoryName, setNewCategoryName] = useState('')
|
||||||
const [showNewCategory, setShowNewCategory] = useState(false)
|
const [showNewCategory, setShowNewCategory] = useState(false)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [pendingFiles, setPendingFiles] = useState([])
|
||||||
|
const fileRef = useRef(null)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, language } = useTranslation()
|
const { t, language } = useTranslation()
|
||||||
const { hasMapsKey } = useAuthStore()
|
const { hasMapsKey } = useAuthStore()
|
||||||
@@ -57,16 +49,15 @@ export default function PlaceFormModal({
|
|||||||
lng: place.lng || '',
|
lng: place.lng || '',
|
||||||
category_id: place.category_id || '',
|
category_id: place.category_id || '',
|
||||||
place_time: place.place_time || '',
|
place_time: place.place_time || '',
|
||||||
|
end_time: place.end_time || '',
|
||||||
notes: place.notes || '',
|
notes: place.notes || '',
|
||||||
transport_mode: place.transport_mode || 'walking',
|
transport_mode: place.transport_mode || 'walking',
|
||||||
reservation_status: place.reservation_status || 'none',
|
|
||||||
reservation_notes: place.reservation_notes || '',
|
|
||||||
reservation_datetime: place.reservation_datetime || '',
|
|
||||||
website: place.website || '',
|
website: place.website || '',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setForm(DEFAULT_FORM)
|
setForm(DEFAULT_FORM)
|
||||||
}
|
}
|
||||||
|
setPendingFiles([])
|
||||||
}, [place, isOpen])
|
}, [place, isOpen])
|
||||||
|
|
||||||
const handleChange = (field, value) => {
|
const handleChange = (field, value) => {
|
||||||
@@ -111,6 +102,30 @@ export default function PlaceFormModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleFileAdd = (e) => {
|
||||||
|
const files = Array.from(e.target.files || [])
|
||||||
|
setPendingFiles(prev => [...prev, ...files])
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveFile = (idx) => {
|
||||||
|
setPendingFiles(prev => prev.filter((_, i) => i !== idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paste support for files/images
|
||||||
|
const handlePaste = (e) => {
|
||||||
|
const items = e.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type.startsWith('image/') || item.type === 'application/pdf') {
|
||||||
|
e.preventDefault()
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (file) setPendingFiles(prev => [...prev, file])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!form.name.trim()) {
|
if (!form.name.trim()) {
|
||||||
@@ -124,6 +139,7 @@ export default function PlaceFormModal({
|
|||||||
lat: form.lat ? parseFloat(form.lat) : null,
|
lat: form.lat ? parseFloat(form.lat) : null,
|
||||||
lng: form.lng ? parseFloat(form.lng) : null,
|
lng: form.lng ? parseFloat(form.lng) : null,
|
||||||
category_id: form.category_id || null,
|
category_id: form.category_id || null,
|
||||||
|
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
|
||||||
})
|
})
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -140,7 +156,7 @@ export default function PlaceFormModal({
|
|||||||
title={place ? t('places.editPlace') : t('places.addPlace')}
|
title={place ? t('places.editPlace') : t('places.addPlace')}
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}>
|
||||||
{/* Place Search */}
|
{/* Place Search */}
|
||||||
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
|
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
|
||||||
{!hasMapsKey && (
|
{!hasMapsKey && (
|
||||||
@@ -278,12 +294,21 @@ export default function PlaceFormModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time */}
|
{/* Time */}
|
||||||
<div>
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formTime')}</label>
|
<div>
|
||||||
<CustomTimePicker
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.startTime')}</label>
|
||||||
value={form.place_time}
|
<CustomTimePicker
|
||||||
onChange={v => handleChange('place_time', v)}
|
value={form.place_time}
|
||||||
/>
|
onChange={v => handleChange('place_time', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.endTime')}</label>
|
||||||
|
<CustomTimePicker
|
||||||
|
value={form.end_time}
|
||||||
|
onChange={v => handleChange('end_time', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Website */}
|
{/* Website */}
|
||||||
@@ -298,45 +323,35 @@ export default function PlaceFormModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reservation */}
|
{/* File Attachments */}
|
||||||
<div className="border border-gray-200 rounded-xl p-3 space-y-3">
|
{true && (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3">
|
<div className="border border-gray-200 rounded-xl p-3 space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 shrink-0">{t('places.formReservation')}</label>
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-2 flex-wrap">
|
<label className="block text-sm font-medium text-gray-700">{t('files.title')}</label>
|
||||||
{['none', 'pending', 'confirmed'].map(status => (
|
<button type="button" onClick={() => fileRef.current?.click()}
|
||||||
<button
|
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-700 transition-colors">
|
||||||
key={status}
|
<Paperclip size={12} /> {t('files.attach')}
|
||||||
type="button"
|
</button>
|
||||||
onClick={() => handleChange('reservation_status', status)}
|
|
||||||
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
|
|
||||||
form.reservation_status === status
|
|
||||||
? status === 'confirmed' ? 'bg-emerald-600 text-white'
|
|
||||||
: status === 'pending' ? 'bg-yellow-500 text-white'
|
|
||||||
: 'bg-gray-600 text-white'
|
|
||||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{status === 'none' ? t('common.none') : status === 'pending' ? t('reservations.pending') : t('reservations.confirmed')}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={handleFileAdd} />
|
||||||
|
{pendingFiles.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{pendingFiles.map((file, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-slate-50 text-xs">
|
||||||
|
<Paperclip size={10} className="text-slate-400 shrink-0" />
|
||||||
|
<span className="truncate flex-1 text-slate-600">{file.name}</span>
|
||||||
|
<button type="button" onClick={() => handleRemoveFile(idx)} className="text-slate-400 hover:text-red-500 shrink-0">
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pendingFiles.length === 0 && (
|
||||||
|
<p className="text-xs text-slate-400">{t('files.pasteHint')}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{form.reservation_status !== 'none' && (
|
)}
|
||||||
<>
|
|
||||||
<CustomDateTimePicker
|
|
||||||
value={form.reservation_datetime}
|
|
||||||
onChange={v => handleChange('reservation_datetime', v)}
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
value={form.reservation_notes}
|
|
||||||
onChange={e => handleChange('reservation_notes', e.target.value)}
|
|
||||||
rows={2}
|
|
||||||
placeholder={t('places.reservationNotesPlaceholder')}
|
|
||||||
className="form-input" style={{ resize: 'none' }}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
|
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, CheckCircle2, AlertCircle, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation } from 'lucide-react'
|
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation } from 'lucide-react'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import { mapsApi } from '../../api/client'
|
import { mapsApi } from '../../api/client'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
@@ -86,16 +86,6 @@ function formatTime(timeStr, locale, timeFormat) {
|
|||||||
} catch { return timeStr }
|
} catch { return timeStr }
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatReservationDatetime(dt, locale, timeFormat) {
|
|
||||||
if (!dt) return null
|
|
||||||
try {
|
|
||||||
const d = new Date(dt)
|
|
||||||
if (isNaN(d)) return dt
|
|
||||||
const datePart = d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
|
||||||
const timePart = formatTime(`${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`, locale, timeFormat)
|
|
||||||
return `${datePart}, ${timePart}`
|
|
||||||
} catch { return dt }
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize(bytes) {
|
function formatFileSize(bytes) {
|
||||||
if (!bytes) return ''
|
if (!bytes) return ''
|
||||||
@@ -105,7 +95,7 @@ function formatFileSize(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PlaceInspector({
|
export default function PlaceInspector({
|
||||||
place, categories, days, selectedDayId, assignments,
|
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
|
||||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||||
files, onFileUpload,
|
files, onFileUpload,
|
||||||
}) {
|
}) {
|
||||||
@@ -279,45 +269,72 @@ export default function PlaceInspector({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Description + Reservation in one box */}
|
{/* Description */}
|
||||||
{(place.description || place.notes || (place.reservation_status && place.reservation_status !== 'none')) && (
|
{(place.description || place.notes) && (
|
||||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
||||||
{(place.description || place.notes) && (
|
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px' }}>
|
||||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px',
|
{place.description || place.notes}
|
||||||
borderBottom: (place.reservation_status && place.reservation_status !== 'none') ? '1px solid var(--border-faint)' : 'none'
|
</p>
|
||||||
}}>
|
|
||||||
{place.description || place.notes}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{place.reservation_status && place.reservation_status !== 'none' && (
|
|
||||||
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
|
|
||||||
{place.reservation_status === 'confirmed'
|
|
||||||
? <CheckCircle2 size={12} color="#059669" />
|
|
||||||
: <AlertCircle size={12} color="#d97706" />
|
|
||||||
}
|
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: place.reservation_status === 'confirmed' ? '#059669' : '#d97706' }}>
|
|
||||||
{place.reservation_status === 'confirmed' ? t('inspector.confirmedRes') : t('inspector.pendingRes')}
|
|
||||||
</span>
|
|
||||||
{(place.reservation_datetime || place.place_time) && (
|
|
||||||
<>
|
|
||||||
<span style={{ fontSize: 11, color: '#d1d5db' }}>·</span>
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
|
||||||
{place.reservation_datetime
|
|
||||||
? formatReservationDatetime(place.reservation_datetime, locale, timeFormat)
|
|
||||||
: formatTime(place.place_time, locale, timeFormat)}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{place.reservation_notes && (
|
|
||||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0, lineHeight: '1.5', paddingLeft: 17 }}>{place.reservation_notes}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Reservation for this specific assignment */}
|
||||||
|
{(() => {
|
||||||
|
const res = selectedAssignmentId ? reservations.find(r => r.assignment_id === selectedAssignmentId) : null
|
||||||
|
if (!res) return null
|
||||||
|
const confirmed = res.status === 'confirmed'
|
||||||
|
const accentColor = confirmed ? '#16a34a' : '#d97706'
|
||||||
|
return (
|
||||||
|
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
|
||||||
|
{/* Header bar */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: confirmed ? 'rgba(22,163,74,0.08)' : 'rgba(217,119,6,0.08)' }}>
|
||||||
|
<div style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: accentColor }} />
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: accentColor }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)' }}>{res.title}</span>
|
||||||
|
</div>
|
||||||
|
{/* Details grid */}
|
||||||
|
{(res.reservation_time || res.confirmation_number || res.location || res.notes) && (
|
||||||
|
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||||
|
{res.reservation_time && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.date')}</div>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||||
|
{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{res.reservation_time && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||||
|
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{res.confirmation_number && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.confirmationCode')}</div>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{res.confirmation_number}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{res.location && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.locationAddress')}</div>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-muted)', marginTop: 1 }}>{res.location}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{res.notes && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-faint)', lineHeight: 1.4, borderTop: '1px solid var(--border-faint)', paddingTop: 5 }}>{res.notes}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Opening hours */}
|
{/* Opening hours */}
|
||||||
{openingHours && openingHours.length > 0 && (
|
{openingHours && openingHours.length > 0 && (
|
||||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
||||||
|
|||||||
@@ -204,19 +204,17 @@ export default function PlacesSidebar({
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 16px' }}>
|
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 16px' }}>
|
||||||
{days.map((day, i) => {
|
{days.map((day, i) => {
|
||||||
const alreadyAssigned = (assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id)
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={day.id}
|
key={day.id}
|
||||||
disabled={alreadyAssigned}
|
|
||||||
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null) }}
|
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null) }}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
|
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
|
||||||
padding: '12px 14px', borderRadius: 12, border: 'none', cursor: alreadyAssigned ? 'default' : 'pointer',
|
padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer',
|
||||||
background: 'transparent', fontFamily: 'inherit', textAlign: 'left',
|
background: 'transparent', fontFamily: 'inherit', textAlign: 'left',
|
||||||
opacity: alreadyAssigned ? 0.4 : 1, transition: 'background 0.1s',
|
transition: 'background 0.1s',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { if (!alreadyAssigned) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -230,7 +228,7 @@ export default function PlacesSidebar({
|
|||||||
</div>
|
</div>
|
||||||
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00').toLocaleDateString()}</div>}
|
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00').toLocaleDateString()}</div>}
|
||||||
</div>
|
</div>
|
||||||
{alreadyAssigned && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>✓</span>}
|
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>✓</span>}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -13,20 +13,7 @@ import { PlaceDetailPanel } from './PlaceDetailPanel'
|
|||||||
import WeatherWidget from '../Weather/WeatherWidget'
|
import WeatherWidget from '../Weather/WeatherWidget'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
const SEGMENTS = [
|
|
||||||
{ id: 'plan', label: 'Plan' },
|
|
||||||
{ id: 'orte', label: 'Orte' },
|
|
||||||
{ id: 'reservierungen', label: 'Buchungen' },
|
|
||||||
{ id: 'packliste', label: 'Packliste' },
|
|
||||||
{ id: 'dokumente', label: 'Dokumente' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const TRANSPORT_MODES = [
|
|
||||||
{ value: 'driving', label: 'Auto', icon: '🚗' },
|
|
||||||
{ value: 'walking', label: 'Fuß', icon: '🚶' },
|
|
||||||
{ value: 'cycling', label: 'Rad', icon: '🚲' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function formatShortDate(dateStr) {
|
function formatShortDate(dateStr) {
|
||||||
if (!dateStr) return ''
|
if (!dateStr) return ''
|
||||||
@@ -53,7 +40,6 @@ export default function PlannerSidebar({
|
|||||||
const [activeSegment, setActiveSegment] = useState('plan')
|
const [activeSegment, setActiveSegment] = useState('plan')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [categoryFilter, setCategoryFilter] = useState('')
|
const [categoryFilter, setCategoryFilter] = useState('')
|
||||||
const [transportMode, setTransportMode] = useState('driving')
|
|
||||||
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
|
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
|
||||||
const [showReservationModal, setShowReservationModal] = useState(false)
|
const [showReservationModal, setShowReservationModal] = useState(false)
|
||||||
const [editingReservation, setEditingReservation] = useState(null)
|
const [editingReservation, setEditingReservation] = useState(null)
|
||||||
@@ -65,6 +51,16 @@ export default function PlannerSidebar({
|
|||||||
|
|
||||||
const tripStore = useTripStore()
|
const tripStore = useTripStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const SEGMENTS = [
|
||||||
|
{ id: 'plan', label: 'Plan' },
|
||||||
|
{ id: 'orte', label: t('planner.places') },
|
||||||
|
{ id: 'reservierungen', label: t('planner.bookings') },
|
||||||
|
{ id: 'packliste', label: t('planner.packingList') },
|
||||||
|
{ id: 'dokumente', label: t('planner.documents') },
|
||||||
|
]
|
||||||
|
|
||||||
const dayNotes = tripStore.dayNotes || {}
|
const dayNotes = tripStore.dayNotes || {}
|
||||||
const placesListRef = useRef(null)
|
const placesListRef = useRef(null)
|
||||||
const [placesListHeight, setPlacesListHeight] = useState(400)
|
const [placesListHeight, setPlacesListHeight] = useState(400)
|
||||||
@@ -135,17 +131,17 @@ export default function PlannerSidebar({
|
|||||||
.filter(p => p?.lat && p?.lng)
|
.filter(p => p?.lat && p?.lng)
|
||||||
.map(p => ({ lat: p.lat, lng: p.lng }))
|
.map(p => ({ lat: p.lat, lng: p.lng }))
|
||||||
if (waypoints.length < 2) {
|
if (waypoints.length < 2) {
|
||||||
toast.error('Mindestens 2 Orte mit Koordinaten benötigt')
|
toast.error(t('planner.minTwoPlaces'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsCalculatingRoute(true)
|
setIsCalculatingRoute(true)
|
||||||
try {
|
try {
|
||||||
const result = await calculateRoute(waypoints, transportMode)
|
const result = await calculateRoute(waypoints, 'walking')
|
||||||
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
|
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
|
||||||
onRouteCalculated?.(result)
|
onRouteCalculated?.(result)
|
||||||
toast.success('Route berechnet')
|
toast.success(t('planner.routeCalculated'))
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Route konnte nicht berechnet werden')
|
toast.error(t('planner.routeCalcFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setIsCalculatingRoute(false)
|
setIsCalculatingRoute(false)
|
||||||
}
|
}
|
||||||
@@ -163,14 +159,14 @@ export default function PlannerSidebar({
|
|||||||
if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id)
|
if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id)
|
||||||
}
|
}
|
||||||
await onReorder(selectedDayId, reorderedIds)
|
await onReorder(selectedDayId, reorderedIds)
|
||||||
toast.success('Route optimiert')
|
toast.success(t('planner.routeOptimized'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenGoogleMaps = () => {
|
const handleOpenGoogleMaps = () => {
|
||||||
const ps = selectedDayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
|
const ps = selectedDayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||||
const url = generateGoogleMapsUrl(ps)
|
const url = generateGoogleMapsUrl(ps)
|
||||||
if (url) window.open(url, '_blank')
|
if (url) window.open(url, '_blank')
|
||||||
else toast.error('Keine Orte mit Koordinaten vorhanden')
|
else toast.error(t('planner.noGeoPlaces'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMoveUp = async (dayId, idx) => {
|
const handleMoveUp = async (dayId, idx) => {
|
||||||
@@ -270,10 +266,10 @@ export default function PlannerSidebar({
|
|||||||
try {
|
try {
|
||||||
if (editingReservation) {
|
if (editingReservation) {
|
||||||
await tripStore.updateReservation(tripId, editingReservation.id, data)
|
await tripStore.updateReservation(tripId, editingReservation.id, data)
|
||||||
toast.success('Reservierung aktualisiert')
|
toast.success(t('planner.reservationUpdated'))
|
||||||
} else {
|
} else {
|
||||||
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
||||||
toast.success('Reservierung hinzugefügt')
|
toast.success(t('planner.reservationAdded'))
|
||||||
}
|
}
|
||||||
setShowReservationModal(false)
|
setShowReservationModal(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -282,10 +278,10 @@ export default function PlannerSidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteReservation = async (id) => {
|
const handleDeleteReservation = async (id) => {
|
||||||
if (!confirm('Reservierung löschen?')) return
|
if (!confirm(t('planner.confirmDeleteReservation'))) return
|
||||||
try {
|
try {
|
||||||
await tripStore.deleteReservation(tripId, id)
|
await tripStore.deleteReservation(tripId, id)
|
||||||
toast.success('Reservierung gelöscht')
|
toast.success(t('planner.reservationDeleted'))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message)
|
toast.error(err.message)
|
||||||
}
|
}
|
||||||
@@ -306,7 +302,7 @@ export default function PlannerSidebar({
|
|||||||
{trip.start_date && formatShortDate(trip.start_date)}
|
{trip.start_date && formatShortDate(trip.start_date)}
|
||||||
{trip.start_date && trip.end_date && ' – '}
|
{trip.start_date && trip.end_date && ' – '}
|
||||||
{trip.end_date && formatShortDate(trip.end_date)}
|
{trip.end_date && formatShortDate(trip.end_date)}
|
||||||
{days.length > 0 && ` · ${days.length} Tage`}
|
{days.length > 0 && ` · ${days.length} ${t('planner.days')}`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -348,18 +344,18 @@ export default function PlannerSidebar({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
|
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
|
||||||
Alle Orte
|
{t('planner.allPlaces')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-400">{places.length} Orte gesamt</p>
|
<p className="text-xs text-gray-400">{t('planner.totalPlaces', { n: places.length })}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{days.length === 0 ? (
|
{days.length === 0 ? (
|
||||||
<div className="px-4 py-10 text-center">
|
<div className="px-4 py-10 text-center">
|
||||||
<CalendarDays className="w-10 h-10 text-gray-200 mx-auto mb-3" />
|
<CalendarDays className="w-10 h-10 text-gray-200 mx-auto mb-3" />
|
||||||
<p className="text-sm text-gray-400">Noch keine Tage geplant</p>
|
<p className="text-sm text-gray-400">{t('planner.noDaysPlanned')}</p>
|
||||||
<button onClick={onEditTrip} className="mt-2 text-slate-700 text-sm">
|
<button onClick={onEditTrip} className="mt-2 text-slate-700 text-sm">
|
||||||
Reise bearbeiten →
|
{t('planner.editTrip')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -396,7 +392,7 @@ export default function PlannerSidebar({
|
|||||||
</p>
|
</p>
|
||||||
{da.length > 0 && (
|
{da.length > 0 && (
|
||||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||||
{da.length} {da.length === 1 ? 'Ort' : 'Orte'}
|
{da.length === 1 ? t('planner.placeOne') : t('planner.placeN', { n: da.length })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -410,7 +406,7 @@ export default function PlannerSidebar({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); openAddNote(day.id); if (!isExpanded) toggleDay(day.id) }}
|
onClick={e => { e.stopPropagation(); openAddNote(day.id); if (!isExpanded) toggleDay(day.id) }}
|
||||||
title="Notiz hinzufügen"
|
title={t('planner.addNote')}
|
||||||
className="p-1 text-gray-300 hover:text-amber-500 flex-shrink-0 transition-colors"
|
className="p-1 text-gray-300 hover:text-amber-500 flex-shrink-0 transition-colors"
|
||||||
>
|
>
|
||||||
<FileText className="w-4 h-4" />
|
<FileText className="w-4 h-4" />
|
||||||
@@ -430,12 +426,12 @@ export default function PlannerSidebar({
|
|||||||
<div className="bg-gray-50/40">
|
<div className="bg-gray-50/40">
|
||||||
{merged.length === 0 && !dayNoteUi ? (
|
{merged.length === 0 && !dayNoteUi ? (
|
||||||
<div className="px-4 py-4 text-center">
|
<div className="px-4 py-4 text-center">
|
||||||
<p className="text-xs text-gray-400">Keine Einträge für diesen Tag</p>
|
<p className="text-xs text-gray-400">{t('planner.noEntries')}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => { onSelectDay(day.id); setActiveSegment('orte') }}
|
onClick={() => { onSelectDay(day.id); setActiveSegment('orte') }}
|
||||||
className="mt-1 text-xs text-slate-700"
|
className="mt-1 text-xs text-slate-700"
|
||||||
>
|
>
|
||||||
+ Ort hinzufügen
|
{t('planner.addPlaceShort')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -478,20 +474,11 @@ export default function PlannerSidebar({
|
|||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||||
{place.place_time && (
|
{place.place_time && (
|
||||||
<span className="text-[11px] text-slate-600 font-medium">{place.place_time}</span>
|
<span className="text-[11px] text-slate-600 font-medium">{place.place_time}{place.end_time ? ` – ${place.end_time}` : ''}</span>
|
||||||
)}
|
)}
|
||||||
{place.price > 0 && (
|
{place.price > 0 && (
|
||||||
<span className="text-[11px] text-gray-400">{place.price} {place.currency || currency}</span>
|
<span className="text-[11px] text-gray-400">{place.price} {place.currency || currency}</span>
|
||||||
)}
|
)}
|
||||||
{place.reservation_status && place.reservation_status !== 'none' && (
|
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
|
||||||
place.reservation_status === 'confirmed'
|
|
||||||
? 'bg-emerald-50 text-emerald-600'
|
|
||||||
: 'bg-amber-50 text-amber-600'
|
|
||||||
}`}>
|
|
||||||
{place.reservation_status === 'confirmed' ? '✓ Bestätigt' : '⏳ Res. ausstehend'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex flex-col gap-0 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
@@ -524,7 +511,7 @@ export default function PlannerSidebar({
|
|||||||
type="text"
|
type="text"
|
||||||
value={dayNoteUi.time}
|
value={dayNoteUi.time}
|
||||||
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
|
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
|
||||||
placeholder="Zeit (optional)"
|
placeholder={t('planner.noteTimePlaceholder')}
|
||||||
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
|
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -533,16 +520,16 @@ export default function PlannerSidebar({
|
|||||||
value={dayNoteUi.text}
|
value={dayNoteUi.text}
|
||||||
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))}
|
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))}
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }}
|
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }}
|
||||||
placeholder="Notiz…"
|
placeholder={t('planner.notePlaceholder')}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none"
|
className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-1.5 mt-1.5">
|
<div className="flex gap-1.5 mt-1.5">
|
||||||
<button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600">
|
<button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600">
|
||||||
<Check className="w-3 h-3" /> Speichern
|
<Check className="w-3 h-3" /> {t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
|
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
|
||||||
Abbrechen
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -587,7 +574,7 @@ export default function PlannerSidebar({
|
|||||||
type="text"
|
type="text"
|
||||||
value={dayNoteUi.time}
|
value={dayNoteUi.time}
|
||||||
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
|
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
|
||||||
placeholder="Zeit (optional)"
|
placeholder={t('planner.noteTimePlaceholder')}
|
||||||
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
|
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -596,16 +583,16 @@ export default function PlannerSidebar({
|
|||||||
value={dayNoteUi.text}
|
value={dayNoteUi.text}
|
||||||
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))}
|
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))}
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }}
|
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }}
|
||||||
placeholder="z.B. S3 um 14:30 ab Hauptbahnhof, Fähre ab Pier 7, Mittagspause…"
|
placeholder={t('planner.noteExamplePlaceholder')}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none"
|
className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-1.5 mt-1.5">
|
<div className="flex gap-1.5 mt-1.5">
|
||||||
<button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600">
|
<button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600">
|
||||||
<Check className="w-3 h-3" /> Hinzufügen
|
<Check className="w-3 h-3" /> {t('common.add')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
|
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
|
||||||
Abbrechen
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -618,7 +605,7 @@ export default function PlannerSidebar({
|
|||||||
className="flex items-center gap-1 text-[11px] text-amber-600 hover:text-amber-700 py-1"
|
className="flex items-center gap-1 text-[11px] text-amber-600 hover:text-amber-700 py-1"
|
||||||
>
|
>
|
||||||
<FileText className="w-3 h-3" />
|
<FileText className="w-3 h-3" />
|
||||||
Notiz hinzufügen
|
{t('planner.addNote')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -626,21 +613,6 @@ export default function PlannerSidebar({
|
|||||||
{/* Route tools — only for the selected day */}
|
{/* Route tools — only for the selected day */}
|
||||||
{isSelected && da.length >= 2 && (
|
{isSelected && da.length >= 2 && (
|
||||||
<div className="px-4 py-3 space-y-2 border-t border-gray-100/60">
|
<div className="px-4 py-3 space-y-2 border-t border-gray-100/60">
|
||||||
<div className="flex bg-gray-100 rounded-[8px] p-0.5 gap-0.5">
|
|
||||||
{TRANSPORT_MODES.map(m => (
|
|
||||||
<button
|
|
||||||
key={m.value}
|
|
||||||
onClick={() => setTransportMode(m.value)}
|
|
||||||
className={`flex-1 py-1 text-[11px] rounded-[6px] transition-all ${
|
|
||||||
transportMode === m.value
|
|
||||||
? 'bg-white shadow-sm text-gray-900 font-medium'
|
|
||||||
: 'text-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{m.icon} {m.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{routeInfo && (
|
{routeInfo && (
|
||||||
<div className="flex items-center justify-center gap-3 text-xs bg-slate-50 rounded-lg px-3 py-2">
|
<div className="flex items-center justify-center gap-3 text-xs bg-slate-50 rounded-lg px-3 py-2">
|
||||||
<span className="text-slate-900">🛣️ {routeInfo.distance}</span>
|
<span className="text-slate-900">🛣️ {routeInfo.distance}</span>
|
||||||
@@ -655,14 +627,14 @@ export default function PlannerSidebar({
|
|||||||
className="flex items-center justify-center gap-1.5 bg-slate-900 text-white text-xs py-2 rounded-lg hover:bg-slate-700 disabled:opacity-60 transition-colors"
|
className="flex items-center justify-center gap-1.5 bg-slate-900 text-white text-xs py-2 rounded-lg hover:bg-slate-700 disabled:opacity-60 transition-colors"
|
||||||
>
|
>
|
||||||
<Navigation className="w-3.5 h-3.5" />
|
<Navigation className="w-3.5 h-3.5" />
|
||||||
{isCalculatingRoute ? 'Berechne...' : 'Route'}
|
{isCalculatingRoute ? t('planner.calculating') : t('planner.route')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleOptimizeRoute}
|
onClick={handleOptimizeRoute}
|
||||||
className="flex items-center justify-center gap-1.5 bg-emerald-600 text-white text-xs py-2 rounded-lg hover:bg-emerald-700 transition-colors"
|
className="flex items-center justify-center gap-1.5 bg-emerald-600 text-white text-xs py-2 rounded-lg hover:bg-emerald-700 transition-colors"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
Optimieren
|
{t('planner.optimize')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -670,7 +642,7 @@ export default function PlannerSidebar({
|
|||||||
className="w-full flex items-center justify-center gap-1.5 border border-gray-200 text-gray-600 text-xs py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
className="w-full flex items-center justify-center gap-1.5 border border-gray-200 text-gray-600 text-xs py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-3.5 h-3.5" />
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
In Google Maps öffnen
|
{t('planner.openGoogleMaps')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -683,7 +655,7 @@ export default function PlannerSidebar({
|
|||||||
|
|
||||||
{totalCost > 0 && (
|
{totalCost > 0 && (
|
||||||
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
|
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
|
||||||
<span className="text-xs text-gray-500">Gesamtkosten</span>
|
<span className="text-xs text-gray-500">{t('planner.totalCost')}</span>
|
||||||
<span className="text-sm font-semibold text-gray-800">{totalCost.toFixed(2)} {currency}</span>
|
<span className="text-sm font-semibold text-gray-800">{totalCost.toFixed(2)} {currency}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -700,7 +672,7 @@ export default function PlannerSidebar({
|
|||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
placeholder="Orte suchen…"
|
placeholder={t('planner.searchPlaces')}
|
||||||
className="w-full pl-8 pr-8 py-2 bg-gray-100 rounded-[10px] text-sm focus:outline-none focus:bg-white focus:ring-2 focus:ring-slate-400 transition-colors"
|
className="w-full pl-8 pr-8 py-2 bg-gray-100 rounded-[10px] text-sm focus:outline-none focus:bg-white focus:ring-2 focus:ring-slate-400 transition-colors"
|
||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
@@ -715,7 +687,7 @@ export default function PlannerSidebar({
|
|||||||
onChange={e => setCategoryFilter(e.target.value)}
|
onChange={e => setCategoryFilter(e.target.value)}
|
||||||
className="flex-1 bg-gray-100 rounded-lg text-xs py-2 px-2 focus:outline-none text-gray-600"
|
className="flex-1 bg-gray-100 rounded-lg text-xs py-2 px-2 focus:outline-none text-gray-600"
|
||||||
>
|
>
|
||||||
<option value="">Alle Kategorien</option>
|
<option value="">{t('planner.allCategories')}</option>
|
||||||
{categories.map(c => (
|
{categories.map(c => (
|
||||||
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
|
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
|
||||||
))}
|
))}
|
||||||
@@ -725,7 +697,7 @@ export default function PlannerSidebar({
|
|||||||
className="flex items-center gap-1 bg-slate-900 text-white text-xs px-3 py-2 rounded-lg hover:bg-slate-700 whitespace-nowrap transition-colors"
|
className="flex items-center gap-1 bg-slate-900 text-white text-xs px-3 py-2 rounded-lg hover:bg-slate-700 whitespace-nowrap transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
Neu
|
{t('planner.new')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -733,9 +705,9 @@ export default function PlannerSidebar({
|
|||||||
{filteredPlaces.length === 0 ? (
|
{filteredPlaces.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||||
<span className="text-3xl mb-2">📍</span>
|
<span className="text-3xl mb-2">📍</span>
|
||||||
<p className="text-sm">Keine Orte gefunden</p>
|
<p className="text-sm">{t('planner.noPlacesFound')}</p>
|
||||||
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm">
|
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm">
|
||||||
Ersten Ort hinzufügen
|
{t('planner.addFirstPlace')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -782,7 +754,7 @@ export default function PlannerSidebar({
|
|||||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||||
className="text-[11px] text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100 transition-colors"
|
className="text-[11px] text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100 transition-colors"
|
||||||
>
|
>
|
||||||
+ Tag
|
{t('planner.addToDay')}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -805,7 +777,7 @@ export default function PlannerSidebar({
|
|||||||
<div>
|
<div>
|
||||||
<div className="px-4 py-3 flex items-center justify-between border-b border-gray-100">
|
<div className="px-4 py-3 flex items-center justify-between border-b border-gray-100">
|
||||||
<h3 className="font-medium text-sm text-gray-900">
|
<h3 className="font-medium text-sm text-gray-900">
|
||||||
Reservierungen
|
{t('planner.reservations')}
|
||||||
{selectedDay && <span className="text-gray-400 font-normal"> · Tag {selectedDay.day_number}</span>}
|
{selectedDay && <span className="text-gray-400 font-normal"> · Tag {selectedDay.day_number}</span>}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
@@ -813,13 +785,13 @@ export default function PlannerSidebar({
|
|||||||
className="flex items-center gap-1 bg-slate-900 text-white text-xs px-2.5 py-1.5 rounded-lg hover:bg-slate-700 transition-colors"
|
className="flex items-center gap-1 bg-slate-900 text-white text-xs px-2.5 py-1.5 rounded-lg hover:bg-slate-700 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
Hinzufügen
|
{t('common.add')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{filteredReservations.length === 0 ? (
|
{filteredReservations.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||||
<span className="text-3xl mb-2">🎫</span>
|
<span className="text-3xl mb-2">🎫</span>
|
||||||
<p className="text-sm">Keine Reservierungen</p>
|
<p className="text-sm">{t('planner.noReservations')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-3 space-y-2.5">
|
<div className="p-3 space-y-2.5">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink } from 'lucide-react'
|
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
|
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
|
||||||
@@ -18,19 +18,46 @@ const TYPE_OPTIONS = [
|
|||||||
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
|
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, selectedDayId, files = [], onFileUpload, onFileDelete }) {
|
function buildAssignmentOptions(days, assignments, t, locale) {
|
||||||
|
const options = []
|
||||||
|
for (const day of (days || [])) {
|
||||||
|
const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||||
|
if (da.length === 0) continue
|
||||||
|
const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number })
|
||||||
|
const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : ''
|
||||||
|
// Group header (non-selectable)
|
||||||
|
options.push({ value: `_header_${day.id}`, label: `${dayLabel}${dateStr}`, disabled: true, isHeader: true })
|
||||||
|
for (let i = 0; i < da.length; i++) {
|
||||||
|
const place = da[i].place
|
||||||
|
if (!place) continue
|
||||||
|
const timeStr = place.place_time ? ` · ${place.place_time}${place.end_time ? ' – ' + place.end_time : ''}` : ''
|
||||||
|
options.push({
|
||||||
|
value: da[i].id,
|
||||||
|
label: ` ${i + 1}. ${place.name}${timeStr}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete }) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
title: '', type: 'other', status: 'pending',
|
title: '', type: 'other', status: 'pending',
|
||||||
reservation_time: '', location: '', confirmation_number: '',
|
reservation_time: '', location: '', confirmation_number: '',
|
||||||
notes: '', day_id: '', place_id: '',
|
notes: '', assignment_id: '',
|
||||||
})
|
})
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [uploadingFile, setUploadingFile] = useState(false)
|
const [uploadingFile, setUploadingFile] = useState(false)
|
||||||
const [pendingFiles, setPendingFiles] = useState([]) // for new reservations
|
const [pendingFiles, setPendingFiles] = useState([])
|
||||||
|
|
||||||
|
const assignmentOptions = useMemo(
|
||||||
|
() => buildAssignmentOptions(days, assignments, t, locale),
|
||||||
|
[days, assignments, t, locale]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (reservation) {
|
if (reservation) {
|
||||||
@@ -42,14 +69,13 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
location: reservation.location || '',
|
location: reservation.location || '',
|
||||||
confirmation_number: reservation.confirmation_number || '',
|
confirmation_number: reservation.confirmation_number || '',
|
||||||
notes: reservation.notes || '',
|
notes: reservation.notes || '',
|
||||||
day_id: reservation.day_id || '',
|
assignment_id: reservation.assignment_id || '',
|
||||||
place_id: reservation.place_id || '',
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setForm({
|
setForm({
|
||||||
title: '', type: 'other', status: 'pending',
|
title: '', type: 'other', status: 'pending',
|
||||||
reservation_time: '', location: '', confirmation_number: '',
|
reservation_time: '', location: '', confirmation_number: '',
|
||||||
notes: '', day_id: selectedDayId || '', place_id: '',
|
notes: '', assignment_id: '',
|
||||||
})
|
})
|
||||||
setPendingFiles([])
|
setPendingFiles([])
|
||||||
}
|
}
|
||||||
@@ -64,10 +90,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
try {
|
try {
|
||||||
const saved = await onSave({
|
const saved = await onSave({
|
||||||
...form,
|
...form,
|
||||||
day_id: form.day_id || null,
|
assignment_id: form.assignment_id || null,
|
||||||
place_id: form.place_id || null,
|
|
||||||
})
|
})
|
||||||
// Upload pending files for newly created reservations
|
|
||||||
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
|
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
|
||||||
for (const file of pendingFiles) {
|
for (const file of pendingFiles) {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
@@ -86,7 +110,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
if (reservation?.id) {
|
if (reservation?.id) {
|
||||||
// Existing reservation — upload immediately
|
|
||||||
setUploadingFile(true)
|
setUploadingFile(true)
|
||||||
try {
|
try {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
@@ -102,7 +125,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// New reservation — stage locally
|
|
||||||
setPendingFiles(prev => [...prev, file])
|
setPendingFiles(prev => [...prev, file])
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
@@ -112,29 +134,29 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
|
|
||||||
const inputStyle = {
|
const inputStyle = {
|
||||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
padding: '8px 14px', fontSize: 13, fontFamily: 'inherit',
|
padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
|
||||||
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)',
|
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)',
|
||||||
}
|
}
|
||||||
const labelStyle = { display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 5 }
|
const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="md">
|
<Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="2xl">
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
|
||||||
{/* Type selector */}
|
{/* Type selector */}
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('reservations.bookingType')}</label>
|
<label style={labelStyle}>{t('reservations.bookingType')}</label>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
||||||
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => (
|
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => (
|
||||||
<button key={value} type="button" onClick={() => set('type', value)} style={{
|
<button key={value} type="button" onClick={() => set('type', value)} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 5,
|
display: 'flex', alignItems: 'center', gap: 4,
|
||||||
padding: '6px 11px', borderRadius: 99, border: '1px solid',
|
padding: '5px 10px', borderRadius: 99, border: '1px solid',
|
||||||
fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
|
fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
|
||||||
background: form.type === value ? 'var(--text-primary)' : 'var(--bg-card)',
|
background: form.type === value ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||||
borderColor: form.type === value ? 'var(--text-primary)' : 'var(--border-primary)',
|
borderColor: form.type === value ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
color: form.type === value ? 'var(--bg-primary)' : 'var(--text-muted)',
|
color: form.type === value ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||||
}}>
|
}}>
|
||||||
<Icon size={12} /> {t(labelKey)}
|
<Icon size={11} /> {t(labelKey)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -147,8 +169,29 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
|
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Assignment Picker */}
|
||||||
|
{assignmentOptions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
|
||||||
|
{t('reservations.linkAssignment')}
|
||||||
|
</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={form.assignment_id}
|
||||||
|
onChange={value => set('assignment_id', value)}
|
||||||
|
placeholder={t('reservations.pickAssignment')}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: t('reservations.noAssignment') },
|
||||||
|
...assignmentOptions,
|
||||||
|
]}
|
||||||
|
searchable
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Date/Time + Status */}
|
{/* Date/Time + Status */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 10 }}>
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('reservations.datetime')}</label>
|
<label style={labelStyle}>{t('reservations.datetime')}</label>
|
||||||
<CustomDateTimePicker value={form.reservation_time} onChange={v => set('reservation_time', v)} />
|
<CustomDateTimePicker value={form.reservation_time} onChange={v => set('reservation_time', v)} />
|
||||||
@@ -167,108 +210,61 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Location */}
|
{/* Location + Booking Code */}
|
||||||
<div>
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<label style={labelStyle}>{t('reservations.locationAddress')}</label>
|
|
||||||
<input type="text" value={form.location} onChange={e => set('location', e.target.value)}
|
|
||||||
placeholder={t('reservations.locationPlaceholder')} style={inputStyle} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Confirmation number */}
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>{t('reservations.confirmationCode')}</label>
|
|
||||||
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
|
|
||||||
placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Linked day + place */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('reservations.day')}</label>
|
<label style={labelStyle}>{t('reservations.locationAddress')}</label>
|
||||||
<CustomSelect
|
<input type="text" value={form.location} onChange={e => set('location', e.target.value)}
|
||||||
value={form.day_id}
|
placeholder={t('reservations.locationPlaceholder')} style={inputStyle} />
|
||||||
onChange={value => set('day_id', value)}
|
|
||||||
placeholder={t('reservations.noDay')}
|
|
||||||
options={[
|
|
||||||
{ value: '', label: t('reservations.noDay') },
|
|
||||||
...(days || []).map(day => ({
|
|
||||||
value: day.id,
|
|
||||||
label: `${t('reservations.day')} ${day.day_number}${day.date ? ` · ${formatDate(day.date)}` : ''}`,
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('reservations.place')}</label>
|
<label style={labelStyle}>{t('reservations.confirmationCode')}</label>
|
||||||
<CustomSelect
|
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
|
||||||
value={form.place_id}
|
placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
|
||||||
onChange={value => set('place_id', value)}
|
|
||||||
placeholder={t('reservations.noPlace')}
|
|
||||||
options={[
|
|
||||||
{ value: '', label: t('reservations.noPlace') },
|
|
||||||
...(places || []).map(place => ({
|
|
||||||
value: place.id,
|
|
||||||
label: place.name,
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
searchable
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('reservations.notes')}</label>
|
<label style={labelStyle}>{t('reservations.notes')}</label>
|
||||||
<textarea value={form.notes} onChange={e => set('notes', e.target.value)} rows={3}
|
<textarea value={form.notes} onChange={e => set('notes', e.target.value)} rows={2}
|
||||||
placeholder={t('reservations.notesPlaceholder')}
|
placeholder={t('reservations.notesPlaceholder')}
|
||||||
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File upload — always visible */}
|
{/* Files */}
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('files.title')}</label>
|
<label style={labelStyle}>{t('files.title')}</label>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
{attachedFiles.map(f => (
|
{attachedFiles.map(f => (
|
||||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
|
||||||
<FileText size={13} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
<span style={{ flex: 1, fontSize: 12.5, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||||
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }} title={t('common.open')}>
|
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}><ExternalLink size={11} /></a>
|
||||||
<ExternalLink size={12} />
|
|
||||||
</a>
|
|
||||||
{onFileDelete && (
|
{onFileDelete && (
|
||||||
<button type="button" onClick={() => onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}
|
<button type="button" onClick={() => onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
<X size={11} />
|
||||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{pendingFiles.map((f, i) => (
|
{pendingFiles.map((f, i) => (
|
||||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
|
||||||
<FileText size={13} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
<span style={{ flex: 1, fontSize: 12.5, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{t('reservations.pendingSave')}</span>
|
|
||||||
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
<X size={11} />
|
||||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 12px',
|
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'var(--bg-card)',
|
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||||
fontSize: 12.5, color: 'var(--text-muted)', cursor: uploadingFile ? 'default' : 'pointer',
|
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||||
fontFamily: 'inherit', transition: 'all 0.12s',
|
}}>
|
||||||
}}
|
<Paperclip size={11} />
|
||||||
onMouseEnter={e => { if (!uploadingFile) { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-secondary)' } }}
|
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-muted)' }}>
|
|
||||||
<Paperclip size={13} />
|
|
||||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -276,10 +272,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
||||||
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-secondary)' }}>
|
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
|
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
|
||||||
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,8 +284,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr, locale) {
|
||||||
if (!dateStr) return ''
|
if (!dateStr) return ''
|
||||||
const d = new Date(dateStr + 'T00:00:00')
|
const d = new Date(dateStr + 'T00:00:00')
|
||||||
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' })
|
return d.toLocaleDateString(locale || 'de-DE', { day: 'numeric', month: 'short' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,160 +1,52 @@
|
|||||||
import React, { useState, useMemo } from 'react'
|
import React, { useState, useMemo } from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
|
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
|
||||||
import {
|
import {
|
||||||
Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, MapPin,
|
Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, MapPin,
|
||||||
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, MapPinned, X, Users,
|
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
|
||||||
ExternalLink, BookMarked, Lightbulb,
|
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane, color: '#3b82f6' },
|
||||||
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
|
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel, color: '#8b5cf6' },
|
||||||
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils },
|
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils, color: '#ef4444' },
|
||||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train, color: '#06b6d4' },
|
||||||
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
|
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car, color: '#6b7280' },
|
||||||
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
|
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship, color: '#0ea5e9' },
|
||||||
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket },
|
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket, color: '#f59e0b' },
|
||||||
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users },
|
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users, color: '#10b981' },
|
||||||
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
|
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText, color: '#6b7280' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function typeIcon(type) {
|
function getType(type) {
|
||||||
return (TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]).Icon
|
return TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]
|
||||||
}
|
|
||||||
function typeLabelKey(type) {
|
|
||||||
return (TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]).labelKey
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTimeWithLocale(str, locale, timeFormat) {
|
function buildAssignmentLookup(days, assignments) {
|
||||||
if (!str) return null
|
const map = {}
|
||||||
const d = new Date(str)
|
for (const day of (days || [])) {
|
||||||
if (isNaN(d)) return str
|
const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||||
const datePart = d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'long' })
|
for (const a of da) {
|
||||||
const h = d.getHours(), m = d.getMinutes()
|
if (!a.place) continue
|
||||||
let timePart
|
map[a.id] = { dayNumber: day.day_number, dayTitle: day.title, dayDate: day.date, placeName: a.place.name, startTime: a.place.place_time, endTime: a.place.end_time }
|
||||||
if (timeFormat === '12h') {
|
|
||||||
const period = h >= 12 ? 'PM' : 'AM'
|
|
||||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
|
||||||
timePart = `${h12}:${String(m).padStart(2, '0')} ${period}`
|
|
||||||
} else {
|
|
||||||
timePart = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
|
||||||
if (locale?.startsWith('de')) timePart += ' Uhr'
|
|
||||||
}
|
|
||||||
return `${datePart} · ${timePart}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputStyle = {
|
|
||||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
|
||||||
padding: '8px 12px', fontSize: 13.5, fontFamily: 'inherit',
|
|
||||||
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-card)',
|
|
||||||
}
|
|
||||||
const labelStyle = { display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 5 }
|
|
||||||
|
|
||||||
function PlaceReservationEditModal({ item, tripId, onClose }) {
|
|
||||||
const { updatePlace } = useTripStore()
|
|
||||||
const toast = useToast()
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const [form, setForm] = useState({
|
|
||||||
reservation_status: item.status === 'confirmed' ? 'confirmed' : 'pending',
|
|
||||||
reservation_datetime: item.reservation_time ? item.reservation_time.slice(0, 16) : '',
|
|
||||||
place_time: item.place_time || '',
|
|
||||||
reservation_notes: item.notes || '',
|
|
||||||
})
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
|
|
||||||
const set = (f, v) => setForm(p => ({ ...p, [f]: v }))
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
await updatePlace(tripId, item.placeId, {
|
|
||||||
reservation_status: form.reservation_status,
|
|
||||||
reservation_datetime: form.reservation_datetime || null,
|
|
||||||
place_time: form.place_time || null,
|
|
||||||
reservation_notes: form.reservation_notes || null,
|
|
||||||
})
|
|
||||||
toast.success(t('reservations.toast.updated'))
|
|
||||||
onClose()
|
|
||||||
} catch {
|
|
||||||
toast.error(t('reservations.toast.saveError'))
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return map
|
||||||
return ReactDOM.createPortal(
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 9000,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
|
|
||||||
}} onClick={onClose}>
|
|
||||||
<div style={{
|
|
||||||
background: 'var(--bg-card)', borderRadius: 18, padding: 24, width: '100%', maxWidth: 420,
|
|
||||||
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
|
||||||
}} onClick={e => e.stopPropagation()}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
|
||||||
<div>
|
|
||||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('reservations.editTitle')}</h3>
|
|
||||||
<p style={{ margin: '3px 0 0', fontSize: 12, color: 'var(--text-faint)' }}>{item.title}</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={onClose} style={{ background: 'var(--bg-tertiary)', border: 'none', borderRadius: '50%', width: 30, height: 30, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
|
||||||
<CustomSelect
|
|
||||||
value={form.reservation_status}
|
|
||||||
onChange={v => set('reservation_status', v)}
|
|
||||||
options={[
|
|
||||||
{ value: 'pending', label: t('reservations.pending') },
|
|
||||||
{ value: 'confirmed', label: t('reservations.confirmed') },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>{t('reservations.datetime')}</label>
|
|
||||||
<CustomDateTimePicker value={form.reservation_datetime} onChange={v => set('reservation_datetime', v)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>{t('reservations.notes')}</label>
|
|
||||||
<textarea value={form.reservation_notes} onChange={e => set('reservation_notes', e.target.value)} rows={3} placeholder={t('reservations.notesPlaceholder')} style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
|
|
||||||
<button onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-secondary)' }}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
<button onClick={handleSave} disabled={saving} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--accent)', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: saving ? 0.6 : 1 }}>
|
|
||||||
{saving ? t('common.saving') : t('common.save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles }) {
|
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }) {
|
||||||
const { toggleReservationStatus } = useTripStore()
|
const { toggleReservationStatus } = useTripStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||||
const TypeIcon = typeIcon(r.type)
|
const typeInfo = getType(r.type)
|
||||||
|
const TypeIcon = typeInfo.Icon
|
||||||
const confirmed = r.status === 'confirmed'
|
const confirmed = r.status === 'confirmed'
|
||||||
const attachedFiles = files.filter(f => f.reservation_id === r.id)
|
const attachedFiles = files.filter(f => f.reservation_id === r.id)
|
||||||
|
const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null
|
||||||
|
|
||||||
const handleToggle = async () => {
|
const handleToggle = async () => {
|
||||||
try { await toggleReservationStatus(tripId, r.id) }
|
try { await toggleReservationStatus(tripId, r.id) }
|
||||||
@@ -165,184 +57,137 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
|
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const fmtDate = (str) => {
|
||||||
<div style={{ background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-faint)', boxShadow: '0 1px 6px rgba(0,0,0,0.05)', overflow: 'hidden' }}>
|
const d = new Date(str)
|
||||||
<div style={{ display: 'flex', alignItems: 'stretch' }}>
|
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||||
<div style={{
|
}
|
||||||
width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
const fmtTime = (str) => {
|
||||||
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(161,98,7,0.1)',
|
const d = new Date(str)
|
||||||
borderRight: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(161,98,7,0.2)'}`,
|
return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||||
}}>
|
|
||||||
<TypeIcon size={16} style={{ color: confirmed ? '#16a34a' : '#a16207' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ flex: 1, padding: '11px 13px', minWidth: 0 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 13.5, color: 'var(--text-primary)', lineHeight: 1.3 }}>{r.title}</div>
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>{t(typeLabelKey(r.type))}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
|
|
||||||
<button onClick={handleToggle} style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 3, padding: '3px 8px', borderRadius: 99,
|
|
||||||
border: 'none', cursor: 'pointer', fontSize: 11, fontWeight: 500,
|
|
||||||
background: confirmed ? 'rgba(22,163,74,0.12)' : 'rgba(161,98,7,0.12)',
|
|
||||||
color: confirmed ? '#16a34a' : '#a16207',
|
|
||||||
}}>
|
|
||||||
{confirmed ? <><CheckCircle2 size={11} /> {t('reservations.confirmed')}</> : <><Circle size={11} /> {t('reservations.pending')}</>}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => onEdit(r)} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Pencil size={12} /></button>
|
|
||||||
<button onClick={handleDelete} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Trash2 size={12} /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 7, display: 'flex', flexWrap: 'wrap', gap: '3px 10px' }}>
|
|
||||||
{r.reservation_time && (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
|
|
||||||
<Calendar size={10} style={{ color: 'var(--text-faint)' }} />{formatDateTimeWithLocale(r.reservation_time, locale, timeFormat)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{r.location && (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
|
|
||||||
<MapPin size={10} style={{ color: 'var(--text-faint)' }} />
|
|
||||||
<span style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.location}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 5, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
|
||||||
{r.confirmation_number && (
|
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 10.5, color: '#16a34a', background: 'rgba(22,163,74,0.1)', border: '1px solid rgba(22,163,74,0.2)', borderRadius: 99, padding: '1px 7px', fontWeight: 600 }}>
|
|
||||||
<Hash size={8} />{r.confirmation_number}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{r.day_number != null && <span style={{ fontSize: 10.5, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '1px 7px' }}>{t('dayplan.dayN', { n: r.day_number })}</span>}
|
|
||||||
{r.place_name && <span style={{ fontSize: 10.5, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '1px 7px' }}>{r.place_name}</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{r.notes && <p style={{ margin: '7px 0 0', fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, borderTop: '1px solid var(--border-secondary)', paddingTop: 7 }}>{r.notes}</p>}
|
|
||||||
|
|
||||||
{/* Attached files — read-only, upload only via edit modal */}
|
|
||||||
{attachedFiles.length > 0 && (
|
|
||||||
<div style={{ marginTop: 8, borderTop: '1px solid var(--border-secondary)', paddingTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
||||||
{attachedFiles.map(f => (
|
|
||||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<FileText size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
|
||||||
<span style={{ fontSize: 11.5, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
|
||||||
<a href={f.url} target="_blank" rel="noreferrer" style={{ display: 'flex', color: 'var(--text-faint)', flexShrink: 0 }} title={t('common.open')}>
|
|
||||||
<ExternalLink size={11} />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button onClick={onNavigateToFiles} style={{ alignSelf: 'flex-start', fontSize: 11, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: 0, textDecoration: 'underline', fontFamily: 'inherit' }}>
|
|
||||||
{t('reservations.showFiles')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlaceReservationCard({ item, tripId }) {
|
|
||||||
const { updatePlace } = useTripStore()
|
|
||||||
const toast = useToast()
|
|
||||||
const { t, locale } = useTranslation()
|
|
||||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const confirmed = item.status === 'confirmed'
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!confirm(t('reservations.confirm.remove', { name: item.title }))) return
|
|
||||||
try {
|
|
||||||
await updatePlace(tripId, item.placeId, {
|
|
||||||
reservation_status: 'none',
|
|
||||||
reservation_datetime: null,
|
|
||||||
place_time: null,
|
|
||||||
reservation_notes: null,
|
|
||||||
})
|
|
||||||
toast.success(t('reservations.toast.removed'))
|
|
||||||
} catch { toast.error(t('reservations.toast.deleteError')) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
|
||||||
{editing && <PlaceReservationEditModal item={item} tripId={tripId} onClose={() => setEditing(false)} />}
|
{/* Header bar */}
|
||||||
<div style={{ background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-faint)', boxShadow: '0 1px 6px rgba(0,0,0,0.05)', overflow: 'hidden' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'stretch' }}>
|
<div style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
|
||||||
<div style={{
|
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
|
||||||
width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||||
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(161,98,7,0.1)',
|
</button>
|
||||||
borderRight: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(161,98,7,0.2)'}`,
|
<div style={{ width: 1, height: 10, background: 'var(--border-faint)' }} />
|
||||||
}}>
|
<TypeIcon size={11} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
||||||
<MapPinned size={16} style={{ color: confirmed ? '#16a34a' : '#a16207' }} />
|
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{t(typeInfo.labelKey)}</span>
|
||||||
</div>
|
<span style={{ flex: 1 }} />
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||||
|
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
|
<Pencil size={11} />
|
||||||
|
</button>
|
||||||
|
<button onClick={handleDelete} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, padding: '11px 13px', minWidth: 0 }}>
|
{/* Details */}
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
|
{(r.reservation_time || r.confirmation_number || r.location || linked) && (
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 13.5, color: 'var(--text-primary)', lineHeight: 1.3 }}>{item.title}</div>
|
{/* Row 1: Date, Time, Code */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 2, flexWrap: 'nowrap', overflow: 'hidden' }}>
|
{(r.reservation_time || r.confirmation_number) && (
|
||||||
<span className="hidden sm:inline" style={{ fontSize: 10.5, color: 'var(--text-faint)', flexShrink: 0 }}>{t('reservations.fromPlan')}</span>
|
<div style={{ display: 'flex', gap: 0, borderRadius: 8, overflow: 'hidden', background: 'var(--bg-secondary)', boxShadow: '0 1px 6px rgba(0,0,0,0.08)' }}>
|
||||||
{item.dayLabel && <span style={{ fontSize: 10.5, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '0 6px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.dayLabel}</span>}
|
{r.reservation_time && (
|
||||||
</div>
|
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
||||||
</div>
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.date')}</div>
|
||||||
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{fmtDate(r.reservation_time)}</div>
|
||||||
<span style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 3, padding: '3px 8px', borderRadius: 99,
|
|
||||||
fontSize: 11, fontWeight: 500,
|
|
||||||
background: confirmed ? 'rgba(22,163,74,0.12)' : 'rgba(161,98,7,0.12)',
|
|
||||||
color: confirmed ? '#16a34a' : '#a16207',
|
|
||||||
}}>
|
|
||||||
{confirmed ? <><CheckCircle2 size={11} /> {t('reservations.confirmed')}</> : <><Circle size={11} /> {t('reservations.pending')}</>}
|
|
||||||
</span>
|
|
||||||
<button onClick={() => setEditing(true)} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Pencil size={12} /></button>
|
|
||||||
<button onClick={handleDelete} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Trash2 size={12} /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 7, display: 'flex', flexWrap: 'wrap', gap: '3px 10px' }}>
|
|
||||||
{item.reservation_time && (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
|
|
||||||
<Calendar size={10} style={{ color: 'var(--text-faint)' }} />{formatDateTimeWithLocale(item.reservation_time, locale, timeFormat)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.place_time && !item.reservation_time && (
|
{r.reservation_time && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
|
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
||||||
<Calendar size={10} style={{ color: 'var(--text-faint)' }} />{item.place_time}
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{fmtTime(r.reservation_time)}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.location && (
|
{r.confirmation_number && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
|
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center' }}>
|
||||||
<MapPin size={10} style={{ color: 'var(--text-faint)' }} />
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.confirmationCode')}</div>
|
||||||
<span style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.location}</span>
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{r.confirmation_number}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Row 2: Location + Assignment */}
|
||||||
|
{(r.location || linked) && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: r.location && linked ? '1fr 1fr' : '1fr', gap: 8, paddingTop: 6, borderTop: '1px solid var(--border-faint)' }}>
|
||||||
|
{r.location && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.locationAddress')}</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
<MapPin size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.location}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{linked && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.linkAssignment')}</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
<Link2 size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{linked.dayTitle || t('dayplan.dayN', { n: linked.dayNumber })} — {linked.placeName}
|
||||||
|
{linked.startTime ? ` · ${linked.startTime}${linked.endTime ? ' – ' + linked.endTime : ''}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{item.notes && <p style={{ margin: '7px 0 0', fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, borderTop: '1px solid var(--border-secondary)', paddingTop: 7 }}>{item.notes}</p>}
|
{/* Notes */}
|
||||||
|
{r.notes && (
|
||||||
|
<div style={{ padding: '0 12px 8px' }}>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
|
||||||
|
<div style={{ padding: '5px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||||
|
{r.notes}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</>
|
|
||||||
|
{/* Files */}
|
||||||
|
{attachedFiles.length > 0 && (
|
||||||
|
<div style={{ padding: '0 12px 8px' }}>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('files.title')}</div>
|
||||||
|
<div style={{ padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
{attachedFiles.map(f => (
|
||||||
|
<a key={f.id} href={f.url} target="_blank" rel="noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
|
||||||
|
<FileText size={9} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({ title, count, children, defaultOpen = true, accent }) {
|
function Section({ title, count, children, defaultOpen = true, accent }) {
|
||||||
const [open, setOpen] = useState(defaultOpen)
|
const [open, setOpen] = useState(defaultOpen)
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<button onClick={() => setOpen(o => !o)} style={{
|
<button onClick={() => setOpen(o => !o)} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||||
background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 10,
|
background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 8, fontFamily: 'inherit',
|
||||||
}}>
|
}}>
|
||||||
{open ? <ChevronDown size={15} style={{ color: 'var(--text-faint)' }} /> : <ChevronRight size={15} style={{ color: 'var(--text-faint)' }} />}
|
{open ? <ChevronDown size={14} style={{ color: 'var(--text-faint)' }} /> : <ChevronRight size={14} style={{ color: 'var(--text-faint)' }} />}
|
||||||
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>{title}</span>
|
<span style={{ fontWeight: 700, fontSize: 12, color: 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{title}</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: 11, fontWeight: 600, padding: '1px 8px', borderRadius: 99,
|
fontSize: 10, fontWeight: 700, padding: '1px 7px', borderRadius: 99,
|
||||||
background: accent === 'green' ? 'rgba(22,163,74,0.12)' : 'var(--bg-tertiary)',
|
background: accent === 'green' ? 'rgba(22,163,74,0.1)' : 'var(--bg-tertiary)',
|
||||||
color: accent === 'green' ? '#16a34a' : 'var(--text-muted)',
|
color: accent === 'green' ? '#16a34a' : 'var(--text-faint)',
|
||||||
}}>{count}</span>
|
}}>{count}</span>
|
||||||
</button>
|
</button>
|
||||||
{open && <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{children}</div>}
|
{open && <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{children}</div>}
|
||||||
@@ -354,98 +199,66 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
|||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
|
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
|
||||||
|
|
||||||
const placeReservations = useMemo(() => {
|
const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
|
||||||
const result = []
|
|
||||||
for (const day of (days || [])) {
|
|
||||||
const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
|
||||||
for (const assignment of da) {
|
|
||||||
const place = assignment.place
|
|
||||||
if (!place || !place.reservation_status || place.reservation_status === 'none') continue
|
|
||||||
const dayLabel = day.title
|
|
||||||
? day.title
|
|
||||||
: day.date
|
|
||||||
? `${t('dayplan.dayN', { n: day.day_number })} · ${new Date(day.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}`
|
|
||||||
: t('dayplan.dayN', { n: day.day_number })
|
|
||||||
result.push({
|
|
||||||
_placeRes: true,
|
|
||||||
id: `place_${day.id}_${place.id}`,
|
|
||||||
placeId: place.id,
|
|
||||||
title: place.name,
|
|
||||||
status: place.reservation_status === 'confirmed' ? 'confirmed' : 'pending',
|
|
||||||
reservation_time: place.reservation_datetime || null,
|
|
||||||
place_time: place.place_time || null,
|
|
||||||
location: place.address || null,
|
|
||||||
notes: place.reservation_notes || null,
|
|
||||||
dayLabel,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}, [days, assignments, locale])
|
|
||||||
|
|
||||||
const allPending = [...reservations.filter(r => r.status !== 'confirmed'), ...placeReservations.filter(r => r.status !== 'confirmed')]
|
const allPending = reservations.filter(r => r.status !== 'confirmed')
|
||||||
const allConfirmed = [...reservations.filter(r => r.status === 'confirmed'), ...placeReservations.filter(r => r.status === 'confirmed')]
|
const allConfirmed = reservations.filter(r => r.status === 'confirmed')
|
||||||
const total = allPending.length + allConfirmed.length
|
const total = reservations.length
|
||||||
|
|
||||||
function renderCard(r) {
|
|
||||||
if (r._placeRes) return <PlaceReservationCard key={r.id} item={r} tripId={tripId} />
|
|
||||||
return <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||||
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
{/* Header */}
|
||||||
|
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid var(--border-faint)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('reservations.title')}</h2>
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('reservations.title')}</h2>
|
||||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
|
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'var(--text-faint)' }}>
|
||||||
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
|
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onAdd} style={{
|
<button onClick={onAdd} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 14px', borderRadius: 99,
|
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
|
||||||
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
|
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||||
fontSize: 12.5, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
}}>
|
}}>
|
||||||
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hinweis — einmalig wegklickbar */}
|
{/* Hint */}
|
||||||
{showHint && (
|
{showHint && (
|
||||||
<div style={{ margin: '12px 24px 8px', padding: '8px 12px', borderRadius: 10, background: 'var(--bg-hover)', display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
<div style={{ margin: '12px 24px 4px', padding: '8px 12px', borderRadius: 10, background: 'var(--bg-hover)', display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||||
<Lightbulb size={13} style={{ flexShrink: 0, marginTop: 1, color: 'var(--text-faint)' }} />
|
<Lightbulb size={12} style={{ flexShrink: 0, marginTop: 1, color: 'var(--text-faint)' }} />
|
||||||
<p style={{ fontSize: 11.5, color: 'var(--text-muted)', margin: 0, lineHeight: 1.5, flex: 1 }}>
|
<p style={{ fontSize: 11, color: 'var(--text-muted)', margin: 0, lineHeight: 1.5, flex: 1 }}>{t('reservations.placeHint')}</p>
|
||||||
{t('reservations.placeHint')}
|
<button onClick={() => { setShowHint(false); localStorage.setItem('hideReservationHint', '1') }}
|
||||||
</p>
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0 4px', color: 'var(--text-faint)', fontSize: 14, lineHeight: 1, flexShrink: 0 }}>×</button>
|
||||||
<button
|
|
||||||
onClick={() => { setShowHint(false); localStorage.setItem('hideReservationHint', '1') }}
|
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0 4px', color: 'var(--text-faint)', fontSize: 16, lineHeight: 1, flexShrink: 0 }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
|
||||||
>×</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px' }}>
|
{/* Content */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px' }}>
|
||||||
{total === 0 ? (
|
{total === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||||
<BookMarked size={40} style={{ marginBottom: 12, color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
<BookMarked size={36} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('reservations.empty')}</p>
|
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('reservations.empty')}</p>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('reservations.emptyHint')}</p>
|
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0 }}>{t('reservations.emptyHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<>
|
||||||
{allPending.length > 0 && (
|
{allPending.length > 0 && (
|
||||||
<Section title={t('reservations.pending')} count={allPending.length} defaultOpen={true} accent="gray">
|
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
|
||||||
{allPending.map(renderCard)}
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
|
||||||
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
{allConfirmed.length > 0 && (
|
{allConfirmed.length > 0 && (
|
||||||
<Section title={t('reservations.confirmed')} count={allConfirmed.length} defaultOpen={true} accent="green">
|
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
|
||||||
{allConfirmed.map(renderCard)}
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
|
||||||
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,19 +6,7 @@ import { ReservationModal } from './ReservationModal'
|
|||||||
import { PlaceDetailPanel } from './PlaceDetailPanel'
|
import { PlaceDetailPanel } from './PlaceDetailPanel'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
const TABS = [
|
|
||||||
{ id: 'orte', label: 'Orte', icon: '📍' },
|
|
||||||
{ id: 'tagesplan', label: 'Tagesplan', icon: '📅' },
|
|
||||||
{ id: 'reservierungen', label: 'Reservierungen', icon: '🎫' },
|
|
||||||
{ id: 'packliste', label: 'Packliste', icon: '🎒' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const TRANSPORT_MODES = [
|
|
||||||
{ value: 'driving', label: 'Auto', icon: '🚗' },
|
|
||||||
{ value: 'walking', label: 'Fuß', icon: '🚶' },
|
|
||||||
{ value: 'cycling', label: 'Rad', icon: '🚲' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function RightPanel({
|
export function RightPanel({
|
||||||
trip, days, places, categories, tags,
|
trip, days, places, categories, tags,
|
||||||
@@ -31,7 +19,6 @@ export function RightPanel({
|
|||||||
const [activeTab, setActiveTab] = useState('orte')
|
const [activeTab, setActiveTab] = useState('orte')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [categoryFilter, setCategoryFilter] = useState('')
|
const [categoryFilter, setCategoryFilter] = useState('')
|
||||||
const [transportMode, setTransportMode] = useState('driving')
|
|
||||||
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
|
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
|
||||||
const [showReservationModal, setShowReservationModal] = useState(false)
|
const [showReservationModal, setShowReservationModal] = useState(false)
|
||||||
const [editingReservation, setEditingReservation] = useState(null)
|
const [editingReservation, setEditingReservation] = useState(null)
|
||||||
@@ -39,6 +26,14 @@ export function RightPanel({
|
|||||||
|
|
||||||
const tripStore = useTripStore()
|
const tripStore = useTripStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'orte', label: t('planner.places'), icon: '📍' },
|
||||||
|
{ id: 'tagesplan', label: t('planner.dayPlan'), icon: '📅' },
|
||||||
|
{ id: 'reservierungen', label: t('planner.reservations'), icon: '🎫' },
|
||||||
|
{ id: 'packliste', label: t('planner.packingList'), icon: '🎒' },
|
||||||
|
]
|
||||||
|
|
||||||
// Filtered places for Orte tab
|
// Filtered places for Orte tab
|
||||||
const filteredPlaces = places.filter(p => {
|
const filteredPlaces = places.filter(p => {
|
||||||
@@ -83,22 +78,22 @@ export function RightPanel({
|
|||||||
.map(p => ({ lat: p.lat, lng: p.lng }))
|
.map(p => ({ lat: p.lat, lng: p.lng }))
|
||||||
|
|
||||||
if (waypoints.length < 2) {
|
if (waypoints.length < 2) {
|
||||||
toast.error('Mindestens 2 Orte mit Koordinaten benötigt')
|
toast.error(t('planner.minTwoPlaces'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsCalculatingRoute(true)
|
setIsCalculatingRoute(true)
|
||||||
try {
|
try {
|
||||||
const result = await calculateRoute(waypoints, transportMode)
|
const result = await calculateRoute(waypoints, 'walking')
|
||||||
if (result) {
|
if (result) {
|
||||||
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
|
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
|
||||||
onRouteCalculated?.(result)
|
onRouteCalculated?.(result)
|
||||||
toast.success('Route berechnet')
|
toast.success(t('planner.routeCalculated'))
|
||||||
} else {
|
} else {
|
||||||
toast.error('Route konnte nicht berechnet werden')
|
toast.error(t('planner.routeCalcFailed'))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error('Fehler bei der Routenberechnung')
|
toast.error(t('planner.routeError'))
|
||||||
} finally {
|
} finally {
|
||||||
setIsCalculatingRoute(false)
|
setIsCalculatingRoute(false)
|
||||||
}
|
}
|
||||||
@@ -113,14 +108,14 @@ export function RightPanel({
|
|||||||
return a?.id
|
return a?.id
|
||||||
}).filter(Boolean)
|
}).filter(Boolean)
|
||||||
await onReorder(selectedDayId, optimizedIds)
|
await onReorder(selectedDayId, optimizedIds)
|
||||||
toast.success('Route optimiert')
|
toast.success(t('planner.routeOptimized'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenGoogleMaps = () => {
|
const handleOpenGoogleMaps = () => {
|
||||||
const places = dayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
|
const places = dayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||||
const url = generateGoogleMapsUrl(places)
|
const url = generateGoogleMapsUrl(places)
|
||||||
if (url) window.open(url, '_blank')
|
if (url) window.open(url, '_blank')
|
||||||
else toast.error('Keine Orte mit Koordinaten vorhanden')
|
else toast.error(t('planner.noGeoPlaces'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMoveUp = async (idx) => {
|
const handleMoveUp = async (idx) => {
|
||||||
@@ -146,10 +141,10 @@ export function RightPanel({
|
|||||||
try {
|
try {
|
||||||
if (editingReservation) {
|
if (editingReservation) {
|
||||||
await tripStore.updateReservation(tripId, editingReservation.id, data)
|
await tripStore.updateReservation(tripId, editingReservation.id, data)
|
||||||
toast.success('Reservierung aktualisiert')
|
toast.success(t('planner.reservationUpdated'))
|
||||||
} else {
|
} else {
|
||||||
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
||||||
toast.success('Reservierung hinzugefügt')
|
toast.success(t('planner.reservationAdded'))
|
||||||
}
|
}
|
||||||
setShowReservationModal(false)
|
setShowReservationModal(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -158,10 +153,10 @@ export function RightPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteReservation = async (id) => {
|
const handleDeleteReservation = async (id) => {
|
||||||
if (!confirm('Reservierung löschen?')) return
|
if (!confirm(t('planner.confirmDeleteReservation'))) return
|
||||||
try {
|
try {
|
||||||
await tripStore.deleteReservation(tripId, id)
|
await tripStore.deleteReservation(tripId, id)
|
||||||
toast.success('Reservierung gelöscht')
|
toast.success(t('planner.reservationDeleted'))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message)
|
toast.error(err.message)
|
||||||
}
|
}
|
||||||
@@ -226,7 +221,7 @@ export function RightPanel({
|
|||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
placeholder="Orte suchen..."
|
placeholder={t('planner.searchPlaces')}
|
||||||
className="w-full pl-8 pr-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
|
className="w-full pl-8 pr-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
|
||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
@@ -241,7 +236,7 @@ export function RightPanel({
|
|||||||
onChange={e => setCategoryFilter(e.target.value)}
|
onChange={e => setCategoryFilter(e.target.value)}
|
||||||
className="flex-1 border border-gray-200 rounded-lg text-xs py-1.5 px-2 focus:outline-none focus:ring-1 focus:ring-slate-900 text-gray-600"
|
className="flex-1 border border-gray-200 rounded-lg text-xs py-1.5 px-2 focus:outline-none focus:ring-1 focus:ring-slate-900 text-gray-600"
|
||||||
>
|
>
|
||||||
<option value="">Alle Kategorien</option>
|
<option value="">{t('planner.allCategories')}</option>
|
||||||
{categories.map(c => (
|
{categories.map(c => (
|
||||||
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
|
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
|
||||||
))}
|
))}
|
||||||
@@ -251,7 +246,7 @@ export function RightPanel({
|
|||||||
className="flex items-center gap-1 bg-slate-700 text-white text-xs px-3 py-1.5 rounded-lg hover:bg-slate-900 whitespace-nowrap"
|
className="flex items-center gap-1 bg-slate-700 text-white text-xs px-3 py-1.5 rounded-lg hover:bg-slate-900 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
Ort hinzufügen
|
{t('planner.addPlace')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,9 +256,9 @@ export function RightPanel({
|
|||||||
{filteredPlaces.length === 0 ? (
|
{filteredPlaces.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||||
<span className="text-3xl mb-2">📍</span>
|
<span className="text-3xl mb-2">📍</span>
|
||||||
<p className="text-sm">Keine Orte gefunden</p>
|
<p className="text-sm">{t('planner.noPlacesFound')}</p>
|
||||||
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm hover:underline">
|
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm hover:underline">
|
||||||
Ersten Ort hinzufügen
|
{t('planner.addFirstPlace')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -299,7 +294,7 @@ export function RightPanel({
|
|||||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||||
className="text-xs text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100"
|
className="text-xs text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100"
|
||||||
>
|
>
|
||||||
+ Tag
|
{t('planner.addToDay')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -312,7 +307,7 @@ export function RightPanel({
|
|||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
{place.place_time && (
|
{place.place_time && (
|
||||||
<span className="text-xs text-gray-500">🕐 {place.place_time}</span>
|
<span className="text-xs text-gray-500">🕐 {place.place_time}{place.end_time ? ` – ${place.end_time}` : ''}</span>
|
||||||
)}
|
)}
|
||||||
{place.price > 0 && (
|
{place.price > 0 && (
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
@@ -337,7 +332,7 @@ export function RightPanel({
|
|||||||
{!selectedDayId ? (
|
{!selectedDayId ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400 px-6">
|
<div className="flex flex-col items-center justify-center py-16 text-gray-400 px-6">
|
||||||
<span className="text-4xl mb-3">📅</span>
|
<span className="text-4xl mb-3">📅</span>
|
||||||
<p className="text-sm text-center">Wähle einen Tag aus der linken Liste um den Tagesplan zu sehen</p>
|
<p className="text-sm text-center">{t('planner.selectDayHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -352,39 +347,22 @@ export function RightPanel({
|
|||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-slate-700 mt-0.5">
|
<p className="text-xs text-slate-700 mt-0.5">
|
||||||
{dayAssignments.length} Ort{dayAssignments.length !== 1 ? 'e' : ''}
|
{dayAssignments.length === 1 ? t('planner.placeOne') : t('planner.placeN', { n: dayAssignments.length })}
|
||||||
{dayAssignments.length > 0 && ` · ${dayAssignments.reduce((s, a) => s + (a.place?.duration_minutes || 60), 0)} Min. gesamt`}
|
{dayAssignments.length > 0 && ` · ${dayAssignments.reduce((s, a) => s + (a.place?.duration_minutes || 60), 0)} ${t('planner.minTotal')}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Transport mode */}
|
|
||||||
<div className="px-3 py-2 border-b border-gray-100 flex items-center gap-1 flex-shrink-0">
|
|
||||||
{TRANSPORT_MODES.map(m => (
|
|
||||||
<button
|
|
||||||
key={m.value}
|
|
||||||
onClick={() => setTransportMode(m.value)}
|
|
||||||
className={`flex-1 py-1.5 text-xs rounded-lg flex items-center justify-center gap-1 transition-colors ${
|
|
||||||
transportMode === m.value
|
|
||||||
? 'bg-slate-100 text-slate-900 font-medium'
|
|
||||||
: 'text-gray-500 hover:bg-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{m.icon} {m.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Places list with order */}
|
{/* Places list with order */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{dayAssignments.length === 0 ? (
|
{dayAssignments.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||||
<span className="text-3xl mb-2">🗺️</span>
|
<span className="text-3xl mb-2">🗺️</span>
|
||||||
<p className="text-sm">Noch keine Orte für diesen Tag</p>
|
<p className="text-sm">{t('planner.noPlacesForDay')}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('orte')}
|
onClick={() => setActiveTab('orte')}
|
||||||
className="mt-3 text-slate-700 text-sm hover:underline"
|
className="mt-3 text-slate-700 text-sm hover:underline"
|
||||||
>
|
>
|
||||||
Orte hinzufügen →
|
{t('planner.addPlacesLink')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -475,14 +453,14 @@ export function RightPanel({
|
|||||||
className="flex items-center justify-center gap-1.5 bg-slate-700 text-white text-xs py-2 rounded-lg hover:bg-slate-900 disabled:opacity-60"
|
className="flex items-center justify-center gap-1.5 bg-slate-700 text-white text-xs py-2 rounded-lg hover:bg-slate-900 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<Navigation className="w-3.5 h-3.5" />
|
<Navigation className="w-3.5 h-3.5" />
|
||||||
{isCalculatingRoute ? 'Berechne...' : 'Route berechnen'}
|
{isCalculatingRoute ? t('planner.calculating') : t('planner.route')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleOptimizeRoute}
|
onClick={handleOptimizeRoute}
|
||||||
className="flex items-center justify-center gap-1.5 bg-emerald-600 text-white text-xs py-2 rounded-lg hover:bg-emerald-700"
|
className="flex items-center justify-center gap-1.5 bg-emerald-600 text-white text-xs py-2 rounded-lg hover:bg-emerald-700"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
Optimieren
|
{t('planner.optimize')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -490,7 +468,7 @@ export function RightPanel({
|
|||||||
className="w-full flex items-center justify-center gap-1.5 bg-white border border-gray-200 text-gray-700 text-xs py-2 rounded-lg hover:bg-gray-50"
|
className="w-full flex items-center justify-center gap-1.5 bg-white border border-gray-200 text-gray-700 text-xs py-2 rounded-lg hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-3.5 h-3.5" />
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
In Google Maps öffnen
|
{t('planner.openGoogleMaps')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -504,7 +482,7 @@ export function RightPanel({
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="p-3 flex items-center justify-between border-b border-gray-100 flex-shrink-0">
|
<div className="p-3 flex items-center justify-between border-b border-gray-100 flex-shrink-0">
|
||||||
<h3 className="font-medium text-sm text-gray-900">
|
<h3 className="font-medium text-sm text-gray-900">
|
||||||
Reservierungen
|
{t('planner.reservations')}
|
||||||
{selectedDay && <span className="text-gray-500 font-normal"> · Tag {selectedDay.day_number}</span>}
|
{selectedDay && <span className="text-gray-500 font-normal"> · Tag {selectedDay.day_number}</span>}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
@@ -512,7 +490,7 @@ export function RightPanel({
|
|||||||
className="flex items-center gap-1 bg-slate-700 text-white text-xs px-2.5 py-1.5 rounded-lg hover:bg-slate-900"
|
className="flex items-center gap-1 bg-slate-700 text-white text-xs px-2.5 py-1.5 rounded-lg hover:bg-slate-900"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
Hinzufügen
|
{t('common.add')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -520,9 +498,9 @@ export function RightPanel({
|
|||||||
{filteredReservations.length === 0 ? (
|
{filteredReservations.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||||
<span className="text-3xl mb-2">🎫</span>
|
<span className="text-3xl mb-2">🎫</span>
|
||||||
<p className="text-sm">Keine Reservierungen</p>
|
<p className="text-sm">{t('planner.noReservations')}</p>
|
||||||
<button onClick={handleAddReservation} className="mt-3 text-slate-700 text-sm hover:underline">
|
<button onClick={handleAddReservation} className="mt-3 text-slate-700 text-sm hover:underline">
|
||||||
Erste Reservierung hinzufügen
|
{t('planner.addFirstReservation')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import { Calendar, Camera, X } from 'lucide-react'
|
import { Calendar, Camera, X, Clipboard } from 'lucide-react'
|
||||||
import { tripsApi } from '../../api/client'
|
import { tripsApi } from '../../api/client'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
@@ -21,6 +21,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [coverPreview, setCoverPreview] = useState(null)
|
const [coverPreview, setCoverPreview] = useState(null)
|
||||||
|
const [pendingCoverFile, setPendingCoverFile] = useState(null)
|
||||||
const [uploadingCover, setUploadingCover] = useState(false)
|
const [uploadingCover, setUploadingCover] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -36,6 +37,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
setFormData({ title: '', description: '', start_date: '', end_date: '' })
|
setFormData({ title: '', description: '', start_date: '', end_date: '' })
|
||||||
setCoverPreview(null)
|
setCoverPreview(null)
|
||||||
}
|
}
|
||||||
|
setPendingCoverFile(null)
|
||||||
setError('')
|
setError('')
|
||||||
}, [trip, isOpen])
|
}, [trip, isOpen])
|
||||||
|
|
||||||
@@ -48,12 +50,23 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
}
|
}
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
await onSave({
|
const result = await onSave({
|
||||||
title: formData.title.trim(),
|
title: formData.title.trim(),
|
||||||
description: formData.description.trim() || null,
|
description: formData.description.trim() || null,
|
||||||
start_date: formData.start_date || null,
|
start_date: formData.start_date || null,
|
||||||
end_date: formData.end_date || null,
|
end_date: formData.end_date || null,
|
||||||
})
|
})
|
||||||
|
// Upload pending cover for newly created trips
|
||||||
|
if (pendingCoverFile && result?.trip?.id) {
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('cover', pendingCoverFile)
|
||||||
|
const data = await tripsApi.uploadCover(result.trip.id, fd)
|
||||||
|
onCoverUpdate?.(result.trip.id, data.cover_image)
|
||||||
|
} catch {
|
||||||
|
// Cover upload failed but trip was created
|
||||||
|
}
|
||||||
|
}
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || t('places.saveError'))
|
setError(err.message || t('places.saveError'))
|
||||||
@@ -62,9 +75,24 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCoverChange = async (e) => {
|
const handleCoverSelect = (file) => {
|
||||||
const file = e.target.files?.[0]
|
if (!file) return
|
||||||
if (!file || !trip?.id) return
|
if (isEditing && trip?.id) {
|
||||||
|
// Existing trip: upload immediately
|
||||||
|
uploadCoverNow(file)
|
||||||
|
} else {
|
||||||
|
// New trip: stage for upload after creation
|
||||||
|
setPendingCoverFile(file)
|
||||||
|
setCoverPreview(URL.createObjectURL(file))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCoverChange = (e) => {
|
||||||
|
handleCoverSelect(e.target.files?.[0])
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadCoverNow = async (file) => {
|
||||||
setUploadingCover(true)
|
setUploadingCover(true)
|
||||||
try {
|
try {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
@@ -77,11 +105,15 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
toast.error(t('dashboard.coverUploadError'))
|
toast.error(t('dashboard.coverUploadError'))
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingCover(false)
|
setUploadingCover(false)
|
||||||
e.target.value = ''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRemoveCover = async () => {
|
const handleRemoveCover = async () => {
|
||||||
|
if (pendingCoverFile) {
|
||||||
|
setPendingCoverFile(null)
|
||||||
|
setCoverPreview(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!trip?.id) return
|
if (!trip?.id) return
|
||||||
try {
|
try {
|
||||||
await tripsApi.update(trip.id, { cover_image: null })
|
await tripsApi.update(trip.id, { cover_image: null })
|
||||||
@@ -92,15 +124,26 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Paste support for cover image
|
||||||
|
const handlePaste = (e) => {
|
||||||
|
const items = e.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
e.preventDefault()
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (file) handleCoverSelect(file)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const update = (field, value) => setFormData(prev => {
|
const update = (field, value) => setFormData(prev => {
|
||||||
const next = { ...prev, [field]: value }
|
const next = { ...prev, [field]: value }
|
||||||
// Auto-adjust end date when start date changes
|
|
||||||
if (field === 'start_date' && value) {
|
if (field === 'start_date' && value) {
|
||||||
if (!prev.end_date || prev.end_date < value) {
|
if (!prev.end_date || prev.end_date < value) {
|
||||||
// If no end date or end date is before new start, set end = start
|
|
||||||
next.end_date = value
|
next.end_date = value
|
||||||
} else if (prev.start_date) {
|
} else if (prev.start_date) {
|
||||||
// Preserve trip duration: shift end date by same delta
|
|
||||||
const oldStart = new Date(prev.start_date + 'T00:00:00')
|
const oldStart = new Date(prev.start_date + 'T00:00:00')
|
||||||
const oldEnd = new Date(prev.end_date + 'T00:00:00')
|
const oldEnd = new Date(prev.end_date + 'T00:00:00')
|
||||||
const duration = Math.round((oldEnd - oldStart) / 86400000)
|
const duration = Math.round((oldEnd - oldStart) / 86400000)
|
||||||
@@ -135,40 +178,38 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">{error}</div>
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cover image — only for existing trips */}
|
{/* Cover image — available for both create and edit */}
|
||||||
{isEditing && (
|
<div>
|
||||||
<div>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.coverImage')}</label>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.coverImage')}</label>
|
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleCoverChange} />
|
||||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleCoverChange} />
|
{coverPreview ? (
|
||||||
{coverPreview ? (
|
<div style={{ position: 'relative', borderRadius: 10, overflow: 'hidden', height: 130 }}>
|
||||||
<div style={{ position: 'relative', borderRadius: 10, overflow: 'hidden', height: 130 }}>
|
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
<div style={{ position: 'absolute', bottom: 8, right: 8, display: 'flex', gap: 6 }}>
|
||||||
<div style={{ position: 'absolute', bottom: 8, right: 8, display: 'flex', gap: 6 }}>
|
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
|
||||||
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
|
style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '5px 10px', borderRadius: 8, background: 'rgba(0,0,0,0.55)', border: 'none', color: 'white', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', backdropFilter: 'blur(4px)' }}>
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '5px 10px', borderRadius: 8, background: 'rgba(0,0,0,0.55)', border: 'none', color: 'white', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', backdropFilter: 'blur(4px)' }}>
|
<Camera size={12} /> {uploadingCover ? t('common.uploading') : t('common.change')}
|
||||||
<Camera size={12} /> {uploadingCover ? t('common.uploading') : t('common.change')}
|
</button>
|
||||||
</button>
|
<button type="button" onClick={handleRemoveCover}
|
||||||
<button type="button" onClick={handleRemoveCover}
|
style={{ display: 'flex', alignItems: 'center', padding: '5px 8px', borderRadius: 8, background: 'rgba(0,0,0,0.55)', border: 'none', color: 'white', cursor: 'pointer', backdropFilter: 'blur(4px)' }}>
|
||||||
style={{ display: 'flex', alignItems: 'center', padding: '5px 8px', borderRadius: 8, background: 'rgba(0,0,0,0.55)', border: 'none', color: 'white', cursor: 'pointer', backdropFilter: 'blur(4px)' }}>
|
<X size={12} />
|
||||||
<X size={12} />
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
|
) : (
|
||||||
style={{ width: '100%', padding: '18px', border: '2px dashed #e5e7eb', borderRadius: 10, background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: '#9ca3af', fontFamily: 'inherit' }}
|
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.color = '#6b7280' }}
|
style={{ width: '100%', padding: '18px', border: '2px dashed #e5e7eb', borderRadius: 10, background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: '#9ca3af', fontFamily: 'inherit' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.color = '#6b7280' }}
|
||||||
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
|
||||||
</button>
|
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
|
||||||
)}
|
</button>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||||
|
|||||||
@@ -46,21 +46,35 @@ export default function WeatherWidget({ lat, lng, date, compact = false }) {
|
|||||||
const cached = getWeatherCache(cacheKey)
|
const cached = getWeatherCache(cacheKey)
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
if (cached === null) setFailed(true)
|
if (cached === null) setFailed(true)
|
||||||
else setWeather(cached)
|
// Climate data: use from cache but re-fetch in background to upgrade to forecast
|
||||||
|
else if (cached.type === 'climate') {
|
||||||
|
setWeather(cached)
|
||||||
|
weatherApi.get(lat, lng, date)
|
||||||
|
.then(data => {
|
||||||
|
if (!data.error && data.temp !== undefined && data.type === 'forecast') {
|
||||||
|
setWeatherCache(cacheKey, data)
|
||||||
|
setWeather(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
setWeather(cached)
|
||||||
|
return
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
weatherApi.get(lat, lng, date)
|
weatherApi.get(lat, lng, date)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.error || data.temp === undefined) {
|
if (data.error || data.temp === undefined) {
|
||||||
setWeatherCache(cacheKey, null)
|
|
||||||
setFailed(true)
|
setFailed(true)
|
||||||
} else {
|
} else {
|
||||||
setWeatherCache(cacheKey, data)
|
setWeatherCache(cacheKey, data)
|
||||||
setWeather(data)
|
setWeather(data)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => { setWeatherCache(cacheKey, null); setFailed(true) })
|
.catch(() => { setFailed(true) })
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [lat, lng, date])
|
}, [lat, lng, date])
|
||||||
|
|
||||||
@@ -83,20 +97,21 @@ export default function WeatherWidget({ lat, lng, date, compact = false }) {
|
|||||||
const rawTemp = weather.temp
|
const rawTemp = weather.temp
|
||||||
const temp = rawTemp !== undefined ? Math.round(isFahrenheit ? rawTemp * 9/5 + 32 : rawTemp) : null
|
const temp = rawTemp !== undefined ? Math.round(isFahrenheit ? rawTemp * 9/5 + 32 : rawTemp) : null
|
||||||
const unit = isFahrenheit ? '°F' : '°C'
|
const unit = isFahrenheit ? '°F' : '°C'
|
||||||
|
const isClimate = weather.type === 'climate'
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: '#6b7280', ...fontStyle }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
|
||||||
<WeatherIcon main={weather.main} size={12} />
|
<WeatherIcon main={weather.main} size={12} />
|
||||||
{temp !== null && <span>{temp}{unit}</span>}
|
{temp !== null && <span>{isClimate ? 'Ø ' : ''}{temp}{unit}</span>}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: '#374151', background: 'rgba(0,0,0,0.04)', borderRadius: 8, padding: '5px 10px', ...fontStyle }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: isClimate ? '#71717a' : '#374151', background: 'rgba(0,0,0,0.04)', borderRadius: 8, padding: '5px 10px', ...fontStyle }}>
|
||||||
<WeatherIcon main={weather.main} size={15} />
|
<WeatherIcon main={weather.main} size={15} />
|
||||||
{temp !== null && <span style={{ fontWeight: 500 }}>{temp}{unit}</span>}
|
{temp !== null && <span style={{ fontWeight: 500 }}>{isClimate ? 'Ø ' : ''}{temp}{unit}</span>}
|
||||||
{weather.description && <span style={{ fontSize: 11, color: '#9ca3af', textTransform: 'capitalize' }}>{weather.description}</span>}
|
{weather.description && <span style={{ fontSize: 11, color: '#9ca3af', textTransform: 'capitalize' }}>{weather.description}</span>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
|||||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||||
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}>
|
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}>
|
||||||
<Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
<Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
<span>{displayValue || placeholder || t('common.date')}</span>
|
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayValue || placeholder || t('common.date')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && ReactDOM.createPortal(
|
{open && ReactDOM.createPortal(
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export default function CustomSelect({
|
|||||||
background: 'var(--bg-input)', color: 'var(--text-primary)',
|
background: 'var(--bg-input)', color: 'var(--text-primary)',
|
||||||
fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
|
fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
|
||||||
cursor: 'pointer', outline: 'none', textAlign: 'left',
|
cursor: 'pointer', outline: 'none', textAlign: 'left',
|
||||||
transition: 'border-color 0.15s',
|
transition: 'border-color 0.15s', overflow: 'hidden', minWidth: 0,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||||
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
||||||
@@ -105,6 +105,17 @@ export default function CustomSelect({
|
|||||||
<div style={{ padding: '10px 12px', fontSize: 12, color: 'var(--text-faint)', textAlign: 'center' }}>—</div>
|
<div style={{ padding: '10px 12px', fontSize: 12, color: 'var(--text-faint)', textAlign: 'center' }}>—</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map(option => {
|
filtered.map(option => {
|
||||||
|
if (option.isHeader) {
|
||||||
|
return (
|
||||||
|
<div key={option.value} style={{
|
||||||
|
padding: '5px 10px', fontSize: 10, fontWeight: 700, color: 'var(--text-faint)',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.03em',
|
||||||
|
background: 'var(--bg-tertiary)', borderRadius: 4, margin: '2px 0',
|
||||||
|
}}>
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
const isSelected = option.value === value
|
const isSelected = option.value === value
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -25,17 +25,17 @@ export const CATEGORY_ICON_MAP = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ICON_LABELS = {
|
export const ICON_LABELS = {
|
||||||
MapPin: 'Pin', Building2: 'Gebäude', BedDouble: 'Hotel', UtensilsCrossed: 'Restaurant',
|
MapPin: 'Pin', Building2: 'Building', BedDouble: 'Hotel', UtensilsCrossed: 'Restaurant',
|
||||||
Landmark: 'Sehenswürdigkeit', ShoppingBag: 'Shopping', Bus: 'Bus', Train: 'Zug',
|
Landmark: 'Attraction', ShoppingBag: 'Shopping', Bus: 'Bus', Train: 'Train',
|
||||||
Car: 'Auto', Plane: 'Flugzeug', Ship: 'Schiff', Bike: 'Fahrrad',
|
Car: 'Car', Plane: 'Plane', Ship: 'Ship', Bike: 'Bicycle',
|
||||||
Activity: 'Aktivität', Dumbbell: 'Fitness', Mountain: 'Berg', Tent: 'Camping',
|
Activity: 'Activity', Dumbbell: 'Fitness', Mountain: 'Mountain', Tent: 'Camping',
|
||||||
Anchor: 'Hafen', Coffee: 'Café', Beer: 'Bar', Wine: 'Wein', Utensils: 'Essen',
|
Anchor: 'Harbor', Coffee: 'Cafe', Beer: 'Bar', Wine: 'Wine', Utensils: 'Food',
|
||||||
Camera: 'Foto', Music: 'Musik', Theater: 'Theater', Ticket: 'Events',
|
Camera: 'Photo', Music: 'Music', Theater: 'Theater', Ticket: 'Events',
|
||||||
TreePine: 'Natur', Waves: 'Strand', Leaf: 'Grün', Flower2: 'Garten', Sun: 'Sonne',
|
TreePine: 'Nature', Waves: 'Beach', Leaf: 'Green', Flower2: 'Garden', Sun: 'Sun',
|
||||||
Globe: 'Welt', Compass: 'Erkundung', Flag: 'Flagge', Navigation: 'Navigation', Map: 'Karte',
|
Globe: 'World', Compass: 'Explore', Flag: 'Flag', Navigation: 'Navigation', Map: 'Map',
|
||||||
Church: 'Kirche', Library: 'Museum', Store: 'Markt', Home: 'Unterkunft', Cross: 'Medizin',
|
Church: 'Church', Library: 'Museum', Store: 'Market', Home: 'Accommodation', Cross: 'Medicine',
|
||||||
Heart: 'Favorit', Star: 'Top', CreditCard: 'Bank', Wifi: 'Internet',
|
Heart: 'Favorite', Star: 'Top', CreditCard: 'Bank', Wifi: 'Internet',
|
||||||
Luggage: 'Gepäck', Backpack: 'Rucksack', Zap: 'Abenteuer',
|
Luggage: 'Luggage', Backpack: 'Backpack', Zap: 'Adventure',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCategoryIcon(iconName) {
|
export function getCategoryIcon(iconName) {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const de = {
|
|||||||
'common.update': 'Aktualisieren',
|
'common.update': 'Aktualisieren',
|
||||||
'common.change': 'Ändern',
|
'common.change': 'Ändern',
|
||||||
'common.uploading': 'Hochladen…',
|
'common.uploading': 'Hochladen…',
|
||||||
|
'common.backToPlanning': 'Zurück zur Planung',
|
||||||
|
'common.reset': 'Zurücksetzen',
|
||||||
|
|
||||||
// Navbar
|
// Navbar
|
||||||
'nav.trip': 'Reise',
|
'nav.trip': 'Reise',
|
||||||
@@ -37,6 +39,7 @@ const de = {
|
|||||||
'nav.logout': 'Abmelden',
|
'nav.logout': 'Abmelden',
|
||||||
'nav.lightMode': 'Heller Modus',
|
'nav.lightMode': 'Heller Modus',
|
||||||
'nav.darkMode': 'Dunkler Modus',
|
'nav.darkMode': 'Dunkler Modus',
|
||||||
|
'nav.autoMode': 'Automatischer Modus',
|
||||||
'nav.administrator': 'Administrator',
|
'nav.administrator': 'Administrator',
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
@@ -120,6 +123,7 @@ const de = {
|
|||||||
'settings.colorMode': 'Farbmodus',
|
'settings.colorMode': 'Farbmodus',
|
||||||
'settings.light': 'Hell',
|
'settings.light': 'Hell',
|
||||||
'settings.dark': 'Dunkel',
|
'settings.dark': 'Dunkel',
|
||||||
|
'settings.auto': 'Automatisch',
|
||||||
'settings.language': 'Sprache',
|
'settings.language': 'Sprache',
|
||||||
'settings.temperature': 'Temperatureinheit',
|
'settings.temperature': 'Temperatureinheit',
|
||||||
'settings.timeFormat': 'Zeitformat',
|
'settings.timeFormat': 'Zeitformat',
|
||||||
@@ -138,14 +142,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',
|
||||||
@@ -191,6 +195,35 @@ const de = {
|
|||||||
'login.register': 'Registrieren',
|
'login.register': 'Registrieren',
|
||||||
'login.emailPlaceholder': 'deine@email.de',
|
'login.emailPlaceholder': 'deine@email.de',
|
||||||
'login.username': 'Benutzername',
|
'login.username': 'Benutzername',
|
||||||
|
'login.oidc.registrationDisabled': 'Registrierung ist deaktiviert. Kontaktiere den Administrator.',
|
||||||
|
'login.oidc.noEmail': 'Keine E-Mail vom Provider erhalten.',
|
||||||
|
'login.oidc.tokenFailed': 'Authentifizierung fehlgeschlagen.',
|
||||||
|
'login.oidc.invalidState': 'Ungültige Sitzung. Bitte erneut versuchen.',
|
||||||
|
'login.demoFailed': 'Demo-Login fehlgeschlagen',
|
||||||
|
'login.oidcSignIn': 'Anmelden mit {name}',
|
||||||
|
'login.demoHint': 'Demo ausprobieren — ohne Registrierung',
|
||||||
|
|
||||||
|
// Register
|
||||||
|
'register.passwordMismatch': 'Passwörter stimmen nicht überein',
|
||||||
|
'register.passwordTooShort': 'Passwort muss mindestens 6 Zeichen lang sein',
|
||||||
|
'register.failed': 'Registrierung fehlgeschlagen',
|
||||||
|
'register.getStarted': 'Jetzt starten',
|
||||||
|
'register.subtitle': 'Erstellen Sie ein Konto und beginnen Sie, Ihre Traumreisen zu planen.',
|
||||||
|
'register.feature1': 'Unbegrenzte Reisepläne',
|
||||||
|
'register.feature2': 'Interaktive Kartenansicht',
|
||||||
|
'register.feature3': 'Orte und Kategorien verwalten',
|
||||||
|
'register.feature4': 'Reservierungen tracken',
|
||||||
|
'register.feature5': 'Packlisten erstellen',
|
||||||
|
'register.feature6': 'Fotos und Dateien speichern',
|
||||||
|
'register.createAccount': 'Konto erstellen',
|
||||||
|
'register.startPlanning': 'Beginnen Sie Ihre Reiseplanung',
|
||||||
|
'register.minChars': 'Mind. 6 Zeichen',
|
||||||
|
'register.confirmPassword': 'Passwort bestätigen',
|
||||||
|
'register.repeatPassword': 'Passwort wiederholen',
|
||||||
|
'register.registering': 'Registrieren...',
|
||||||
|
'register.register': 'Registrieren',
|
||||||
|
'register.hasAccount': 'Bereits ein Konto?',
|
||||||
|
'register.signIn': 'Anmelden',
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
'admin.title': 'Administration',
|
'admin.title': 'Administration',
|
||||||
@@ -252,6 +285,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',
|
||||||
@@ -261,6 +296,48 @@ const de = {
|
|||||||
'admin.addons.toast.updated': 'Addon aktualisiert',
|
'admin.addons.toast.updated': 'Addon aktualisiert',
|
||||||
'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden',
|
'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden',
|
||||||
'admin.addons.noAddons': 'Keine Addons verfügbar',
|
'admin.addons.noAddons': 'Keine Addons verfügbar',
|
||||||
|
// Weather info
|
||||||
|
'admin.weather.title': 'Wetterdaten',
|
||||||
|
'admin.weather.badge': 'Seit 24. März 2026',
|
||||||
|
'admin.weather.description': 'NOMAD nutzt Open-Meteo als Wetterdatenquelle. Open-Meteo ist ein kostenloser, quelloffener Wetterdienst — es wird kein API-Schlüssel benötigt.',
|
||||||
|
'admin.weather.forecast': '16-Tage-Vorhersage',
|
||||||
|
'admin.weather.forecastDesc': 'Statt bisher 5 Tage (OpenWeatherMap)',
|
||||||
|
'admin.weather.climate': 'Historische Klimadaten',
|
||||||
|
'admin.weather.climateDesc': 'Durchschnittswerte der letzten 85 Jahre für Tage jenseits der 16-Tage-Vorhersage',
|
||||||
|
'admin.weather.requests': '10.000 Anfragen / Tag',
|
||||||
|
'admin.weather.requestsDesc': 'Kostenlos, kein API-Schlüssel erforderlich',
|
||||||
|
'admin.weather.locationHint': 'Das Wetter wird anhand des ersten Ortes mit Koordinaten im jeweiligen Tag berechnet. Ist kein Ort am Tag eingeplant, wird ein beliebiger Ort aus der Ortsliste als Referenz verwendet.',
|
||||||
|
|
||||||
|
// GitHub
|
||||||
|
'admin.tabs.github': 'GitHub',
|
||||||
|
'admin.github.title': 'Update-Verlauf',
|
||||||
|
'admin.github.subtitle': 'Neueste Updates von {repo}',
|
||||||
|
'admin.github.latest': 'Aktuell',
|
||||||
|
'admin.github.prerelease': 'Vorabversion',
|
||||||
|
'admin.github.showDetails': 'Details anzeigen',
|
||||||
|
'admin.github.hideDetails': 'Details ausblenden',
|
||||||
|
'admin.github.loadMore': 'Mehr laden',
|
||||||
|
'admin.github.loading': 'Wird geladen...',
|
||||||
|
'admin.github.error': 'Releases konnten nicht geladen werden',
|
||||||
|
'admin.github.by': 'von',
|
||||||
|
|
||||||
|
'admin.update.available': 'Update verfügbar',
|
||||||
|
'admin.update.text': 'NOMAD {version} ist verfügbar. Du verwendest {current}.',
|
||||||
|
'admin.update.button': 'Auf GitHub ansehen',
|
||||||
|
'admin.update.install': 'Update installieren',
|
||||||
|
'admin.update.confirmTitle': 'Update installieren?',
|
||||||
|
'admin.update.confirmText': 'NOMAD wird von {current} auf {version} aktualisiert. Der Server startet danach automatisch neu.',
|
||||||
|
'admin.update.dataInfo': 'Alle Daten (Reisen, Benutzer, API-Schlüssel, Uploads, Vacay, Atlas, Budgets) bleiben erhalten.',
|
||||||
|
'admin.update.warning': 'Die App ist während des Neustarts kurz nicht erreichbar.',
|
||||||
|
'admin.update.confirm': 'Jetzt aktualisieren',
|
||||||
|
'admin.update.installing': 'Wird aktualisiert…',
|
||||||
|
'admin.update.success': 'Update installiert! Server startet neu…',
|
||||||
|
'admin.update.failed': 'Update fehlgeschlagen',
|
||||||
|
'admin.update.backupHint': 'Wir empfehlen, vor dem Update ein Backup zu erstellen und herunterzuladen.',
|
||||||
|
'admin.update.backupLink': 'Zum Backup',
|
||||||
|
'admin.update.howTo': 'Update-Anleitung',
|
||||||
|
'admin.update.dockerText': 'Deine NOMAD-Instanz läuft in Docker. Um auf {version} zu aktualisieren, führe folgende Befehle auf deinem Server aus:',
|
||||||
|
'admin.update.reloadHint': 'Bitte lade die Seite in wenigen Sekunden neu.',
|
||||||
|
|
||||||
// Vacay addon
|
// Vacay addon
|
||||||
'vacay.subtitle': 'Urlaubstage planen und verwalten',
|
'vacay.subtitle': 'Urlaubstage planen und verwalten',
|
||||||
@@ -396,9 +473,6 @@ const de = {
|
|||||||
'trip.confirm.deletePlace': 'Möchtest du diesen Ort wirklich löschen?',
|
'trip.confirm.deletePlace': 'Möchtest du diesen Ort wirklich löschen?',
|
||||||
|
|
||||||
// Day Plan Sidebar
|
// Day Plan Sidebar
|
||||||
'dayplan.transport.car': 'Auto',
|
|
||||||
'dayplan.transport.walk': 'Zu Fuß',
|
|
||||||
'dayplan.transport.bike': 'Fahrrad',
|
|
||||||
'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant',
|
'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant',
|
||||||
'dayplan.addNote': 'Notiz hinzufügen',
|
'dayplan.addNote': 'Notiz hinzufügen',
|
||||||
'dayplan.editNote': 'Notiz bearbeiten',
|
'dayplan.editNote': 'Notiz bearbeiten',
|
||||||
@@ -414,6 +488,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',
|
||||||
@@ -444,6 +521,8 @@ const de = {
|
|||||||
'places.noCategory': 'Keine Kategorie',
|
'places.noCategory': 'Keine Kategorie',
|
||||||
'places.categoryNamePlaceholder': 'Kategoriename',
|
'places.categoryNamePlaceholder': 'Kategoriename',
|
||||||
'places.formTime': 'Uhrzeit',
|
'places.formTime': 'Uhrzeit',
|
||||||
|
'places.startTime': 'Start',
|
||||||
|
'places.endTime': 'Ende',
|
||||||
'places.formWebsite': 'Website',
|
'places.formWebsite': 'Website',
|
||||||
'places.formNotesPlaceholder': 'Persönliche Notizen...',
|
'places.formNotesPlaceholder': 'Persönliche Notizen...',
|
||||||
'places.formReservation': 'Reservierung',
|
'places.formReservation': 'Reservierung',
|
||||||
@@ -455,11 +534,6 @@ const de = {
|
|||||||
'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie',
|
'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie',
|
||||||
'places.nameRequired': 'Bitte einen Namen eingeben',
|
'places.nameRequired': 'Bitte einen Namen eingeben',
|
||||||
'places.saveError': 'Fehler beim Speichern',
|
'places.saveError': 'Fehler beim Speichern',
|
||||||
'places.transport.walking': '🚶 Zu Fuß',
|
|
||||||
'places.transport.driving': '🚗 Auto',
|
|
||||||
'places.transport.cycling': '🚲 Fahrrad',
|
|
||||||
'places.transport.transit': '🚌 ÖPNV',
|
|
||||||
|
|
||||||
// Place Inspector
|
// Place Inspector
|
||||||
'inspector.opened': 'Geöffnet',
|
'inspector.opened': 'Geöffnet',
|
||||||
'inspector.closed': 'Geschlossen',
|
'inspector.closed': 'Geschlossen',
|
||||||
@@ -473,6 +547,8 @@ const de = {
|
|||||||
'inspector.pendingRes': 'Ausstehende Reservierung',
|
'inspector.pendingRes': 'Ausstehende Reservierung',
|
||||||
'inspector.google': 'In Google Maps öffnen',
|
'inspector.google': 'In Google Maps öffnen',
|
||||||
'inspector.website': 'Webseite öffnen',
|
'inspector.website': 'Webseite öffnen',
|
||||||
|
'inspector.addRes': 'Reservierung',
|
||||||
|
'inspector.editRes': 'Reservierung bearbeiten',
|
||||||
|
|
||||||
// Reservations
|
// Reservations
|
||||||
'reservations.title': 'Buchungen',
|
'reservations.title': 'Buchungen',
|
||||||
@@ -489,6 +565,8 @@ const de = {
|
|||||||
'reservations.editTitle': 'Reservierung bearbeiten',
|
'reservations.editTitle': 'Reservierung bearbeiten',
|
||||||
'reservations.status': 'Status',
|
'reservations.status': 'Status',
|
||||||
'reservations.datetime': 'Datum & Uhrzeit',
|
'reservations.datetime': 'Datum & Uhrzeit',
|
||||||
|
'reservations.date': 'Datum',
|
||||||
|
'reservations.time': 'Uhrzeit',
|
||||||
'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)',
|
'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)',
|
||||||
'reservations.notes': 'Notizen',
|
'reservations.notes': 'Notizen',
|
||||||
'reservations.notesPlaceholder': 'Zusätzliche Notizen...',
|
'reservations.notesPlaceholder': 'Zusätzliche Notizen...',
|
||||||
@@ -516,7 +594,7 @@ const de = {
|
|||||||
'reservations.titlePlaceholder': 'z.B. Lufthansa LH123, Hotel Adlon, ...',
|
'reservations.titlePlaceholder': 'z.B. Lufthansa LH123, Hotel Adlon, ...',
|
||||||
'reservations.locationAddress': 'Ort / Adresse',
|
'reservations.locationAddress': 'Ort / Adresse',
|
||||||
'reservations.locationPlaceholder': 'Adresse, Flughafen, Hotel...',
|
'reservations.locationPlaceholder': 'Adresse, Flughafen, Hotel...',
|
||||||
'reservations.confirmationCode': 'Bestätigungsnummer / Buchungscode',
|
'reservations.confirmationCode': 'Buchungscode',
|
||||||
'reservations.confirmationPlaceholder': 'z.B. ABC12345',
|
'reservations.confirmationPlaceholder': 'z.B. ABC12345',
|
||||||
'reservations.day': 'Tag',
|
'reservations.day': 'Tag',
|
||||||
'reservations.noDay': 'Kein Tag',
|
'reservations.noDay': 'Kein Tag',
|
||||||
@@ -525,6 +603,9 @@ const de = {
|
|||||||
'reservations.pendingSave': 'wird gespeichert…',
|
'reservations.pendingSave': 'wird gespeichert…',
|
||||||
'reservations.uploading': 'Wird hochgeladen...',
|
'reservations.uploading': 'Wird hochgeladen...',
|
||||||
'reservations.attachFile': 'Datei anhängen',
|
'reservations.attachFile': 'Datei anhängen',
|
||||||
|
'reservations.linkAssignment': 'Mit Tagesplanung verknüpfen',
|
||||||
|
'reservations.pickAssignment': 'Zuordnung aus dem Plan wählen...',
|
||||||
|
'reservations.noAssignment': 'Keine Verknüpfung',
|
||||||
|
|
||||||
// Budget
|
// Budget
|
||||||
'budget.title': 'Budget',
|
'budget.title': 'Budget',
|
||||||
@@ -573,6 +654,8 @@ const de = {
|
|||||||
'files.toast.deleteError': 'Fehler beim Löschen der Datei',
|
'files.toast.deleteError': 'Fehler beim Löschen der Datei',
|
||||||
'files.sourcePlan': 'Tagesplan',
|
'files.sourcePlan': 'Tagesplan',
|
||||||
'files.sourceBooking': 'Buchung',
|
'files.sourceBooking': 'Buchung',
|
||||||
|
'files.attach': 'Anhängen',
|
||||||
|
'files.pasteHint': 'Du kannst auch Bilder aus der Zwischenablage einfügen (Strg+V)',
|
||||||
|
|
||||||
// Packing
|
// Packing
|
||||||
'packing.title': 'Packliste',
|
'packing.title': 'Packliste',
|
||||||
@@ -725,6 +808,21 @@ const de = {
|
|||||||
'backup.keep.30days': '30 Tage',
|
'backup.keep.30days': '30 Tage',
|
||||||
'backup.keep.forever': 'Immer behalten',
|
'backup.keep.forever': 'Immer behalten',
|
||||||
|
|
||||||
|
// Photos
|
||||||
|
'photos.allDays': 'Alle Tage',
|
||||||
|
'photos.noPhotos': 'Noch keine Fotos',
|
||||||
|
'photos.uploadHint': 'Lade deine Reisefotos hoch',
|
||||||
|
'photos.clickToSelect': 'oder klicken zum Auswählen',
|
||||||
|
'photos.linkPlace': 'Ort verknüpfen',
|
||||||
|
'photos.noPlace': 'Kein Ort',
|
||||||
|
'photos.uploadN': '{n} Foto(s) hochladen',
|
||||||
|
|
||||||
|
// Backup restore modal
|
||||||
|
'backup.restoreConfirmTitle': 'Backup wiederherstellen?',
|
||||||
|
'backup.restoreWarning': 'Alle aktuellen Daten (Reisen, Orte, Benutzer, Uploads) werden unwiderruflich durch das Backup ersetzt. Dieser Vorgang kann nicht rückgängig gemacht werden.',
|
||||||
|
'backup.restoreTip': 'Tipp: Erstelle zuerst ein Backup des aktuellen Stands, bevor du wiederherstellst.',
|
||||||
|
'backup.restoreConfirm': 'Ja, wiederherstellen',
|
||||||
|
|
||||||
// PDF
|
// PDF
|
||||||
'pdf.travelPlan': 'Reiseplan',
|
'pdf.travelPlan': 'Reiseplan',
|
||||||
'pdf.planned': 'Eingeplant',
|
'pdf.planned': 'Eingeplant',
|
||||||
@@ -732,6 +830,68 @@ const de = {
|
|||||||
'pdf.preview': 'PDF Vorschau',
|
'pdf.preview': 'PDF Vorschau',
|
||||||
'pdf.saveAsPdf': 'Als PDF speichern',
|
'pdf.saveAsPdf': 'Als PDF speichern',
|
||||||
|
|
||||||
|
// Planner
|
||||||
|
'planner.places': 'Orte',
|
||||||
|
'planner.bookings': 'Buchungen',
|
||||||
|
'planner.packingList': 'Packliste',
|
||||||
|
'planner.documents': 'Dokumente',
|
||||||
|
'planner.dayPlan': 'Tagesplan',
|
||||||
|
'planner.reservations': 'Reservierungen',
|
||||||
|
'planner.minTwoPlaces': 'Mindestens 2 Orte mit Koordinaten benötigt',
|
||||||
|
'planner.noGeoPlaces': 'Keine Orte mit Koordinaten vorhanden',
|
||||||
|
'planner.routeCalculated': 'Route berechnet',
|
||||||
|
'planner.routeCalcFailed': 'Route konnte nicht berechnet werden',
|
||||||
|
'planner.routeError': 'Fehler bei der Routenberechnung',
|
||||||
|
'planner.routeOptimized': 'Route optimiert',
|
||||||
|
'planner.reservationUpdated': 'Reservierung aktualisiert',
|
||||||
|
'planner.reservationAdded': 'Reservierung hinzugefügt',
|
||||||
|
'planner.confirmDeleteReservation': 'Reservierung löschen?',
|
||||||
|
'planner.reservationDeleted': 'Reservierung gelöscht',
|
||||||
|
'planner.days': 'Tage',
|
||||||
|
'planner.allPlaces': 'Alle Orte',
|
||||||
|
'planner.totalPlaces': '{n} Orte gesamt',
|
||||||
|
'planner.noDaysPlanned': 'Noch keine Tage geplant',
|
||||||
|
'planner.editTrip': 'Reise bearbeiten \u2192',
|
||||||
|
'planner.placeOne': '1 Ort',
|
||||||
|
'planner.placeN': '{n} Orte',
|
||||||
|
'planner.addNote': 'Notiz hinzufügen',
|
||||||
|
'planner.noEntries': 'Keine Einträge für diesen Tag',
|
||||||
|
'planner.addPlace': 'Ort hinzufügen',
|
||||||
|
'planner.addPlaceShort': '+ Ort hinzufügen',
|
||||||
|
'planner.resPending': 'Reservierung ausstehend · ',
|
||||||
|
'planner.resConfirmed': 'Reservierung bestätigt · ',
|
||||||
|
'planner.notePlaceholder': 'Notiz\u2026',
|
||||||
|
'planner.noteTimePlaceholder': 'Zeit (optional)',
|
||||||
|
'planner.noteExamplePlaceholder': 'z.B. S3 um 14:30 ab Hauptbahnhof, Fähre ab Pier 7, Mittagspause\u2026',
|
||||||
|
'planner.totalCost': 'Gesamtkosten',
|
||||||
|
'planner.searchPlaces': 'Orte suchen\u2026',
|
||||||
|
'planner.allCategories': 'Alle Kategorien',
|
||||||
|
'planner.noPlacesFound': 'Keine Orte gefunden',
|
||||||
|
'planner.addFirstPlace': 'Ersten Ort hinzufügen',
|
||||||
|
'planner.noReservations': 'Keine Reservierungen',
|
||||||
|
'planner.addFirstReservation': 'Erste Reservierung hinzufügen',
|
||||||
|
'planner.new': 'Neu',
|
||||||
|
'planner.addToDay': '+ Tag',
|
||||||
|
'planner.calculating': 'Berechne\u2026',
|
||||||
|
'planner.route': 'Route',
|
||||||
|
'planner.optimize': 'Optimieren',
|
||||||
|
'planner.openGoogleMaps': 'In Google Maps öffnen',
|
||||||
|
'planner.selectDayHint': 'Wähle einen Tag aus der linken Liste um den Tagesplan zu sehen',
|
||||||
|
'planner.noPlacesForDay': 'Noch keine Orte für diesen Tag',
|
||||||
|
'planner.addPlacesLink': 'Orte hinzufügen \u2192',
|
||||||
|
'planner.minTotal': 'Min. gesamt',
|
||||||
|
'planner.noReservation': 'Keine Reservierung',
|
||||||
|
'planner.removeFromDay': 'Aus Tag entfernen',
|
||||||
|
'planner.addToThisDay': 'Zum Tag hinzufügen',
|
||||||
|
'planner.overview': 'Gesamtübersicht',
|
||||||
|
'planner.noDays': 'Noch keine Tage',
|
||||||
|
'planner.editTripToAddDays': 'Reise bearbeiten um Tage hinzuzufügen',
|
||||||
|
'planner.dayCount': '{n} Tage',
|
||||||
|
'planner.clickToUnlock': 'Klicken zum Entsperren',
|
||||||
|
'planner.keepPosition': 'Position bei Routenoptimierung beibehalten',
|
||||||
|
'planner.dayDetails': 'Tagesdetails',
|
||||||
|
'planner.dayN': 'Tag {n}',
|
||||||
|
|
||||||
// Dashboard Stats
|
// Dashboard Stats
|
||||||
'stats.countries': 'Länder',
|
'stats.countries': 'Länder',
|
||||||
'stats.cities': 'Städte',
|
'stats.cities': 'Städte',
|
||||||
@@ -741,6 +901,26 @@ const de = {
|
|||||||
'stats.visited': 'besucht',
|
'stats.visited': 'besucht',
|
||||||
'stats.remaining': 'verbleibend',
|
'stats.remaining': 'verbleibend',
|
||||||
'stats.visitedCountries': 'Besuchte Länder',
|
'stats.visitedCountries': 'Besuchte Länder',
|
||||||
|
|
||||||
|
// Day Detail Panel
|
||||||
|
'day.precipProb': 'Regenwahrscheinlichkeit',
|
||||||
|
'day.precipitation': 'Niederschlag',
|
||||||
|
'day.wind': 'Wind',
|
||||||
|
'day.sunrise': 'Sonnenaufgang',
|
||||||
|
'day.sunset': 'Sonnenuntergang',
|
||||||
|
'day.hourlyForecast': 'Stündliche Vorhersage',
|
||||||
|
'day.climateHint': 'Historische Durchschnittswerte — echte Vorhersage verfügbar innerhalb von 16 Tagen vor diesem Datum.',
|
||||||
|
'day.noWeather': 'Keine Wetterdaten verfügbar. Füge einen Ort mit Koordinaten hinzu.',
|
||||||
|
'day.accommodation': 'Unterkunft',
|
||||||
|
'day.addAccommodation': 'Unterkunft hinzufügen',
|
||||||
|
'day.hotelDayRange': 'Auf Tage anwenden',
|
||||||
|
'day.noPlacesForHotel': 'Füge zuerst Orte zu deiner Reise hinzu',
|
||||||
|
'day.allDays': 'Alle',
|
||||||
|
'day.checkIn': 'Check-in',
|
||||||
|
'day.checkOut': 'Check-out',
|
||||||
|
'day.confirmation': 'Bestätigung',
|
||||||
|
'day.editAccommodation': 'Unterkunft bearbeiten',
|
||||||
|
'day.reservations': 'Reservierungen',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default de
|
export default de
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const en = {
|
|||||||
'common.update': 'Update',
|
'common.update': 'Update',
|
||||||
'common.change': 'Change',
|
'common.change': 'Change',
|
||||||
'common.uploading': 'Uploading…',
|
'common.uploading': 'Uploading…',
|
||||||
|
'common.backToPlanning': 'Back to Planning',
|
||||||
|
'common.reset': 'Reset',
|
||||||
|
|
||||||
// Navbar
|
// Navbar
|
||||||
'nav.trip': 'Trip',
|
'nav.trip': 'Trip',
|
||||||
@@ -37,6 +39,7 @@ const en = {
|
|||||||
'nav.logout': 'Log out',
|
'nav.logout': 'Log out',
|
||||||
'nav.lightMode': 'Light Mode',
|
'nav.lightMode': 'Light Mode',
|
||||||
'nav.darkMode': 'Dark Mode',
|
'nav.darkMode': 'Dark Mode',
|
||||||
|
'nav.autoMode': 'Auto Mode',
|
||||||
'nav.administrator': 'Administrator',
|
'nav.administrator': 'Administrator',
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
@@ -120,6 +123,7 @@ const en = {
|
|||||||
'settings.colorMode': 'Color Mode',
|
'settings.colorMode': 'Color Mode',
|
||||||
'settings.light': 'Light',
|
'settings.light': 'Light',
|
||||||
'settings.dark': 'Dark',
|
'settings.dark': 'Dark',
|
||||||
|
'settings.auto': 'Auto',
|
||||||
'settings.language': 'Language',
|
'settings.language': 'Language',
|
||||||
'settings.temperature': 'Temperature Unit',
|
'settings.temperature': 'Temperature Unit',
|
||||||
'settings.timeFormat': 'Time Format',
|
'settings.timeFormat': 'Time Format',
|
||||||
@@ -191,6 +195,35 @@ const en = {
|
|||||||
'login.register': 'Register',
|
'login.register': 'Register',
|
||||||
'login.emailPlaceholder': 'your@email.com',
|
'login.emailPlaceholder': 'your@email.com',
|
||||||
'login.username': 'Username',
|
'login.username': 'Username',
|
||||||
|
'login.oidc.registrationDisabled': 'Registration is disabled. Contact your administrator.',
|
||||||
|
'login.oidc.noEmail': 'No email received from provider.',
|
||||||
|
'login.oidc.tokenFailed': 'Authentication failed.',
|
||||||
|
'login.oidc.invalidState': 'Invalid session. Please try again.',
|
||||||
|
'login.demoFailed': 'Demo login failed',
|
||||||
|
'login.oidcSignIn': 'Sign in with {name}',
|
||||||
|
'login.demoHint': 'Try the demo — no registration needed',
|
||||||
|
|
||||||
|
// Register
|
||||||
|
'register.passwordMismatch': 'Passwords do not match',
|
||||||
|
'register.passwordTooShort': 'Password must be at least 6 characters',
|
||||||
|
'register.failed': 'Registration failed',
|
||||||
|
'register.getStarted': 'Get Started',
|
||||||
|
'register.subtitle': 'Create an account and start planning your dream trips.',
|
||||||
|
'register.feature1': 'Unlimited trip plans',
|
||||||
|
'register.feature2': 'Interactive map view',
|
||||||
|
'register.feature3': 'Manage places and categories',
|
||||||
|
'register.feature4': 'Track reservations',
|
||||||
|
'register.feature5': 'Create packing lists',
|
||||||
|
'register.feature6': 'Store photos and files',
|
||||||
|
'register.createAccount': 'Create Account',
|
||||||
|
'register.startPlanning': 'Start your trip planning',
|
||||||
|
'register.minChars': 'Min. 6 characters',
|
||||||
|
'register.confirmPassword': 'Confirm Password',
|
||||||
|
'register.repeatPassword': 'Repeat password',
|
||||||
|
'register.registering': 'Registering...',
|
||||||
|
'register.register': 'Register',
|
||||||
|
'register.hasAccount': 'Already have an account?',
|
||||||
|
'register.signIn': 'Sign In',
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
'admin.title': 'Administration',
|
'admin.title': 'Administration',
|
||||||
@@ -252,6 +285,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',
|
||||||
@@ -261,6 +296,48 @@ const en = {
|
|||||||
'admin.addons.toast.updated': 'Addon updated',
|
'admin.addons.toast.updated': 'Addon updated',
|
||||||
'admin.addons.toast.error': 'Failed to update addon',
|
'admin.addons.toast.error': 'Failed to update addon',
|
||||||
'admin.addons.noAddons': 'No addons available',
|
'admin.addons.noAddons': 'No addons available',
|
||||||
|
// Weather info
|
||||||
|
'admin.weather.title': 'Weather Data',
|
||||||
|
'admin.weather.badge': 'Since March 24, 2026',
|
||||||
|
'admin.weather.description': 'NOMAD uses Open-Meteo as its weather data source. Open-Meteo is a free, open-source weather service — no API key required.',
|
||||||
|
'admin.weather.forecast': '16-day forecast',
|
||||||
|
'admin.weather.forecastDesc': 'Previously 5 days (OpenWeatherMap)',
|
||||||
|
'admin.weather.climate': 'Historical climate data',
|
||||||
|
'admin.weather.climateDesc': 'Averages from the last 85 years for days beyond the 16-day forecast',
|
||||||
|
'admin.weather.requests': '10,000 requests / day',
|
||||||
|
'admin.weather.requestsDesc': 'Free, no API key required',
|
||||||
|
'admin.weather.locationHint': 'Weather is based on the first place with coordinates in each day. If no place is assigned to a day, any place from the place list is used as a reference.',
|
||||||
|
|
||||||
|
// GitHub
|
||||||
|
'admin.tabs.github': 'GitHub',
|
||||||
|
'admin.github.title': 'Release History',
|
||||||
|
'admin.github.subtitle': 'Latest updates from {repo}',
|
||||||
|
'admin.github.latest': 'Latest',
|
||||||
|
'admin.github.prerelease': 'Pre-release',
|
||||||
|
'admin.github.showDetails': 'Show details',
|
||||||
|
'admin.github.hideDetails': 'Hide details',
|
||||||
|
'admin.github.loadMore': 'Load more',
|
||||||
|
'admin.github.loading': 'Loading...',
|
||||||
|
'admin.github.error': 'Failed to load releases',
|
||||||
|
'admin.github.by': 'by',
|
||||||
|
|
||||||
|
'admin.update.available': 'Update available',
|
||||||
|
'admin.update.text': 'NOMAD {version} is available. You are running {current}.',
|
||||||
|
'admin.update.button': 'View on GitHub',
|
||||||
|
'admin.update.install': 'Install Update',
|
||||||
|
'admin.update.confirmTitle': 'Install Update?',
|
||||||
|
'admin.update.confirmText': 'NOMAD will be updated from {current} to {version}. The server will restart automatically afterwards.',
|
||||||
|
'admin.update.dataInfo': 'All your data (trips, users, API keys, uploads, Vacay, Atlas, budgets) will be preserved.',
|
||||||
|
'admin.update.warning': 'The app will be briefly unavailable during the restart.',
|
||||||
|
'admin.update.confirm': 'Update Now',
|
||||||
|
'admin.update.installing': 'Updating…',
|
||||||
|
'admin.update.success': 'Update installed! Server is restarting…',
|
||||||
|
'admin.update.failed': 'Update failed',
|
||||||
|
'admin.update.backupHint': 'We recommend creating a backup before updating.',
|
||||||
|
'admin.update.backupLink': 'Go to Backup',
|
||||||
|
'admin.update.howTo': 'How to Update',
|
||||||
|
'admin.update.dockerText': 'Your NOMAD instance runs in Docker. To update to {version}, run the following commands on your server:',
|
||||||
|
'admin.update.reloadHint': 'Please reload the page in a few seconds.',
|
||||||
|
|
||||||
// Vacay addon
|
// Vacay addon
|
||||||
'vacay.subtitle': 'Plan and manage vacation days',
|
'vacay.subtitle': 'Plan and manage vacation days',
|
||||||
@@ -396,9 +473,6 @@ const en = {
|
|||||||
'trip.confirm.deletePlace': 'Are you sure you want to delete this place?',
|
'trip.confirm.deletePlace': 'Are you sure you want to delete this place?',
|
||||||
|
|
||||||
// Day Plan Sidebar
|
// Day Plan Sidebar
|
||||||
'dayplan.transport.car': 'Car',
|
|
||||||
'dayplan.transport.walk': 'Walk',
|
|
||||||
'dayplan.transport.bike': 'Bike',
|
|
||||||
'dayplan.emptyDay': 'No places planned for this day',
|
'dayplan.emptyDay': 'No places planned for this day',
|
||||||
'dayplan.addNote': 'Add Note',
|
'dayplan.addNote': 'Add Note',
|
||||||
'dayplan.editNote': 'Edit Note',
|
'dayplan.editNote': 'Edit Note',
|
||||||
@@ -414,6 +488,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',
|
||||||
@@ -444,6 +521,8 @@ const en = {
|
|||||||
'places.noCategory': 'No Category',
|
'places.noCategory': 'No Category',
|
||||||
'places.categoryNamePlaceholder': 'Category name',
|
'places.categoryNamePlaceholder': 'Category name',
|
||||||
'places.formTime': 'Time',
|
'places.formTime': 'Time',
|
||||||
|
'places.startTime': 'Start',
|
||||||
|
'places.endTime': 'End',
|
||||||
'places.formWebsite': 'Website',
|
'places.formWebsite': 'Website',
|
||||||
'places.formNotesPlaceholder': 'Personal notes...',
|
'places.formNotesPlaceholder': 'Personal notes...',
|
||||||
'places.formReservation': 'Reservation',
|
'places.formReservation': 'Reservation',
|
||||||
@@ -455,11 +534,6 @@ const en = {
|
|||||||
'places.categoryCreateError': 'Failed to create category',
|
'places.categoryCreateError': 'Failed to create category',
|
||||||
'places.nameRequired': 'Please enter a name',
|
'places.nameRequired': 'Please enter a name',
|
||||||
'places.saveError': 'Failed to save',
|
'places.saveError': 'Failed to save',
|
||||||
'places.transport.walking': '🚶 Walking',
|
|
||||||
'places.transport.driving': '🚗 Driving',
|
|
||||||
'places.transport.cycling': '🚲 Cycling',
|
|
||||||
'places.transport.transit': '🚌 Transit',
|
|
||||||
|
|
||||||
// Place Inspector
|
// Place Inspector
|
||||||
'inspector.opened': 'Open',
|
'inspector.opened': 'Open',
|
||||||
'inspector.closed': 'Closed',
|
'inspector.closed': 'Closed',
|
||||||
@@ -473,6 +547,8 @@ const en = {
|
|||||||
'inspector.pendingRes': 'Pending Reservation',
|
'inspector.pendingRes': 'Pending Reservation',
|
||||||
'inspector.google': 'Open in Google Maps',
|
'inspector.google': 'Open in Google Maps',
|
||||||
'inspector.website': 'Open Website',
|
'inspector.website': 'Open Website',
|
||||||
|
'inspector.addRes': 'Reservation',
|
||||||
|
'inspector.editRes': 'Edit Reservation',
|
||||||
|
|
||||||
// Reservations
|
// Reservations
|
||||||
'reservations.title': 'Bookings',
|
'reservations.title': 'Bookings',
|
||||||
@@ -489,6 +565,8 @@ const en = {
|
|||||||
'reservations.editTitle': 'Edit Reservation',
|
'reservations.editTitle': 'Edit Reservation',
|
||||||
'reservations.status': 'Status',
|
'reservations.status': 'Status',
|
||||||
'reservations.datetime': 'Date & Time',
|
'reservations.datetime': 'Date & Time',
|
||||||
|
'reservations.date': 'Date',
|
||||||
|
'reservations.time': 'Time',
|
||||||
'reservations.timeAlt': 'Time (alternative, e.g. 19:30)',
|
'reservations.timeAlt': 'Time (alternative, e.g. 19:30)',
|
||||||
'reservations.notes': 'Notes',
|
'reservations.notes': 'Notes',
|
||||||
'reservations.notesPlaceholder': 'Additional notes...',
|
'reservations.notesPlaceholder': 'Additional notes...',
|
||||||
@@ -512,7 +590,7 @@ const en = {
|
|||||||
'reservations.titlePlaceholder': 'e.g. Lufthansa LH123, Hotel Adlon, ...',
|
'reservations.titlePlaceholder': 'e.g. Lufthansa LH123, Hotel Adlon, ...',
|
||||||
'reservations.locationAddress': 'Location / Address',
|
'reservations.locationAddress': 'Location / Address',
|
||||||
'reservations.locationPlaceholder': 'Address, Airport, Hotel...',
|
'reservations.locationPlaceholder': 'Address, Airport, Hotel...',
|
||||||
'reservations.confirmationCode': 'Confirmation Number / Booking Code',
|
'reservations.confirmationCode': 'Booking Code',
|
||||||
'reservations.confirmationPlaceholder': 'e.g. ABC12345',
|
'reservations.confirmationPlaceholder': 'e.g. ABC12345',
|
||||||
'reservations.day': 'Day',
|
'reservations.day': 'Day',
|
||||||
'reservations.noDay': 'No Day',
|
'reservations.noDay': 'No Day',
|
||||||
@@ -525,6 +603,9 @@ const en = {
|
|||||||
'reservations.toast.updateError': 'Failed to update',
|
'reservations.toast.updateError': 'Failed to update',
|
||||||
'reservations.toast.deleteError': 'Failed to delete',
|
'reservations.toast.deleteError': 'Failed to delete',
|
||||||
'reservations.confirm.remove': 'Remove reservation for "{name}"?',
|
'reservations.confirm.remove': 'Remove reservation for "{name}"?',
|
||||||
|
'reservations.linkAssignment': 'Link to day assignment',
|
||||||
|
'reservations.pickAssignment': 'Select an assignment from your plan...',
|
||||||
|
'reservations.noAssignment': 'No link (standalone)',
|
||||||
|
|
||||||
// Budget
|
// Budget
|
||||||
'budget.title': 'Budget',
|
'budget.title': 'Budget',
|
||||||
@@ -573,6 +654,8 @@ const en = {
|
|||||||
'files.toast.deleteError': 'Failed to delete file',
|
'files.toast.deleteError': 'Failed to delete file',
|
||||||
'files.sourcePlan': 'Day Plan',
|
'files.sourcePlan': 'Day Plan',
|
||||||
'files.sourceBooking': 'Booking',
|
'files.sourceBooking': 'Booking',
|
||||||
|
'files.attach': 'Attach',
|
||||||
|
'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)',
|
||||||
|
|
||||||
// Packing
|
// Packing
|
||||||
'packing.title': 'Packing List',
|
'packing.title': 'Packing List',
|
||||||
@@ -725,6 +808,21 @@ const en = {
|
|||||||
'backup.keep.30days': '30 days',
|
'backup.keep.30days': '30 days',
|
||||||
'backup.keep.forever': 'Keep forever',
|
'backup.keep.forever': 'Keep forever',
|
||||||
|
|
||||||
|
// Photos
|
||||||
|
'photos.allDays': 'All Days',
|
||||||
|
'photos.noPhotos': 'No photos yet',
|
||||||
|
'photos.uploadHint': 'Upload your travel photos',
|
||||||
|
'photos.clickToSelect': 'or click to select',
|
||||||
|
'photos.linkPlace': 'Link Place',
|
||||||
|
'photos.noPlace': 'No Place',
|
||||||
|
'photos.uploadN': '{n} photo(s) upload',
|
||||||
|
|
||||||
|
// Backup restore modal
|
||||||
|
'backup.restoreConfirmTitle': 'Restore Backup?',
|
||||||
|
'backup.restoreWarning': 'All current data (trips, places, users, uploads) will be permanently replaced by the backup. This action cannot be undone.',
|
||||||
|
'backup.restoreTip': 'Tip: Create a backup of the current state before restoring.',
|
||||||
|
'backup.restoreConfirm': 'Yes, restore',
|
||||||
|
|
||||||
// PDF
|
// PDF
|
||||||
'pdf.travelPlan': 'Travel Plan',
|
'pdf.travelPlan': 'Travel Plan',
|
||||||
'pdf.planned': 'Planned',
|
'pdf.planned': 'Planned',
|
||||||
@@ -732,6 +830,68 @@ const en = {
|
|||||||
'pdf.preview': 'PDF Preview',
|
'pdf.preview': 'PDF Preview',
|
||||||
'pdf.saveAsPdf': 'Save as PDF',
|
'pdf.saveAsPdf': 'Save as PDF',
|
||||||
|
|
||||||
|
// Planner
|
||||||
|
'planner.places': 'Places',
|
||||||
|
'planner.bookings': 'Bookings',
|
||||||
|
'planner.packingList': 'Packing List',
|
||||||
|
'planner.documents': 'Documents',
|
||||||
|
'planner.dayPlan': 'Day Plan',
|
||||||
|
'planner.reservations': 'Reservations',
|
||||||
|
'planner.minTwoPlaces': 'At least 2 places with coordinates needed',
|
||||||
|
'planner.noGeoPlaces': 'No places with coordinates available',
|
||||||
|
'planner.routeCalculated': 'Route calculated',
|
||||||
|
'planner.routeCalcFailed': 'Route could not be calculated',
|
||||||
|
'planner.routeError': 'Error calculating route',
|
||||||
|
'planner.routeOptimized': 'Route optimized',
|
||||||
|
'planner.reservationUpdated': 'Reservation updated',
|
||||||
|
'planner.reservationAdded': 'Reservation added',
|
||||||
|
'planner.confirmDeleteReservation': 'Delete reservation?',
|
||||||
|
'planner.reservationDeleted': 'Reservation deleted',
|
||||||
|
'planner.days': 'Days',
|
||||||
|
'planner.allPlaces': 'All Places',
|
||||||
|
'planner.totalPlaces': '{n} places total',
|
||||||
|
'planner.noDaysPlanned': 'No days planned yet',
|
||||||
|
'planner.editTrip': 'Edit trip \u2192',
|
||||||
|
'planner.placeOne': '1 place',
|
||||||
|
'planner.placeN': '{n} places',
|
||||||
|
'planner.addNote': 'Add note',
|
||||||
|
'planner.noEntries': 'No entries for this day',
|
||||||
|
'planner.addPlace': 'Add place',
|
||||||
|
'planner.addPlaceShort': '+ Add place',
|
||||||
|
'planner.resPending': 'Reservation pending · ',
|
||||||
|
'planner.resConfirmed': 'Reservation confirmed · ',
|
||||||
|
'planner.notePlaceholder': 'Note\u2026',
|
||||||
|
'planner.noteTimePlaceholder': 'Time (optional)',
|
||||||
|
'planner.noteExamplePlaceholder': 'e.g. S3 at 14:30 from central station, ferry from pier 7, lunch break\u2026',
|
||||||
|
'planner.totalCost': 'Total cost',
|
||||||
|
'planner.searchPlaces': 'Search places\u2026',
|
||||||
|
'planner.allCategories': 'All Categories',
|
||||||
|
'planner.noPlacesFound': 'No places found',
|
||||||
|
'planner.addFirstPlace': 'Add first place',
|
||||||
|
'planner.noReservations': 'No reservations',
|
||||||
|
'planner.addFirstReservation': 'Add first reservation',
|
||||||
|
'planner.new': 'New',
|
||||||
|
'planner.addToDay': '+ Day',
|
||||||
|
'planner.calculating': 'Calculating\u2026',
|
||||||
|
'planner.route': 'Route',
|
||||||
|
'planner.optimize': 'Optimize',
|
||||||
|
'planner.openGoogleMaps': 'Open in Google Maps',
|
||||||
|
'planner.selectDayHint': 'Select a day from the left list to see the day plan',
|
||||||
|
'planner.noPlacesForDay': 'No places for this day yet',
|
||||||
|
'planner.addPlacesLink': 'Add places \u2192',
|
||||||
|
'planner.minTotal': 'min. total',
|
||||||
|
'planner.noReservation': 'No reservation',
|
||||||
|
'planner.removeFromDay': 'Remove from day',
|
||||||
|
'planner.addToThisDay': 'Add to day',
|
||||||
|
'planner.overview': 'Overview',
|
||||||
|
'planner.noDays': 'No days yet',
|
||||||
|
'planner.editTripToAddDays': 'Edit trip to add days',
|
||||||
|
'planner.dayCount': '{n} Days',
|
||||||
|
'planner.clickToUnlock': 'Click to unlock',
|
||||||
|
'planner.keepPosition': 'Keep position during route optimization',
|
||||||
|
'planner.dayDetails': 'Day details',
|
||||||
|
'planner.dayN': 'Day {n}',
|
||||||
|
|
||||||
// Dashboard Stats
|
// Dashboard Stats
|
||||||
'stats.countries': 'Countries',
|
'stats.countries': 'Countries',
|
||||||
'stats.cities': 'Cities',
|
'stats.cities': 'Cities',
|
||||||
@@ -741,6 +901,26 @@ const en = {
|
|||||||
'stats.visited': 'visited',
|
'stats.visited': 'visited',
|
||||||
'stats.remaining': 'remaining',
|
'stats.remaining': 'remaining',
|
||||||
'stats.visitedCountries': 'Visited Countries',
|
'stats.visitedCountries': 'Visited Countries',
|
||||||
|
|
||||||
|
// Day Detail Panel
|
||||||
|
'day.precipProb': 'Rain probability',
|
||||||
|
'day.precipitation': 'Precipitation',
|
||||||
|
'day.wind': 'Wind',
|
||||||
|
'day.sunrise': 'Sunrise',
|
||||||
|
'day.sunset': 'Sunset',
|
||||||
|
'day.hourlyForecast': 'Hourly Forecast',
|
||||||
|
'day.climateHint': 'Historical averages — real forecast available within 16 days of this date.',
|
||||||
|
'day.noWeather': 'No weather data available. Add a place with coordinates.',
|
||||||
|
'day.accommodation': 'Accommodation',
|
||||||
|
'day.addAccommodation': 'Add accommodation',
|
||||||
|
'day.hotelDayRange': 'Apply to days',
|
||||||
|
'day.noPlacesForHotel': 'Add places to your trip first',
|
||||||
|
'day.allDays': 'All',
|
||||||
|
'day.checkIn': 'Check-in',
|
||||||
|
'day.checkOut': 'Check-out',
|
||||||
|
'day.confirmation': 'Confirmation',
|
||||||
|
'day.editAccommodation': 'Edit accommodation',
|
||||||
|
'day.reservations': 'Reservations',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default en
|
export default en
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import Modal from '../components/shared/Modal'
|
|||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
import CategoryManager from '../components/Admin/CategoryManager'
|
import CategoryManager from '../components/Admin/CategoryManager'
|
||||||
import BackupPanel from '../components/Admin/BackupPanel'
|
import BackupPanel from '../components/Admin/BackupPanel'
|
||||||
|
import GitHubPanel from '../components/Admin/GitHubPanel'
|
||||||
import AddonManager from '../components/Admin/AddonManager'
|
import AddonManager from '../components/Admin/AddonManager'
|
||||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus } from 'lucide-react'
|
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun } from 'lucide-react'
|
||||||
import CustomSelect from '../components/shared/CustomSelect'
|
import CustomSelect from '../components/shared/CustomSelect'
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
@@ -23,6 +24,7 @@ export default function AdminPage() {
|
|||||||
{ id: 'addons', label: t('admin.tabs.addons') },
|
{ id: 'addons', label: t('admin.tabs.addons') },
|
||||||
{ id: 'settings', label: t('admin.tabs.settings') },
|
{ id: 'settings', label: t('admin.tabs.settings') },
|
||||||
{ id: 'backup', label: t('admin.tabs.backup') },
|
{ id: 'backup', label: t('admin.tabs.backup') },
|
||||||
|
{ id: 'github', label: t('admin.tabs.github') },
|
||||||
]
|
]
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('users')
|
const [activeTab, setActiveTab] = useState('users')
|
||||||
@@ -49,6 +51,12 @@ export default function AdminPage() {
|
|||||||
const [validating, setValidating] = useState({})
|
const [validating, setValidating] = useState({})
|
||||||
const [validation, setValidation] = useState({})
|
const [validation, setValidation] = useState({})
|
||||||
|
|
||||||
|
// Version check & update
|
||||||
|
const [updateInfo, setUpdateInfo] = useState(null)
|
||||||
|
const [showUpdateModal, setShowUpdateModal] = useState(false)
|
||||||
|
const [updating, setUpdating] = useState(false)
|
||||||
|
const [updateResult, setUpdateResult] = useState(null) // 'success' | 'error'
|
||||||
|
|
||||||
const { user: currentUser, updateApiKeys } = useAuthStore()
|
const { user: currentUser, updateApiKeys } = useAuthStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -58,6 +66,9 @@ export default function AdminPage() {
|
|||||||
loadAppConfig()
|
loadAppConfig()
|
||||||
loadApiKeys()
|
loadApiKeys()
|
||||||
adminApi.getOidc().then(setOidcConfig).catch(() => {})
|
adminApi.getOidc().then(setOidcConfig).catch(() => {})
|
||||||
|
adminApi.checkVersion().then(data => {
|
||||||
|
if (data.update_available) setUpdateInfo(data)
|
||||||
|
}).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -95,6 +106,26 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleInstallUpdate = async () => {
|
||||||
|
setUpdating(true)
|
||||||
|
setUpdateResult(null)
|
||||||
|
try {
|
||||||
|
await adminApi.installUpdate()
|
||||||
|
setUpdateResult('success')
|
||||||
|
// Server is restarting — poll until it comes back, then reload
|
||||||
|
const poll = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await authApi.getAppConfig()
|
||||||
|
clearInterval(poll)
|
||||||
|
window.location.reload()
|
||||||
|
} catch { /* still restarting */ }
|
||||||
|
}, 2000)
|
||||||
|
} catch {
|
||||||
|
setUpdateResult('error')
|
||||||
|
setUpdating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleToggleRegistration = async (value) => {
|
const handleToggleRegistration = async (value) => {
|
||||||
setAllowRegistration(value)
|
setAllowRegistration(value)
|
||||||
try {
|
try {
|
||||||
@@ -209,7 +240,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">
|
||||||
@@ -222,6 +253,53 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Update Banner */}
|
||||||
|
{updateInfo && (
|
||||||
|
<div className="mb-6 p-4 rounded-xl border flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-amber-50 dark:bg-amber-950/40 border-amber-300 dark:border-amber-700">
|
||||||
|
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center bg-amber-500 dark:bg-amber-600">
|
||||||
|
<ArrowUpCircle className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-amber-900 dark:text-amber-200">{t('admin.update.available')}</p>
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
|
||||||
|
{t('admin.update.text').replace('{version}', `v${updateInfo.latest}`).replace('{current}', `v${updateInfo.current}`)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{updateInfo.release_url && (
|
||||||
|
<a
|
||||||
|
href={updateInfo.release_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors text-amber-800 dark:text-amber-300 border border-amber-300 dark:border-amber-600 hover:bg-amber-100 dark:hover:bg-amber-900/50"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
{t('admin.update.button')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{updateInfo.is_docker ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUpdateModal(true)}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
{t('admin.update.howTo')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUpdateModal(true)}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
{t('admin.update.install')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Demo Baseline Button */}
|
{/* Demo Baseline Button */}
|
||||||
{demoMode && (
|
{demoMode && (
|
||||||
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl flex items-center justify-between">
|
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl flex items-center justify-between">
|
||||||
@@ -426,7 +504,7 @@ export default function AdminPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-1.5">
|
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-1.5">
|
||||||
{t('admin.mapsKey')}
|
{t('admin.mapsKey')}
|
||||||
<span style={{ fontSize: 10, fontWeight: 500, padding: '1px 7px', borderRadius: 99, background: '#dbeafe', color: '#1d4ed8' }}>{t('admin.recommended')}</span>
|
<span className="text-[9px] font-medium px-1.5 py-px rounded-full bg-emerald-200 dark:bg-emerald-800 text-emerald-800 dark:text-emerald-200">{t('admin.recommended')}</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
@@ -475,54 +553,35 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OpenWeatherMap Key */}
|
{/* Open-Meteo Weather Info */}
|
||||||
<div>
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/30 dark:border-emerald-800 overflow-hidden">
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.weatherKey')}</label>
|
<div className="px-4 py-3 flex items-center justify-between">
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="w-7 h-7 rounded-lg bg-emerald-500 flex items-center justify-center flex-shrink-0">
|
||||||
<input
|
<Sun className="w-3.5 h-3.5 text-white" />
|
||||||
type={showKeys.weather ? 'text' : 'password'}
|
</div>
|
||||||
value={weatherKey}
|
<span className="text-sm font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.title')}</span>
|
||||||
onChange={e => setWeatherKey(e.target.value)}
|
</div>
|
||||||
placeholder={t('settings.keyPlaceholder')}
|
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-emerald-200 dark:bg-emerald-800 text-emerald-800 dark:text-emerald-200">{t('admin.weather.badge')}</span>
|
||||||
className="w-full pr-10 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
</div>
|
||||||
/>
|
<div className="px-4 pb-3">
|
||||||
<button
|
<p className="text-xs text-emerald-800 dark:text-emerald-300 leading-relaxed">{t('admin.weather.description')}</p>
|
||||||
type="button"
|
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-1.5 leading-relaxed">{t('admin.weather.locationHint')}</p>
|
||||||
onClick={() => toggleKey('weather')}
|
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
<div className="rounded-md bg-white dark:bg-emerald-900/40 px-3 py-2 border border-emerald-100 dark:border-emerald-800">
|
||||||
>
|
<p className="text-xs font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.forecast')}</p>
|
||||||
{showKeys.weather ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t('admin.weather.forecastDesc')}</p>
|
||||||
</button>
|
</div>
|
||||||
|
<div className="rounded-md bg-white dark:bg-emerald-900/40 px-3 py-2 border border-emerald-100 dark:border-emerald-800">
|
||||||
|
<p className="text-xs font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.climate')}</p>
|
||||||
|
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t('admin.weather.climateDesc')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-white dark:bg-emerald-900/40 px-3 py-2 border border-emerald-100 dark:border-emerald-800">
|
||||||
|
<p className="text-xs font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.requests')}</p>
|
||||||
|
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t('admin.weather.requestsDesc')}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => handleValidateKey('weather')}
|
|
||||||
disabled={!weatherKey || validating.weather}
|
|
||||||
className="px-3 py-2 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
{validating.weather ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : validation.weather === true ? (
|
|
||||||
<CheckCircle className="w-4 h-4 text-emerald-500" />
|
|
||||||
) : validation.weather === false ? (
|
|
||||||
<XCircle className="w-4 h-4 text-red-500" />
|
|
||||||
) : null}
|
|
||||||
{t('admin.validateKey')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-1">{t('admin.weatherKeyHint')}</p>
|
|
||||||
{validation.weather === true && (
|
|
||||||
<p className="text-xs text-emerald-600 mt-1 flex items-center gap-1">
|
|
||||||
<span className="w-2 h-2 bg-emerald-500 rounded-full inline-block"></span>
|
|
||||||
{t('admin.keyValid')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{validation.weather === false && (
|
|
||||||
<p className="text-xs text-red-500 mt-1 flex items-center gap-1">
|
|
||||||
<span className="w-2 h-2 bg-red-500 rounded-full inline-block"></span>
|
|
||||||
{t('admin.keyInvalid')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -606,6 +665,8 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'backup' && <BackupPanel />}
|
{activeTab === 'backup' && <BackupPanel />}
|
||||||
|
|
||||||
|
{activeTab === 'github' && <GitHubPanel />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -744,6 +805,171 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Update confirmation popup — matches backup restore style */}
|
||||||
|
{showUpdateModal && (
|
||||||
|
<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={() => { if (!updating) setShowUpdateModal(false) }}
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{updateResult === 'success' ? (
|
||||||
|
<>
|
||||||
|
<div style={{ background: 'linear-gradient(135deg, #16a34a, #15803d)', 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 }}>
|
||||||
|
<CheckCircle size={20} style={{ color: 'white' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.success')}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '20px 24px', textAlign: 'center' }}>
|
||||||
|
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2" style={{ color: 'var(--text-muted)' }} />
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('admin.update.reloadHint')}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : updateResult === 'error' ? (
|
||||||
|
<>
|
||||||
|
<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 }}>
|
||||||
|
<XCircle size={20} style={{ color: 'white' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.failed')}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '0 24px 20px', display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowUpdateModal(false); setUpdateResult(null) }}
|
||||||
|
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
||||||
|
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 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' }}>{t('admin.update.confirmTitle')}</h3>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
|
||||||
|
v{updateInfo?.current} → v{updateInfo?.latest}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div style={{ padding: '20px 24px' }}>
|
||||||
|
{updateInfo?.is_docker ? (
|
||||||
|
<>
|
||||||
|
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||||
|
{t('admin.update.dockerText').replace('{version}', `v${updateInfo.latest}`)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||||
|
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
|
||||||
|
>
|
||||||
|
{`docker pull mauriceboe/nomad:latest
|
||||||
|
docker stop nomad && docker rm nomad
|
||||||
|
docker run -d --name nomad \\
|
||||||
|
-p 3000:3000 \\
|
||||||
|
-v /opt/nomad/data:/app/data \\
|
||||||
|
-v /opt/nomad/uploads:/app/uploads \\
|
||||||
|
--restart unless-stopped \\
|
||||||
|
mauriceboe/nomad:latest`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||||
|
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>{t('admin.update.dataInfo')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||||
|
{updateInfo && t('admin.update.confirmText').replace('{current}', `v${updateInfo.current}`).replace('{version}', `v${updateInfo.latest}`)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||||
|
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>{t('admin.update.dataInfo')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||||
|
className="bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Download className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>
|
||||||
|
{t('admin.update.backupHint')}{' '}
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowUpdateModal(false); setActiveTab('backup') }}
|
||||||
|
className="underline font-semibold hover:text-blue-950 dark:hover:text-blue-100"
|
||||||
|
>{t('admin.update.backupLink')}</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 10, 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"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>{t('admin.update.warning')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUpdateModal(false)}
|
||||||
|
disabled={updating}
|
||||||
|
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40"
|
||||||
|
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
{!updateInfo?.is_docker && (
|
||||||
|
<button
|
||||||
|
onClick={handleInstallUpdate}
|
||||||
|
disabled={updating}
|
||||||
|
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200 disabled:opacity-60 flex items-center gap-2"
|
||||||
|
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{updating ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download size={14} />
|
||||||
|
)}
|
||||||
|
{updating ? t('admin.update.installing') : t('admin.update.confirm')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ export default function AtlasPage() {
|
|||||||
const { settings } = useSettingsStore()
|
const { settings } = useSettingsStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const resolveName = useCountryNames(language)
|
const resolveName = useCountryNames(language)
|
||||||
const dark = settings.dark_mode
|
const dm = settings.dark_mode
|
||||||
|
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
const mapRef = useRef(null)
|
const mapRef = useRef(null)
|
||||||
const mapInstance = useRef(null)
|
const mapInstance = useRef(null)
|
||||||
const geoLayerRef = useRef(null)
|
const geoLayerRef = useRef(null)
|
||||||
@@ -270,7 +271,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 +281,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 +422,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 +435,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,8 @@ 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 dm = settings.dark_mode
|
||||||
|
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
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
|
||||||
@@ -390,6 +426,7 @@ export default function DashboardPage() {
|
|||||||
const data = await tripsApi.create(tripData)
|
const data = await tripsApi.create(tripData)
|
||||||
setTrips(prev => sortTrips([data.trip, ...prev]))
|
setTrips(prev => sortTrips([data.trip, ...prev]))
|
||||||
toast.success(t('dashboard.toast.created'))
|
toast.success(t('dashboard.toast.created'))
|
||||||
|
return data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || t('dashboard.toast.createError'))
|
throw new Error(err.response?.data?.error || t('dashboard.toast.createError'))
|
||||||
}
|
}
|
||||||
@@ -456,7 +493,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 +612,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 +672,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>
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { tripsApi, placesApi } from '../api/client'
|
|||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
import FileManager from '../components/Files/FileManager'
|
import FileManager from '../components/Files/FileManager'
|
||||||
import { ArrowLeft } from 'lucide-react'
|
import { ArrowLeft } from 'lucide-react'
|
||||||
|
import { useTranslation } from '../i18n'
|
||||||
|
|
||||||
export default function FilesPage() {
|
export default function FilesPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { id: tripId } = useParams()
|
const { id: tripId } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const tripStore = useTripStore()
|
const tripStore = useTripStore()
|
||||||
@@ -61,7 +63,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
|
||||||
@@ -69,7 +71,7 @@ export default function FilesPage() {
|
|||||||
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700"
|
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Zurück zur Planung
|
{t('common.backToPlanning')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -42,10 +44,10 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
if (oidcError) {
|
if (oidcError) {
|
||||||
const errorMessages = {
|
const errorMessages = {
|
||||||
registration_disabled: language === 'de' ? 'Registrierung ist deaktiviert. Kontaktiere den Administrator.' : 'Registration is disabled. Contact your administrator.',
|
registration_disabled: t('login.oidc.registrationDisabled'),
|
||||||
no_email: language === 'de' ? 'Keine E-Mail vom Provider erhalten.' : 'No email received from provider.',
|
no_email: t('login.oidc.noEmail'),
|
||||||
token_failed: language === 'de' ? 'Authentifizierung fehlgeschlagen.' : 'Authentication failed.',
|
token_failed: t('login.oidc.tokenFailed'),
|
||||||
invalid_state: language === 'de' ? 'Ungueltige Sitzung. Bitte erneut versuchen.' : 'Invalid session. Please try again.',
|
invalid_state: t('login.oidc.invalidState'),
|
||||||
}
|
}
|
||||||
setError(errorMessages[oidcError] || oidcError)
|
setError(errorMessages[oidcError] || oidcError)
|
||||||
window.history.replaceState({}, '', '/login')
|
window.history.replaceState({}, '', '/login')
|
||||||
@@ -57,14 +59,17 @@ export default function LoginPage() {
|
|||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
await demoLogin()
|
await demoLogin()
|
||||||
navigate('/dashboard')
|
setShowTakeoff(true)
|
||||||
|
setTimeout(() => navigate('/dashboard'), 2600)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Demo-Login fehlgeschlagen')
|
setError(err.message || t('login.demoFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [showTakeoff, setShowTakeoff] = useState(false)
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
@@ -77,10 +82,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 +98,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', whiteSpace: 'nowrap' }}>{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 +369,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 +412,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: 16, color: '#9ca3af', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase', whiteSpace: 'nowrap' }}>{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 +495,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>
|
||||||
@@ -366,7 +517,7 @@ export default function LoginPage() {
|
|||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}>
|
||||||
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
||||||
<span style={{ fontSize: 12, color: '#9ca3af' }}>{language === 'de' ? 'oder' : 'or'}</span>
|
<span style={{ fontSize: 12, color: '#9ca3af' }}>{t('common.or')}</span>
|
||||||
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
||||||
</div>
|
</div>
|
||||||
<a href="/api/auth/oidc/login"
|
<a href="/api/auth/oidc/login"
|
||||||
@@ -383,7 +534,7 @@ export default function LoginPage() {
|
|||||||
onMouseLeave={e => { e.currentTarget.style.background = 'white'; e.currentTarget.style.borderColor = '#d1d5db' }}
|
onMouseLeave={e => { e.currentTarget.style.background = 'white'; e.currentTarget.style.borderColor = '#d1d5db' }}
|
||||||
>
|
>
|
||||||
<Shield size={16} />
|
<Shield size={16} />
|
||||||
{language === 'de' ? `Anmelden mit ${appConfig.oidc_display_name}` : `Sign in with ${appConfig.oidc_display_name}`}
|
{t('login.oidcSignIn', { name: appConfig.oidc_display_name })}
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -404,7 +555,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
|
{t('login.demoHint')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { tripsApi, daysApi, placesApi } from '../api/client'
|
|||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
import PhotoGallery from '../components/Photos/PhotoGallery'
|
import PhotoGallery from '../components/Photos/PhotoGallery'
|
||||||
import { ArrowLeft } from 'lucide-react'
|
import { ArrowLeft } from 'lucide-react'
|
||||||
|
import { useTranslation } from '../i18n'
|
||||||
|
|
||||||
export default function PhotosPage() {
|
export default function PhotosPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { id: tripId } = useParams()
|
const { id: tripId } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const tripStore = useTripStore()
|
const tripStore = useTripStore()
|
||||||
@@ -71,7 +73,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">
|
||||||
@@ -80,7 +82,7 @@ export default function PhotosPage() {
|
|||||||
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700"
|
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Zurück zur Planung
|
{t('common.backToPlanning')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
|
import { useTranslation } from '../i18n'
|
||||||
import { Map, Eye, EyeOff, Mail, Lock, User } from 'lucide-react'
|
import { Map, Eye, EyeOff, Mail, Lock, User } from 'lucide-react'
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
@@ -20,12 +22,12 @@ export default function RegisterPage() {
|
|||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
setError('Passwörter stimmen nicht überein')
|
setError(t('register.passwordMismatch'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 6) {
|
if (password.length < 6) {
|
||||||
setError('Passwort muss mindestens 6 Zeichen lang sein')
|
setError(t('register.passwordTooShort'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ export default function RegisterPage() {
|
|||||||
await register(username, email, password)
|
await register(username, email, password)
|
||||||
navigate('/dashboard')
|
navigate('/dashboard')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Registrierung fehlgeschlagen')
|
setError(err.message || t('register.failed'))
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -48,19 +50,19 @@ export default function RegisterPage() {
|
|||||||
<div className="w-20 h-20 bg-white/10 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
<div className="w-20 h-20 bg-white/10 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||||
<Map className="w-10 h-10 text-white" />
|
<Map className="w-10 h-10 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-bold mb-4">Jetzt starten</h1>
|
<h1 className="text-4xl font-bold mb-4">{t('register.getStarted')}</h1>
|
||||||
<p className="text-slate-300 text-lg leading-relaxed">
|
<p className="text-slate-300 text-lg leading-relaxed">
|
||||||
Erstellen Sie ein Konto und beginnen Sie, Ihre Traumreisen zu planen.
|
{t('register.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-10 space-y-3 text-left">
|
<div className="mt-10 space-y-3 text-left">
|
||||||
{[
|
{[
|
||||||
'✓ Unbegrenzte Reisepläne',
|
`✓ ${t('register.feature1')}`,
|
||||||
'✓ Interaktive Kartenansicht',
|
`✓ ${t('register.feature2')}`,
|
||||||
'✓ Orte und Kategorien verwalten',
|
`✓ ${t('register.feature3')}`,
|
||||||
'✓ Reservierungen tracken',
|
`✓ ${t('register.feature4')}`,
|
||||||
'✓ Packlisten erstellen',
|
`✓ ${t('register.feature5')}`,
|
||||||
'✓ Fotos und Dateien speichern',
|
`✓ ${t('register.feature6')}`,
|
||||||
].map(item => (
|
].map(item => (
|
||||||
<p key={item} className="text-slate-200 text-sm">{item}</p>
|
<p key={item} className="text-slate-200 text-sm">{item}</p>
|
||||||
))}
|
))}
|
||||||
@@ -77,8 +79,8 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
|
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
|
||||||
<h2 className="text-2xl font-bold text-slate-900 mb-1">Konto erstellen</h2>
|
<h2 className="text-2xl font-bold text-slate-900 mb-1">{t('register.createAccount')}</h2>
|
||||||
<p className="text-slate-500 mb-8">Beginnen Sie Ihre Reiseplanung</p>
|
<p className="text-slate-500 mb-8">{t('register.startPlanning')}</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
{error && (
|
{error && (
|
||||||
@@ -88,7 +90,7 @@ export default function RegisterPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Benutzername</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
@@ -96,7 +98,7 @@ export default function RegisterPage() {
|
|||||||
value={username}
|
value={username}
|
||||||
onChange={e => setUsername(e.target.value)}
|
onChange={e => setUsername(e.target.value)}
|
||||||
required
|
required
|
||||||
placeholder="maxmustermann"
|
placeholder="johndoe"
|
||||||
minLength={3}
|
minLength={3}
|
||||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||||
/>
|
/>
|
||||||
@@ -104,7 +106,7 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">E-Mail</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.email')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
@@ -112,14 +114,14 @@ export default function RegisterPage() {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={e => setEmail(e.target.value)}
|
onChange={e => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
placeholder="ihre@email.de"
|
placeholder="your@email.com"
|
||||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Passwort</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
@@ -127,7 +129,7 @@ export default function RegisterPage() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={e => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
placeholder="Mind. 6 Zeichen"
|
placeholder={t('register.minChars')}
|
||||||
className="w-full pl-10 pr-12 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
className="w-full pl-10 pr-12 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -141,7 +143,7 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Passwort bestätigen</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('register.confirmPassword')}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
@@ -149,7 +151,7 @@ export default function RegisterPage() {
|
|||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={e => setConfirmPassword(e.target.value)}
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
placeholder="Passwort wiederholen"
|
placeholder={t('register.repeatPassword')}
|
||||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,17 +165,17 @@ export default function RegisterPage() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||||
Registrieren...
|
{t('register.registering')}
|
||||||
</>
|
</>
|
||||||
) : 'Registrieren'}
|
) : t('register.register')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
Bereits ein Konto?{' '}
|
{t('register.hasAccount')}{' '}
|
||||||
<Link to="/login" className="text-slate-900 hover:text-slate-700 font-medium">
|
<Link to="/login" className="text-slate-900 hover:text-slate-700 font-medium">
|
||||||
Anmelden
|
{t('register.signIn')}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useTranslation } from '../i18n'
|
|||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
import CustomSelect from '../components/shared/CustomSelect'
|
import CustomSelect from '../components/shared/CustomSelect'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
import { Save, Map, Palette, User, Moon, Sun, Shield, Camera, Trash2, Lock } from 'lucide-react'
|
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock } from 'lucide-react'
|
||||||
import { authApi, adminApi } from '../api/client'
|
import { authApi, adminApi } from '../api/client'
|
||||||
|
|
||||||
const MAP_PRESETS = [
|
const MAP_PRESETS = [
|
||||||
@@ -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>
|
||||||
@@ -208,30 +208,35 @@ export default function SettingsPage() {
|
|||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.colorMode')}</label>
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.colorMode')}</label>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{[
|
{[
|
||||||
{ value: false, label: t('settings.light'), icon: Sun },
|
{ value: 'light', label: t('settings.light'), icon: Sun },
|
||||||
{ value: true, label: t('settings.dark'), icon: Moon },
|
{ value: 'dark', label: t('settings.dark'), icon: Moon },
|
||||||
].map(opt => (
|
{ value: 'auto', label: t('settings.auto'), icon: Monitor },
|
||||||
<button
|
].map(opt => {
|
||||||
key={String(opt.value)}
|
const current = settings.dark_mode
|
||||||
onClick={async () => {
|
const isActive = current === opt.value || (opt.value === 'light' && current === false) || (opt.value === 'dark' && current === true)
|
||||||
try {
|
return (
|
||||||
await updateSetting('dark_mode', opt.value)
|
<button
|
||||||
} catch (e) { toast.error(e.message) }
|
key={opt.value}
|
||||||
}}
|
onClick={async () => {
|
||||||
style={{
|
try {
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
await updateSetting('dark_mode', opt.value)
|
||||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
} catch (e) { toast.error(e.message) }
|
||||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
}}
|
||||||
border: settings.dark_mode === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
style={{
|
||||||
background: settings.dark_mode === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
color: 'var(--text-primary)',
|
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||||
transition: 'all 0.15s',
|
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||||
}}
|
border: isActive ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||||
>
|
background: isActive ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||||
<opt.icon size={16} />
|
color: 'var(--text-primary)',
|
||||||
{opt.label}
|
transition: 'all 0.15s',
|
||||||
</button>
|
}}
|
||||||
))}
|
>
|
||||||
|
<opt.icon size={16} />
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { MapView } from '../components/Map/MapView'
|
|||||||
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
||||||
import PlacesSidebar from '../components/Planner/PlacesSidebar'
|
import PlacesSidebar from '../components/Planner/PlacesSidebar'
|
||||||
import PlaceInspector from '../components/Planner/PlaceInspector'
|
import PlaceInspector from '../components/Planner/PlaceInspector'
|
||||||
|
import DayDetailPanel from '../components/Planner/DayDetailPanel'
|
||||||
import PlaceFormModal from '../components/Planner/PlaceFormModal'
|
import PlaceFormModal from '../components/Planner/PlaceFormModal'
|
||||||
import TripFormModal from '../components/Trips/TripFormModal'
|
import TripFormModal from '../components/Trips/TripFormModal'
|
||||||
import TripMembersModal from '../components/Trips/TripMembersModal'
|
import TripMembersModal from '../components/Trips/TripMembersModal'
|
||||||
@@ -20,7 +21,7 @@ import { useToast } from '../components/shared/Toast'
|
|||||||
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
|
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
|
||||||
import { useTranslation } from '../i18n'
|
import { useTranslation } from '../i18n'
|
||||||
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
|
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
|
||||||
import { addonsApi } from '../api/client'
|
import { addonsApi, accommodationsApi } from '../api/client'
|
||||||
|
|
||||||
const MIN_SIDEBAR = 200
|
const MIN_SIDEBAR = 200
|
||||||
const MAX_SIDEBAR = 520
|
const MAX_SIDEBAR = 520
|
||||||
@@ -35,6 +36,11 @@ export default function TripPlannerPage() {
|
|||||||
const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore
|
const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore
|
||||||
|
|
||||||
const [enabledAddons, setEnabledAddons] = useState({ packing: true, budget: true, documents: true })
|
const [enabledAddons, setEnabledAddons] = useState({ packing: true, budget: true, documents: true })
|
||||||
|
const [tripAccommodations, setTripAccommodations] = useState([])
|
||||||
|
|
||||||
|
const loadAccommodations = useCallback(() => {
|
||||||
|
if (tripId) accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
||||||
|
}, [tripId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
addonsApi.enabled().then(data => {
|
addonsApi.enabled().then(data => {
|
||||||
@@ -63,10 +69,24 @@ export default function TripPlannerPage() {
|
|||||||
const [rightWidth, setRightWidth] = useState(() => parseInt(localStorage.getItem('sidebarRightWidth')) || 300)
|
const [rightWidth, setRightWidth] = useState(() => parseInt(localStorage.getItem('sidebarRightWidth')) || 300)
|
||||||
const [leftCollapsed, setLeftCollapsed] = useState(false)
|
const [leftCollapsed, setLeftCollapsed] = useState(false)
|
||||||
const [rightCollapsed, setRightCollapsed] = useState(false)
|
const [rightCollapsed, setRightCollapsed] = useState(false)
|
||||||
|
const [showDayDetail, setShowDayDetail] = useState(null) // day object or null
|
||||||
const isResizingLeft = useRef(false)
|
const isResizingLeft = useRef(false)
|
||||||
const isResizingRight = useRef(false)
|
const isResizingRight = useRef(false)
|
||||||
|
|
||||||
const [selectedPlaceId, setSelectedPlaceId] = useState(null)
|
const [selectedPlaceId, _setSelectedPlaceId] = useState(null)
|
||||||
|
const [selectedAssignmentId, setSelectedAssignmentId] = useState(null)
|
||||||
|
|
||||||
|
// Set place selection - from PlacesSidebar/Map (no assignment context)
|
||||||
|
const setSelectedPlaceId = useCallback((placeId) => {
|
||||||
|
_setSelectedPlaceId(placeId)
|
||||||
|
setSelectedAssignmentId(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Set assignment selection - from DayPlanSidebar (specific assignment)
|
||||||
|
const selectAssignment = useCallback((assignmentId, placeId) => {
|
||||||
|
setSelectedAssignmentId(assignmentId)
|
||||||
|
_setSelectedPlaceId(placeId)
|
||||||
|
}, [])
|
||||||
const [showPlaceForm, setShowPlaceForm] = useState(false)
|
const [showPlaceForm, setShowPlaceForm] = useState(false)
|
||||||
const [editingPlace, setEditingPlace] = useState(null)
|
const [editingPlace, setEditingPlace] = useState(null)
|
||||||
const [showTripForm, setShowTripForm] = useState(false)
|
const [showTripForm, setShowTripForm] = useState(false)
|
||||||
@@ -83,6 +103,7 @@ export default function TripPlannerPage() {
|
|||||||
if (tripId) {
|
if (tripId) {
|
||||||
tripStore.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
|
tripStore.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
|
||||||
tripStore.loadFiles(tripId)
|
tripStore.loadFiles(tripId)
|
||||||
|
loadAccommodations()
|
||||||
}
|
}
|
||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
@@ -133,13 +154,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 +163,26 @@ export default function TripPlannerPage() {
|
|||||||
} else {
|
} else {
|
||||||
setRoute(null)
|
setRoute(null)
|
||||||
}
|
}
|
||||||
|
setRouteInfo(null)
|
||||||
}, [tripStore])
|
}, [tripStore])
|
||||||
|
|
||||||
const handlePlaceClick = useCallback((placeId) => {
|
const handleSelectDay = useCallback((dayId, skipFit) => {
|
||||||
setSelectedPlaceId(placeId)
|
const changed = dayId !== selectedDayId
|
||||||
if (placeId) { setLeftCollapsed(false); setRightCollapsed(false) }
|
tripStore.setSelectedDay(dayId)
|
||||||
}, [])
|
if (changed && !skipFit) setFitKey(k => k + 1)
|
||||||
|
setMobileSidebarOpen(null)
|
||||||
|
updateRouteForDay(dayId)
|
||||||
|
}, [tripStore, updateRouteForDay, selectedDayId])
|
||||||
|
|
||||||
|
const handlePlaceClick = useCallback((placeId, assignmentId) => {
|
||||||
|
if (assignmentId) {
|
||||||
|
selectAssignment(assignmentId, placeId)
|
||||||
|
} else {
|
||||||
|
setSelectedPlaceId(placeId)
|
||||||
|
}
|
||||||
|
if (placeId) { setShowDayDetail(null); setLeftCollapsed(false); setRightCollapsed(false) }
|
||||||
|
updateRouteForDay(selectedDayId)
|
||||||
|
}, [selectedDayId, updateRouteForDay, selectAssignment, setSelectedPlaceId])
|
||||||
|
|
||||||
const handleMarkerClick = useCallback((placeId) => {
|
const handleMarkerClick = useCallback((placeId) => {
|
||||||
const opening = placeId !== undefined
|
const opening = placeId !== undefined
|
||||||
@@ -165,11 +195,30 @@ export default function TripPlannerPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSavePlace = useCallback(async (data) => {
|
const handleSavePlace = useCallback(async (data) => {
|
||||||
|
const pendingFiles = data._pendingFiles
|
||||||
|
delete data._pendingFiles
|
||||||
if (editingPlace) {
|
if (editingPlace) {
|
||||||
await tripStore.updatePlace(tripId, editingPlace.id, data)
|
await tripStore.updatePlace(tripId, editingPlace.id, data)
|
||||||
|
// Upload pending files with place_id
|
||||||
|
if (pendingFiles?.length > 0) {
|
||||||
|
for (const file of pendingFiles) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
fd.append('place_id', editingPlace.id)
|
||||||
|
try { await tripStore.addFile(tripId, fd) } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
toast.success(t('trip.toast.placeUpdated'))
|
toast.success(t('trip.toast.placeUpdated'))
|
||||||
} else {
|
} else {
|
||||||
await tripStore.addPlace(tripId, data)
|
const place = await tripStore.addPlace(tripId, data)
|
||||||
|
if (pendingFiles?.length > 0 && place?.id) {
|
||||||
|
for (const file of pendingFiles) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
fd.append('place_id', place.id)
|
||||||
|
try { await tripStore.addFile(tripId, fd) } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
toast.success(t('trip.toast.placeAdded'))
|
toast.success(t('trip.toast.placeAdded'))
|
||||||
}
|
}
|
||||||
}, [editingPlace, tripId, tripStore, toast])
|
}, [editingPlace, tripId, tripStore, toast])
|
||||||
@@ -189,16 +238,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((dayId, orderedIds) => {
|
||||||
try { await tripStore.reorderAssignments(tripId, dayId, orderedIds) }
|
try {
|
||||||
|
tripStore.reorderAssignments(tripId, dayId, orderedIds).catch(() => {})
|
||||||
|
// Update route immediately from orderedIds
|
||||||
|
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])
|
||||||
|
|
||||||
@@ -236,10 +298,21 @@ export default function TripPlannerPage() {
|
|||||||
const da = assignments[String(selectedDayId)] || []
|
const da = assignments[String(selectedDayId)] || []
|
||||||
const sorted = [...da].sort((a, b) => a.order_index - b.order_index)
|
const sorted = [...da].sort((a, b) => a.order_index - b.order_index)
|
||||||
const map = {}
|
const map = {}
|
||||||
sorted.forEach((a, i) => { if (a.place?.id) map[a.place.id] = i + 1 })
|
sorted.forEach((a, i) => {
|
||||||
|
if (!a.place?.id) return
|
||||||
|
if (!map[a.place.id]) map[a.place.id] = []
|
||||||
|
map[a.place.id].push(i + 1)
|
||||||
|
})
|
||||||
return map
|
return map
|
||||||
}, [selectedDayId, assignments])
|
}, [selectedDayId, assignments])
|
||||||
|
|
||||||
|
// Places assigned to selected day (with coords) — used for map fitting
|
||||||
|
const dayPlaces = useMemo(() => {
|
||||||
|
if (!selectedDayId) return []
|
||||||
|
const da = assignments[String(selectedDayId)] || []
|
||||||
|
return da.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||||
|
}, [selectedDayId, assignments])
|
||||||
|
|
||||||
const mapTileUrl = settings.map_tile_url || 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
const mapTileUrl = settings.map_tile_url || 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||||
const defaultCenter = [settings.default_lat || 48.8566, settings.default_lng || 2.3522]
|
const defaultCenter = [settings.default_lat || 48.8566, settings.default_lng || 2.3522]
|
||||||
const defaultZoom = settings.default_zoom || 10
|
const defaultZoom = settings.default_zoom || 10
|
||||||
@@ -259,11 +332,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,13 +371,14 @@ 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 }}>
|
||||||
<MapView
|
<MapView
|
||||||
places={mapPlaces()}
|
places={mapPlaces()}
|
||||||
|
dayPlaces={dayPlaces}
|
||||||
route={route}
|
route={route}
|
||||||
selectedPlaceId={selectedPlaceId}
|
selectedPlaceId={selectedPlaceId}
|
||||||
onMarkerClick={handleMarkerClick}
|
onMarkerClick={handleMarkerClick}
|
||||||
@@ -314,6 +388,9 @@ export default function TripPlannerPage() {
|
|||||||
tileUrl={mapTileUrl}
|
tileUrl={mapTileUrl}
|
||||||
fitKey={fitKey}
|
fitKey={fitKey}
|
||||||
dayOrderMap={dayOrderMap}
|
dayOrderMap={dayOrderMap}
|
||||||
|
leftWidth={leftCollapsed ? 0 : leftWidth}
|
||||||
|
rightWidth={rightCollapsed ? 0 : rightWidth}
|
||||||
|
hasInspector={!!selectedPlace}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{routeInfo && (
|
{routeInfo && (
|
||||||
@@ -333,7 +410,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: -1,
|
||||||
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',
|
||||||
@@ -365,6 +442,7 @@ export default function TripPlannerPage() {
|
|||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
selectedDayId={selectedDayId}
|
selectedDayId={selectedDayId}
|
||||||
selectedPlaceId={selectedPlaceId}
|
selectedPlaceId={selectedPlaceId}
|
||||||
|
selectedAssignmentId={selectedAssignmentId}
|
||||||
onSelectDay={handleSelectDay}
|
onSelectDay={handleSelectDay}
|
||||||
onPlaceClick={handlePlaceClick}
|
onPlaceClick={handlePlaceClick}
|
||||||
onReorder={handleReorder}
|
onReorder={handleReorder}
|
||||||
@@ -373,6 +451,8 @@ export default function TripPlannerPage() {
|
|||||||
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } else { setRoute(null); setRouteInfo(null) } }}
|
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } else { setRoute(null); setRouteInfo(null) } }}
|
||||||
reservations={reservations}
|
reservations={reservations}
|
||||||
onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }}
|
onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }}
|
||||||
|
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null) }}
|
||||||
|
accommodations={tripAccommodations}
|
||||||
/>
|
/>
|
||||||
{!leftCollapsed && (
|
{!leftCollapsed && (
|
||||||
<div
|
<div
|
||||||
@@ -388,7 +468,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: -1,
|
||||||
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 +516,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')}
|
||||||
@@ -449,13 +529,36 @@ export default function TripPlannerPage() {
|
|||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showDayDetail && !selectedPlace && (() => {
|
||||||
|
const currentDay = days.find(d => d.id === showDayDetail.id) || showDayDetail
|
||||||
|
const dayAssignments = assignments[String(currentDay.id)] || []
|
||||||
|
const geoPlace = dayAssignments.find(a => a.place?.lat && a.place?.lng)?.place || places.find(p => p.lat && p.lng)
|
||||||
|
return (
|
||||||
|
<DayDetailPanel
|
||||||
|
day={currentDay}
|
||||||
|
days={days}
|
||||||
|
places={places}
|
||||||
|
categories={categories}
|
||||||
|
tripId={tripId}
|
||||||
|
assignments={assignments}
|
||||||
|
reservations={reservations}
|
||||||
|
lat={geoPlace?.lat}
|
||||||
|
lng={geoPlace?.lng}
|
||||||
|
onClose={() => setShowDayDetail(null)}
|
||||||
|
onAccommodationChange={loadAccommodations}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{selectedPlace && (
|
{selectedPlace && (
|
||||||
<PlaceInspector
|
<PlaceInspector
|
||||||
place={selectedPlace}
|
place={selectedPlace}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
days={days}
|
days={days}
|
||||||
selectedDayId={selectedDayId}
|
selectedDayId={selectedDayId}
|
||||||
|
selectedAssignmentId={selectedAssignmentId}
|
||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
|
reservations={reservations}
|
||||||
onClose={() => setSelectedPlaceId(null)}
|
onClose={() => setSelectedPlaceId(null)}
|
||||||
onEdit={() => { setEditingPlace(selectedPlace); setShowPlaceForm(true) }}
|
onEdit={() => { setEditingPlace(selectedPlace); setShowPlaceForm(true) }}
|
||||||
onDelete={() => handleDeletePlace(selectedPlace.id)}
|
onDelete={() => handleDeletePlace(selectedPlace.id)}
|
||||||
@@ -468,7 +571,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 +580,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>
|
||||||
@@ -534,7 +637,7 @@ export default function TripPlannerPage() {
|
|||||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null) }} onSave={handleSavePlace} place={editingPlace} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
|
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null) }} onSave={handleSavePlace} place={editingPlace} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
|
||||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
||||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} />
|
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} />
|
||||||
</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">
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const useAuthStore = create((set, get) => ({
|
|||||||
connect(data.token)
|
connect(data.token)
|
||||||
return data
|
return data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err.response?.data?.error || 'Anmeldung fehlgeschlagen'
|
const error = err.response?.data?.error || 'Login failed'
|
||||||
set({ isLoading: false, error })
|
set({ isLoading: false, error })
|
||||||
throw new Error(error)
|
throw new Error(error)
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ export const useAuthStore = create((set, get) => ({
|
|||||||
connect(data.token)
|
connect(data.token)
|
||||||
return data
|
return data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err.response?.data?.error || 'Registrierung fehlgeschlagen'
|
const error = err.response?.data?.error || 'Registration failed'
|
||||||
set({ isLoading: false, error })
|
set({ isLoading: false, error })
|
||||||
throw new Error(error)
|
throw new Error(error)
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ export const useAuthStore = create((set, get) => ({
|
|||||||
user: { ...state.user, maps_api_key: key || null }
|
user: { ...state.user, maps_api_key: key || null }
|
||||||
}))
|
}))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Speichern des API-Schlüssels')
|
throw new Error(err.response?.data?.error || 'Error saving API key')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ export const useAuthStore = create((set, get) => ({
|
|||||||
const data = await authApi.updateApiKeys(keys)
|
const data = await authApi.updateApiKeys(keys)
|
||||||
set({ user: data.user })
|
set({ user: data.user })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Speichern der API-Schlüssel')
|
throw new Error(err.response?.data?.error || 'Error saving API keys')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ export const useAuthStore = create((set, get) => ({
|
|||||||
const data = await authApi.updateSettings(profileData)
|
const data = await authApi.updateSettings(profileData)
|
||||||
set({ user: data.user })
|
set({ user: data.user })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Profils')
|
throw new Error(err.response?.data?.error || 'Error updating profile')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ export const useAuthStore = create((set, get) => ({
|
|||||||
connect(data.token)
|
connect(data.token)
|
||||||
return data
|
return data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err.response?.data?.error || 'Demo-Login fehlgeschlagen'
|
const error = err.response?.data?.error || 'Demo login failed'
|
||||||
set({ isLoading: false, error })
|
set({ isLoading: false, error })
|
||||||
throw new Error(error)
|
throw new Error(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const useSettingsStore = create((set, get) => ({
|
|||||||
await settingsApi.set(key, value)
|
await settingsApi.set(key, value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save setting:', err)
|
console.error('Failed to save setting:', err)
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Speichern der Einstellung')
|
throw new Error(err.response?.data?.error || 'Error saving setting')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ export const useSettingsStore = create((set, get) => ({
|
|||||||
await settingsApi.setBulk(settingsObj)
|
await settingsApi.setBulk(settingsObj)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save settings:', err)
|
console.error('Failed to save settings:', err)
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Speichern der Einstellungen')
|
throw new Error(err.response?.data?.error || 'Error saving settings')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -76,6 +76,17 @@ export const useTripStore = create((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case 'assignment:updated': {
|
||||||
|
const dayKey = String(payload.assignment.day_id)
|
||||||
|
return {
|
||||||
|
assignments: {
|
||||||
|
...state.assignments,
|
||||||
|
[dayKey]: (state.assignments[dayKey] || []).map(a =>
|
||||||
|
a.id === payload.assignment.id ? { ...a, ...payload.assignment } : a
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
case 'assignment:deleted': {
|
case 'assignment:deleted': {
|
||||||
const dayKey = String(payload.dayId)
|
const dayKey = String(payload.dayId)
|
||||||
return {
|
return {
|
||||||
@@ -279,7 +290,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
set(state => ({ places: [data.place, ...state.places] }))
|
set(state => ({ places: [data.place, ...state.places] }))
|
||||||
return data.place
|
return data.place
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen des Ortes')
|
throw new Error(err.response?.data?.error || 'Error adding place')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -297,7 +308,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
}))
|
}))
|
||||||
return data.place
|
return data.place
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Ortes')
|
throw new Error(err.response?.data?.error || 'Error updating place')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -314,7 +325,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Löschen des Ortes')
|
throw new Error(err.response?.data?.error || 'Error deleting place')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -323,9 +334,6 @@ export const useTripStore = create((set, get) => ({
|
|||||||
const place = state.places.find(p => p.id === parseInt(placeId))
|
const place = state.places.find(p => p.id === parseInt(placeId))
|
||||||
if (!place) return
|
if (!place) return
|
||||||
|
|
||||||
const existing = (state.assignments[String(dayId)] || []).find(a => a.place?.id === parseInt(placeId))
|
|
||||||
if (existing) return
|
|
||||||
|
|
||||||
const tempId = Date.now() * -1
|
const tempId = Date.now() * -1
|
||||||
const current = [...(state.assignments[String(dayId)] || [])]
|
const current = [...(state.assignments[String(dayId)] || [])]
|
||||||
const insertIdx = position != null ? position : current.length
|
const insertIdx = position != null ? position : current.length
|
||||||
@@ -347,9 +355,11 @@ export const useTripStore = create((set, get) => ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId })
|
const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId })
|
||||||
const newAssignment = position != null
|
const newAssignment = {
|
||||||
? { ...data.assignment, order_index: insertIdx }
|
...data.assignment,
|
||||||
: data.assignment
|
place: data.assignment.place || place,
|
||||||
|
order_index: position != null ? insertIdx : data.assignment.order_index,
|
||||||
|
}
|
||||||
set(state => ({
|
set(state => ({
|
||||||
assignments: {
|
assignments: {
|
||||||
...state.assignments,
|
...state.assignments,
|
||||||
@@ -390,7 +400,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== tempId),
|
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== tempId),
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Zuweisen des Ortes')
|
throw new Error(err.response?.data?.error || 'Error assigning place')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -408,7 +418,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
await assignmentsApi.delete(tripId, dayId, assignmentId)
|
await assignmentsApi.delete(tripId, dayId, assignmentId)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ assignments: prevAssignments })
|
set({ assignments: prevAssignments })
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Entfernen der Zuweisung')
|
throw new Error(err.response?.data?.error || 'Error removing assignment')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -431,7 +441,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
await assignmentsApi.reorder(tripId, dayId, orderedIds)
|
await assignmentsApi.reorder(tripId, dayId, orderedIds)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ assignments: prevAssignments })
|
set({ assignments: prevAssignments })
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Neuanordnen')
|
throw new Error(err.response?.data?.error || 'Error reordering')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -464,7 +474,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ assignments: prevAssignments })
|
set({ assignments: prevAssignments })
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Verschieben der Zuweisung')
|
throw new Error(err.response?.data?.error || 'Error moving assignment')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -498,7 +508,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
[String(fromDayId)]: [...(s.dayNotes[String(fromDayId)] || []), note],
|
[String(fromDayId)]: [...(s.dayNotes[String(fromDayId)] || []), note],
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Verschieben der Notiz')
|
throw new Error(err.response?.data?.error || 'Error moving note')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -512,7 +522,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
set(state => ({ packingItems: [...state.packingItems, result.item] }))
|
set(state => ({ packingItems: [...state.packingItems, result.item] }))
|
||||||
return result.item
|
return result.item
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen des Artikels')
|
throw new Error(err.response?.data?.error || 'Error adding item')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -524,7 +534,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
}))
|
}))
|
||||||
return result.item
|
return result.item
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Artikels')
|
throw new Error(err.response?.data?.error || 'Error updating item')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -535,7 +545,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
await packingApi.delete(tripId, id)
|
await packingApi.delete(tripId, id)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ packingItems: prev })
|
set({ packingItems: prev })
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Löschen des Artikels')
|
throw new Error(err.response?.data?.error || 'Error deleting item')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -563,7 +573,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
days: state.days.map(d => d.id === parseInt(dayId) ? { ...d, notes } : d)
|
days: state.days.map(d => d.id === parseInt(dayId) ? { ...d, notes } : d)
|
||||||
}))
|
}))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Notizen')
|
throw new Error(err.response?.data?.error || 'Error updating notes')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -574,7 +584,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
days: state.days.map(d => d.id === parseInt(dayId) ? { ...d, title } : d)
|
days: state.days.map(d => d.id === parseInt(dayId) ? { ...d, title } : d)
|
||||||
}))
|
}))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Tagesnamens')
|
throw new Error(err.response?.data?.error || 'Error updating day name')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -584,7 +594,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
set(state => ({ tags: [...state.tags, result.tag] }))
|
set(state => ({ tags: [...state.tags, result.tag] }))
|
||||||
return result.tag
|
return result.tag
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Erstellen des Tags')
|
throw new Error(err.response?.data?.error || 'Error creating tag')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -594,7 +604,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
set(state => ({ categories: [...state.categories, result.category] }))
|
set(state => ({ categories: [...state.categories, result.category] }))
|
||||||
return result.category
|
return result.category
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Erstellen der Kategorie')
|
throw new Error(err.response?.data?.error || 'Error creating category')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -612,7 +622,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
|
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
|
||||||
return result.trip
|
return result.trip
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Reise')
|
throw new Error(err.response?.data?.error || 'Error updating trip')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -631,7 +641,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
|
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
|
||||||
return result.item
|
return result.item
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen des Budget-Eintrags')
|
throw new Error(err.response?.data?.error || 'Error adding budget item')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -643,7 +653,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
}))
|
}))
|
||||||
return result.item
|
return result.item
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Budget-Eintrags')
|
throw new Error(err.response?.data?.error || 'Error updating budget item')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -654,7 +664,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
await budgetApi.delete(tripId, id)
|
await budgetApi.delete(tripId, id)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ budgetItems: prev })
|
set({ budgetItems: prev })
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Löschen des Budget-Eintrags')
|
throw new Error(err.response?.data?.error || 'Error deleting budget item')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -673,7 +683,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
set(state => ({ files: [data.file, ...state.files] }))
|
set(state => ({ files: [data.file, ...state.files] }))
|
||||||
return data.file
|
return data.file
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Hochladen der Datei')
|
throw new Error(err.response?.data?.error || 'Error uploading file')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -682,7 +692,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
await filesApi.delete(tripId, id)
|
await filesApi.delete(tripId, id)
|
||||||
set(state => ({ files: state.files.filter(f => f.id !== id) }))
|
set(state => ({ files: state.files.filter(f => f.id !== id) }))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Löschen der Datei')
|
throw new Error(err.response?.data?.error || 'Error deleting file')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -701,7 +711,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
set(state => ({ reservations: [result.reservation, ...state.reservations] }))
|
set(state => ({ reservations: [result.reservation, ...state.reservations] }))
|
||||||
return result.reservation
|
return result.reservation
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Erstellen der Reservierung')
|
throw new Error(err.response?.data?.error || 'Error creating reservation')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -713,7 +723,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
}))
|
}))
|
||||||
return result.reservation
|
return result.reservation
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Reservierung')
|
throw new Error(err.response?.data?.error || 'Error updating reservation')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -737,22 +747,36 @@ export const useTripStore = create((set, get) => ({
|
|||||||
await reservationsApi.delete(tripId, id)
|
await reservationsApi.delete(tripId, id)
|
||||||
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
|
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Löschen der Reservierung')
|
throw new Error(err.response?.data?.error || 'Error deleting reservation')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addDayNote: async (tripId, dayId, data) => {
|
addDayNote: async (tripId, dayId, data) => {
|
||||||
|
const tempId = Date.now() * -1
|
||||||
|
const tempNote = { id: tempId, day_id: dayId, ...data, created_at: new Date().toISOString() }
|
||||||
|
set(state => ({
|
||||||
|
dayNotes: {
|
||||||
|
...state.dayNotes,
|
||||||
|
[String(dayId)]: [...(state.dayNotes[String(dayId)] || []), tempNote],
|
||||||
|
}
|
||||||
|
}))
|
||||||
try {
|
try {
|
||||||
const result = await dayNotesApi.create(tripId, dayId, data)
|
const result = await dayNotesApi.create(tripId, dayId, data)
|
||||||
set(state => ({
|
set(state => ({
|
||||||
dayNotes: {
|
dayNotes: {
|
||||||
...state.dayNotes,
|
...state.dayNotes,
|
||||||
[String(dayId)]: [...(state.dayNotes[String(dayId)] || []), result.note],
|
[String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === tempId ? result.note : n),
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
return result.note
|
return result.note
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen der Notiz')
|
set(state => ({
|
||||||
|
dayNotes: {
|
||||||
|
...state.dayNotes,
|
||||||
|
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== tempId),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
throw new Error(err.response?.data?.error || 'Error adding note')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -767,7 +791,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
}))
|
}))
|
||||||
return result.note
|
return result.note
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Notiz')
|
throw new Error(err.response?.data?.error || 'Error updating note')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -783,7 +807,7 @@ export const useTripStore = create((set, get) => ({
|
|||||||
await dayNotesApi.delete(tripId, dayId, id)
|
await dayNotesApi.delete(tripId, dayId, id)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ dayNotes: prev })
|
set({ dayNotes: prev })
|
||||||
throw new Error(err.response?.data?.error || 'Fehler beim Löschen der Notiz')
|
throw new Error(err.response?.data?.error || 'Error deleting note')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: mauriceboe/nomad:latest
|
image: mauriceboe/nomad:2.5.5
|
||||||
container_name: nomad
|
container_name: nomad
|
||||||
ports:
|
ports:
|
||||||
- "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:
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 504 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 2.6 MiB |
@@ -1,18 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "nomad-server",
|
"name": "nomad-server",
|
||||||
"version": "2.4.1",
|
"version": "2.5.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nomad-server",
|
"name": "nomad-server",
|
||||||
"version": "2.4.1",
|
"version": "2.5.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
"better-sqlite3": "^12.8.0",
|
||||||
"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",
|
||||||
@@ -216,12 +218,46 @@
|
|||||||
"bare-path": "^3.0.0"
|
"bare-path": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/bcryptjs": {
|
"node_modules/bcryptjs": {
|
||||||
"version": "2.4.3",
|
"version": "2.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||||
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/better-sqlite3": {
|
||||||
|
"version": "12.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
|
||||||
|
"integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bindings": "^1.5.0",
|
||||||
|
"prebuild-install": "^7.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -235,6 +271,26 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bindings": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"file-uri-to-path": "1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bl": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer": "^5.5.0",
|
||||||
|
"inherits": "^2.0.4",
|
||||||
|
"readable-stream": "^3.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bluebird": {
|
"node_modules/bluebird": {
|
||||||
"version": "3.7.2",
|
"version": "3.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||||
@@ -291,6 +347,30 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer": {
|
||||||
|
"version": "5.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||||
|
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.1",
|
||||||
|
"ieee754": "^1.1.13"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/buffer-crc32": {
|
"node_modules/buffer-crc32": {
|
||||||
"version": "0.2.13",
|
"version": "0.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||||
@@ -386,6 +466,12 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chownr": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/compress-commons": {
|
"node_modules/compress-commons": {
|
||||||
"version": "5.0.3",
|
"version": "5.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.3.tgz",
|
||||||
@@ -539,6 +625,30 @@
|
|||||||
"ms": "2.0.0"
|
"ms": "2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decompress-response": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mimic-response": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/deep-extend": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -558,6 +668,15 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
@@ -647,6 +766,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/end-of-stream": {
|
||||||
|
"version": "1.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
|
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"once": "^1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-define-property": {
|
"node_modules/es-define-property": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
@@ -701,6 +829,15 @@
|
|||||||
"bare-events": "^2.7.0"
|
"bare-events": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expand-template": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||||
|
"license": "(MIT OR WTFPL)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.22.1",
|
"version": "4.22.1",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||||
@@ -753,6 +890,12 @@
|
|||||||
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
|
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/file-uri-to-path": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -802,6 +945,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fs-constants": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fs-extra": {
|
"node_modules/fs-extra": {
|
||||||
"version": "11.3.4",
|
"version": "11.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz",
|
||||||
@@ -883,6 +1032,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/github-from-package": {
|
||||||
|
"version": "0.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||||
|
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/glob": {
|
"node_modules/glob": {
|
||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
|
||||||
@@ -995,6 +1150,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",
|
||||||
@@ -1027,6 +1191,26 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ieee754": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/ignore-by-default": {
|
"node_modules/ignore-by-default": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
|
||||||
@@ -1051,6 +1235,12 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ini": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -1332,6 +1522,18 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mimic-response": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.4",
|
"version": "10.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||||
@@ -1369,6 +1571,12 @@
|
|||||||
"mkdirp": "bin/cmd.js"
|
"mkdirp": "bin/cmd.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mkdirp-classic": {
|
||||||
|
"version": "0.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||||
|
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
@@ -1394,6 +1602,12 @@
|
|||||||
"node": ">= 6.0.0"
|
"node": ">= 6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/napi-build-utils": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
@@ -1403,6 +1617,18 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-abi": {
|
||||||
|
"version": "3.89.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
|
||||||
|
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "^7.3.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-cron": {
|
"node_modules/node-cron": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||||
@@ -1571,6 +1797,33 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prebuild-install": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||||
|
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.0",
|
||||||
|
"expand-template": "^2.0.3",
|
||||||
|
"github-from-package": "0.0.0",
|
||||||
|
"minimist": "^1.2.3",
|
||||||
|
"mkdirp-classic": "^0.5.3",
|
||||||
|
"napi-build-utils": "^2.0.0",
|
||||||
|
"node-abi": "^3.3.0",
|
||||||
|
"pump": "^3.0.0",
|
||||||
|
"rc": "^1.2.7",
|
||||||
|
"simple-get": "^4.0.0",
|
||||||
|
"tar-fs": "^2.0.0",
|
||||||
|
"tunnel-agent": "^0.6.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"prebuild-install": "bin.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/process-nextick-args": {
|
"node_modules/process-nextick-args": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
@@ -1597,6 +1850,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pump": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"end-of-stream": "^1.1.0",
|
||||||
|
"once": "^1.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.2",
|
"version": "6.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||||
@@ -1636,6 +1899,21 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rc": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||||
|
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||||
|
"dependencies": {
|
||||||
|
"deep-extend": "^0.6.0",
|
||||||
|
"ini": "~1.3.0",
|
||||||
|
"minimist": "^1.2.0",
|
||||||
|
"strip-json-comments": "~2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"rc": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readable-stream": {
|
"node_modules/readable-stream": {
|
||||||
"version": "3.6.2",
|
"version": "3.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
@@ -1860,6 +2138,51 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/simple-concat": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/simple-get": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"decompress-response": "^6.0.0",
|
||||||
|
"once": "^1.3.1",
|
||||||
|
"simple-concat": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/simple-update-notifier": {
|
"node_modules/simple-update-notifier": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||||
@@ -1910,6 +2233,15 @@
|
|||||||
"safe-buffer": "~5.2.0"
|
"safe-buffer": "~5.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-json-comments": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||||
@@ -1923,6 +2255,34 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tar-fs": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chownr": "^1.1.1",
|
||||||
|
"mkdirp-classic": "^0.5.2",
|
||||||
|
"pump": "^3.0.0",
|
||||||
|
"tar-stream": "^2.1.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tar-fs/node_modules/tar-stream": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bl": "^4.0.3",
|
||||||
|
"end-of-stream": "^1.4.1",
|
||||||
|
"fs-constants": "^1.0.0",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^3.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tar-stream": {
|
"node_modules/tar-stream": {
|
||||||
"version": "3.1.8",
|
"version": "3.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz",
|
||||||
@@ -1991,6 +2351,18 @@
|
|||||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tunnel-agent": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "nomad-server",
|
"name": "nomad-server",
|
||||||
"version": "2.5.0",
|
"version": "2.5.7",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --experimental-sqlite src/index.js",
|
"start": "node src/index.js",
|
||||||
"dev": "nodemon src/index.js"
|
"dev": "nodemon src/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
"better-sqlite3": "^12.8.0",
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { DatabaseSync } = require('node:sqlite');
|
const Database = require('better-sqlite3');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
const dbPath = path.join(__dirname, 'data/travel.db');
|
const dbPath = path.join(__dirname, 'data/travel.db');
|
||||||
const db = new DatabaseSync(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
const hash = bcrypt.hashSync('admin123', 10);
|
const hash = bcrypt.hashSync('admin123', 10);
|
||||||
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get('admin@admin.com');
|
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get('admin@admin.com');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { DatabaseSync } = require('node:sqlite');
|
const Database = require('better-sqlite3');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
@@ -19,7 +19,7 @@ function initDb() {
|
|||||||
_db = null;
|
_db = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_db = new DatabaseSync(dbPath);
|
_db = new Database(dbPath);
|
||||||
_db.exec('PRAGMA journal_mode = WAL');
|
_db.exec('PRAGMA journal_mode = WAL');
|
||||||
_db.exec('PRAGMA busy_timeout = 5000');
|
_db.exec('PRAGMA busy_timeout = 5000');
|
||||||
_db.exec('PRAGMA foreign_keys = ON');
|
_db.exec('PRAGMA foreign_keys = ON');
|
||||||
@@ -35,6 +35,10 @@ function initDb() {
|
|||||||
maps_api_key TEXT,
|
maps_api_key TEXT,
|
||||||
unsplash_api_key TEXT,
|
unsplash_api_key TEXT,
|
||||||
openweather_api_key TEXT,
|
openweather_api_key TEXT,
|
||||||
|
avatar TEXT,
|
||||||
|
oidc_sub TEXT,
|
||||||
|
oidc_issuer TEXT,
|
||||||
|
last_login DATETIME,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -55,6 +59,8 @@ function initDb() {
|
|||||||
start_date TEXT,
|
start_date TEXT,
|
||||||
end_date TEXT,
|
end_date TEXT,
|
||||||
currency TEXT DEFAULT 'EUR',
|
currency TEXT DEFAULT 'EUR',
|
||||||
|
cover_image TEXT,
|
||||||
|
is_archived INTEGER DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -65,6 +71,7 @@ function initDb() {
|
|||||||
day_number INTEGER NOT NULL,
|
day_number INTEGER NOT NULL,
|
||||||
date TEXT,
|
date TEXT,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
|
title TEXT,
|
||||||
UNIQUE(trip_id, day_number)
|
UNIQUE(trip_id, day_number)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -73,6 +80,7 @@ function initDb() {
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
color TEXT DEFAULT '#6366f1',
|
color TEXT DEFAULT '#6366f1',
|
||||||
icon TEXT DEFAULT '📍',
|
icon TEXT DEFAULT '📍',
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -99,6 +107,7 @@ function initDb() {
|
|||||||
reservation_notes TEXT,
|
reservation_notes TEXT,
|
||||||
reservation_datetime TEXT,
|
reservation_datetime TEXT,
|
||||||
place_time TEXT,
|
place_time TEXT,
|
||||||
|
end_time TEXT,
|
||||||
duration_minutes INTEGER DEFAULT 60,
|
duration_minutes INTEGER DEFAULT 60,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
image_url TEXT,
|
image_url TEXT,
|
||||||
@@ -122,6 +131,9 @@ function initDb() {
|
|||||||
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
|
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
|
||||||
order_index INTEGER DEFAULT 0,
|
order_index INTEGER DEFAULT 0,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
|
reservation_status TEXT DEFAULT 'none',
|
||||||
|
reservation_notes TEXT,
|
||||||
|
reservation_datetime TEXT,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -153,6 +165,7 @@ function initDb() {
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
|
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
|
||||||
|
reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL,
|
||||||
filename TEXT NOT NULL,
|
filename TEXT NOT NULL,
|
||||||
original_name TEXT NOT NULL,
|
original_name TEXT NOT NULL,
|
||||||
file_size INTEGER,
|
file_size INTEGER,
|
||||||
@@ -166,11 +179,14 @@ function initDb() {
|
|||||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
|
day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
|
||||||
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
|
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
|
||||||
|
assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
reservation_time TEXT,
|
reservation_time TEXT,
|
||||||
location TEXT,
|
location TEXT,
|
||||||
confirmation_number TEXT,
|
confirmation_number TEXT,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
type TEXT DEFAULT 'other',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -202,7 +218,7 @@ function initDb() {
|
|||||||
CREATE TABLE IF NOT EXISTS budget_items (
|
CREATE TABLE IF NOT EXISTS budget_items (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
category TEXT NOT NULL DEFAULT 'Other',
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
total_price REAL NOT NULL DEFAULT 0,
|
total_price REAL NOT NULL DEFAULT 0,
|
||||||
persons INTEGER DEFAULT NULL,
|
persons INTEGER DEFAULT NULL,
|
||||||
@@ -287,6 +303,19 @@ function initDb() {
|
|||||||
note TEXT DEFAULT '',
|
note TEXT DEFAULT '',
|
||||||
UNIQUE(plan_id, date)
|
UNIQUE(plan_id, date)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS day_accommodations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
|
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
|
||||||
|
start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
|
||||||
|
end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
|
||||||
|
check_in TEXT,
|
||||||
|
check_out TEXT,
|
||||||
|
confirmation TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Create indexes for performance
|
// Create indexes for performance
|
||||||
@@ -307,55 +336,118 @@ function initDb() {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_day_notes_day_id ON day_notes(day_id);
|
CREATE INDEX IF NOT EXISTS idx_day_notes_day_id ON day_notes(day_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_photos_trip_id ON photos(trip_id);
|
CREATE INDEX IF NOT EXISTS idx_photos_trip_id ON photos(trip_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Migrations
|
// Versioned migrations — each runs exactly once
|
||||||
|
_db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)');
|
||||||
|
const versionRow = _db.prepare('SELECT version FROM schema_version').get();
|
||||||
|
let currentVersion = versionRow?.version ?? 0;
|
||||||
|
|
||||||
|
// Existing or fresh DBs may already have columns the migrations add.
|
||||||
|
// Detect by checking for a column from migration 1 (unsplash_api_key).
|
||||||
|
if (currentVersion === 0) {
|
||||||
|
const hasUnsplash = _db.prepare(
|
||||||
|
"SELECT 1 FROM pragma_table_info('users') WHERE name = 'unsplash_api_key'"
|
||||||
|
).get();
|
||||||
|
if (hasUnsplash) {
|
||||||
|
// All columns from CREATE TABLE already exist — skip ALTER migrations
|
||||||
|
currentVersion = 19;
|
||||||
|
_db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(currentVersion);
|
||||||
|
console.log('[DB] Schema already up-to-date, setting version to', currentVersion);
|
||||||
|
} else {
|
||||||
|
_db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const migrations = [
|
const migrations = [
|
||||||
`ALTER TABLE users ADD COLUMN unsplash_api_key TEXT`,
|
// 1–18: ALTER TABLE additions
|
||||||
`ALTER TABLE users ADD COLUMN openweather_api_key TEXT`,
|
() => _db.exec('ALTER TABLE users ADD COLUMN unsplash_api_key TEXT'),
|
||||||
`ALTER TABLE places ADD COLUMN duration_minutes INTEGER DEFAULT 60`,
|
() => _db.exec('ALTER TABLE users ADD COLUMN openweather_api_key TEXT'),
|
||||||
`ALTER TABLE places ADD COLUMN notes TEXT`,
|
() => _db.exec('ALTER TABLE places ADD COLUMN duration_minutes INTEGER DEFAULT 60'),
|
||||||
`ALTER TABLE places ADD COLUMN image_url TEXT`,
|
() => _db.exec('ALTER TABLE places ADD COLUMN notes TEXT'),
|
||||||
`ALTER TABLE places ADD COLUMN transport_mode TEXT DEFAULT 'walking'`,
|
() => _db.exec('ALTER TABLE places ADD COLUMN image_url TEXT'),
|
||||||
`ALTER TABLE days ADD COLUMN title TEXT`,
|
() => _db.exec("ALTER TABLE places ADD COLUMN transport_mode TEXT DEFAULT 'walking'"),
|
||||||
`ALTER TABLE reservations ADD COLUMN status TEXT DEFAULT 'pending'`,
|
() => _db.exec('ALTER TABLE days ADD COLUMN title TEXT'),
|
||||||
`ALTER TABLE trip_files ADD COLUMN reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL`,
|
() => _db.exec("ALTER TABLE reservations ADD COLUMN status TEXT DEFAULT 'pending'"),
|
||||||
`ALTER TABLE reservations ADD COLUMN type TEXT DEFAULT 'other'`,
|
() => _db.exec('ALTER TABLE trip_files ADD COLUMN reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL'),
|
||||||
`ALTER TABLE trips ADD COLUMN cover_image TEXT`,
|
() => _db.exec("ALTER TABLE reservations ADD COLUMN type TEXT DEFAULT 'other'"),
|
||||||
`ALTER TABLE day_notes ADD COLUMN icon TEXT DEFAULT '📝'`,
|
() => _db.exec('ALTER TABLE trips ADD COLUMN cover_image TEXT'),
|
||||||
`ALTER TABLE trips ADD COLUMN is_archived INTEGER DEFAULT 0`,
|
() => _db.exec("ALTER TABLE day_notes ADD COLUMN icon TEXT DEFAULT '📝'"),
|
||||||
`ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL`,
|
() => _db.exec('ALTER TABLE trips ADD COLUMN is_archived INTEGER DEFAULT 0'),
|
||||||
`ALTER TABLE users ADD COLUMN avatar TEXT`,
|
() => _db.exec('ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'),
|
||||||
`ALTER TABLE users ADD COLUMN oidc_sub TEXT`,
|
() => _db.exec('ALTER TABLE users ADD COLUMN avatar TEXT'),
|
||||||
`ALTER TABLE users ADD COLUMN oidc_issuer TEXT`,
|
() => _db.exec('ALTER TABLE users ADD COLUMN oidc_sub TEXT'),
|
||||||
`ALTER TABLE users ADD COLUMN last_login DATETIME`,
|
() => _db.exec('ALTER TABLE users ADD COLUMN oidc_issuer TEXT'),
|
||||||
|
() => _db.exec('ALTER TABLE users ADD COLUMN last_login DATETIME'),
|
||||||
|
// 19: budget_items table rebuild (NOT NULL → nullable persons)
|
||||||
|
() => {
|
||||||
|
const schema = _db.prepare("SELECT sql FROM sqlite_master WHERE name = 'budget_items'").get();
|
||||||
|
if (schema?.sql?.includes('NOT NULL DEFAULT 1')) {
|
||||||
|
_db.exec(`
|
||||||
|
CREATE TABLE budget_items_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
|
category TEXT NOT NULL DEFAULT 'Other',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
total_price REAL NOT NULL DEFAULT 0,
|
||||||
|
persons INTEGER DEFAULT NULL,
|
||||||
|
days INTEGER DEFAULT NULL,
|
||||||
|
note TEXT,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
INSERT INTO budget_items_new SELECT * FROM budget_items;
|
||||||
|
DROP TABLE budget_items;
|
||||||
|
ALTER TABLE budget_items_new RENAME TO budget_items;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 20: accommodation check-in/check-out/confirmation fields
|
||||||
|
() => {
|
||||||
|
try { _db.exec('ALTER TABLE day_accommodations ADD COLUMN check_in TEXT'); } catch {}
|
||||||
|
try { _db.exec('ALTER TABLE day_accommodations ADD COLUMN check_out TEXT'); } catch {}
|
||||||
|
try { _db.exec('ALTER TABLE day_accommodations ADD COLUMN confirmation TEXT'); } catch {}
|
||||||
|
},
|
||||||
|
// 21: places end_time field (place_time becomes start_time conceptually, end_time is new)
|
||||||
|
() => {
|
||||||
|
try { _db.exec('ALTER TABLE places ADD COLUMN end_time TEXT'); } catch {}
|
||||||
|
},
|
||||||
|
// 22: Move reservation fields from places to day_assignments
|
||||||
|
() => {
|
||||||
|
// Add new columns to day_assignments
|
||||||
|
try { _db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_status TEXT DEFAULT \'none\''); } catch {}
|
||||||
|
try { _db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_notes TEXT'); } catch {}
|
||||||
|
try { _db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_datetime TEXT'); } catch {}
|
||||||
|
|
||||||
|
// Migrate existing data: copy reservation info from places to all their assignments
|
||||||
|
try {
|
||||||
|
_db.exec(`
|
||||||
|
UPDATE day_assignments SET
|
||||||
|
reservation_status = (SELECT reservation_status FROM places WHERE places.id = day_assignments.place_id),
|
||||||
|
reservation_notes = (SELECT reservation_notes FROM places WHERE places.id = day_assignments.place_id),
|
||||||
|
reservation_datetime = (SELECT reservation_datetime FROM places WHERE places.id = day_assignments.place_id)
|
||||||
|
WHERE place_id IN (SELECT id FROM places WHERE reservation_status IS NOT NULL AND reservation_status != 'none')
|
||||||
|
`);
|
||||||
|
console.log('[DB] Migrated reservation data from places to day_assignments');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[DB] Migration 22 data copy error:', e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 23: Add assignment_id to reservations table
|
||||||
|
() => {
|
||||||
|
try { _db.exec('ALTER TABLE reservations ADD COLUMN assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL'); } catch {}
|
||||||
|
},
|
||||||
|
// Future migrations go here (append only, never reorder)
|
||||||
];
|
];
|
||||||
|
|
||||||
// Recreate budget_items to allow NULL persons (SQLite can't ALTER NOT NULL)
|
if (currentVersion < migrations.length) {
|
||||||
try {
|
for (let i = currentVersion; i < migrations.length; i++) {
|
||||||
const hasNotNull = _db.prepare("SELECT sql FROM sqlite_master WHERE name = 'budget_items'").get()
|
console.log(`[DB] Running migration ${i + 1}/${migrations.length}`);
|
||||||
if (hasNotNull?.sql?.includes('NOT NULL DEFAULT 1')) {
|
migrations[i]();
|
||||||
_db.exec(`
|
|
||||||
CREATE TABLE budget_items_new (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
|
||||||
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
total_price REAL NOT NULL DEFAULT 0,
|
|
||||||
persons INTEGER DEFAULT NULL,
|
|
||||||
days INTEGER DEFAULT NULL,
|
|
||||||
note TEXT,
|
|
||||||
sort_order INTEGER DEFAULT 0,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
INSERT INTO budget_items_new SELECT * FROM budget_items;
|
|
||||||
DROP TABLE budget_items;
|
|
||||||
ALTER TABLE budget_items_new RENAME TO budget_items;
|
|
||||||
`)
|
|
||||||
}
|
}
|
||||||
} catch (e) { /* table doesn't exist yet or already migrated */ }
|
_db.prepare('UPDATE schema_version SET version = ?').run(migrations.length);
|
||||||
for (const sql of migrations) {
|
console.log(`[DB] Migrations complete — schema version ${migrations.length}`);
|
||||||
try { _db.exec(sql); } catch (e) { /* column already exists */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// First registered user becomes admin — no default admin seed needed
|
// First registered user becomes admin — no default admin seed needed
|
||||||
@@ -367,14 +459,14 @@ function initDb() {
|
|||||||
const defaultCategories = [
|
const defaultCategories = [
|
||||||
{ name: 'Hotel', color: '#3b82f6', icon: '🏨' },
|
{ name: 'Hotel', color: '#3b82f6', icon: '🏨' },
|
||||||
{ name: 'Restaurant', color: '#ef4444', icon: '🍽️' },
|
{ name: 'Restaurant', color: '#ef4444', icon: '🍽️' },
|
||||||
{ name: 'Sehenswürdigkeit', color: '#8b5cf6', icon: '🏛️' },
|
{ name: 'Attraction', color: '#8b5cf6', icon: '🏛️' },
|
||||||
{ name: 'Shopping', color: '#f59e0b', icon: '🛍️' },
|
{ name: 'Shopping', color: '#f59e0b', icon: '🛍️' },
|
||||||
{ name: 'Transport', color: '#6b7280', icon: '🚌' },
|
{ name: 'Transport', color: '#6b7280', icon: '🚌' },
|
||||||
{ name: 'Aktivität', color: '#10b981', icon: '🎯' },
|
{ name: 'Activity', color: '#10b981', icon: '🎯' },
|
||||||
{ name: 'Bar/Café', color: '#f97316', icon: '☕' },
|
{ name: 'Bar/Cafe', color: '#f97316', icon: '☕' },
|
||||||
{ name: 'Strand', color: '#06b6d4', icon: '🏖️' },
|
{ name: 'Beach', color: '#06b6d4', icon: '🏖️' },
|
||||||
{ name: 'Natur', color: '#84cc16', icon: '🌿' },
|
{ name: 'Nature', color: '#84cc16', icon: '🌿' },
|
||||||
{ name: 'Sonstiges', color: '#6366f1', icon: '📍' },
|
{ name: 'Other', color: '#6366f1', icon: '📍' },
|
||||||
];
|
];
|
||||||
const insertCat = _db.prepare('INSERT INTO categories (name, color, icon) VALUES (?, ?, ?)');
|
const insertCat = _db.prepare('INSERT INTO categories (name, color, icon) VALUES (?, ?, ?)');
|
||||||
for (const cat of defaultCategories) insertCat.run(cat.name, cat.color, cat.icon);
|
for (const cat of defaultCategories) insertCat.run(cat.name, cat.color, cat.icon);
|
||||||
@@ -421,6 +513,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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -63,15 +63,18 @@ function ensureDemoMembership(db, adminId, demoId) {
|
|||||||
function seedExampleTrips(db, adminId, demoId) {
|
function seedExampleTrips(db, adminId, demoId) {
|
||||||
const insertTrip = db.prepare('INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)');
|
const insertTrip = db.prepare('INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)');
|
||||||
const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
|
const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
|
||||||
const insertPlace = db.prepare('INSERT INTO places (trip_id, name, lat, lng, address, category_id, place_time, duration_minutes, notes, image_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
const insertPlace = db.prepare('INSERT INTO places (trip_id, name, lat, lng, address, category_id, place_time, duration_minutes, notes, image_url, google_place_id, website, phone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||||
const insertAssignment = db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, ?)');
|
const insertAssignment = db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, ?)');
|
||||||
const insertPacking = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)');
|
const insertPacking = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)');
|
||||||
const insertBudget = db.prepare('INSERT INTO budget_items (trip_id, category, name, total_price, persons, note) VALUES (?, ?, ?, ?, ?, ?)');
|
const insertBudget = db.prepare('INSERT INTO budget_items (trip_id, category, name, total_price, persons, note) VALUES (?, ?, ?, ?, ?, ?)');
|
||||||
const insertReservation = db.prepare('INSERT INTO reservations (trip_id, day_id, title, reservation_time, confirmation_number, status, type, location) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
|
const insertReservation = db.prepare('INSERT INTO reservations (trip_id, day_id, title, reservation_time, confirmation_number, status, type, location) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
|
||||||
const insertMember = db.prepare('INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)');
|
const insertMember = db.prepare('INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)');
|
||||||
|
const insertNote = db.prepare('INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)');
|
||||||
|
|
||||||
// ─── Trip 1: Tokyo & Kyoto ───
|
// Category IDs: 1=Hotel, 2=Restaurant, 3=Attraction, 5=Transport, 7=Bar/Cafe, 8=Beach, 9=Nature, 6=Entertainment
|
||||||
const trip1 = insertTrip.run(adminId, 'Tokyo & Kyoto', 'Zwei Wochen Japan — von den neonbeleuchteten Strassen Tokyos bis zu den stillen Tempeln Kyotos.', '2026-04-15', '2026-04-21', 'EUR');
|
|
||||||
|
// ─── Trip 1: Tokyo & Kyoto ─────────────────────────────────────────────────
|
||||||
|
const trip1 = insertTrip.run(adminId, 'Tokyo & Kyoto', 'Two weeks in Japan — from the neon-lit streets of Tokyo to the serene temples of Kyoto.', '2026-04-15', '2026-04-21', 'JPY');
|
||||||
const t1 = Number(trip1.lastInsertRowid);
|
const t1 = Number(trip1.lastInsertRowid);
|
||||||
|
|
||||||
const t1days = [];
|
const t1days = [];
|
||||||
@@ -80,38 +83,39 @@ function seedExampleTrips(db, adminId, demoId) {
|
|||||||
t1days.push(Number(d.lastInsertRowid));
|
t1days.push(Number(d.lastInsertRowid));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Places — cat IDs: 1=Hotel, 2=Restaurant, 3=Sehenswuerdigkeit, 5=Transport, 7=Bar/Cafe, 9=Natur
|
|
||||||
const t1places = [
|
const t1places = [
|
||||||
[t1, 'Hotel Shinjuku Granbell', 35.6938, 139.7035, 'Shinjuku, Tokyo, Japan', 1, '15:00', 60, 'Check-in ab 15 Uhr. Nahe Shinjuku Station.', null],
|
[t1, 'Hotel Shinjuku Granbell', 35.6938, 139.7035, '2-14-5 Kabukicho, Shinjuku City, Tokyo 160-0021, Japan', 1, '15:00', 60, 'Check-in from 3 PM. Steps from Shinjuku Station.', null, 'ChIJdaGEJBeMGGARYgt8sLBv6lM', 'https://www.grfranbellhotel.jp/shinjuku/', '+81 3-5155-2666'],
|
||||||
[t1, 'Senso-ji Tempel', 35.7148, 139.7967, 'Asakusa, Tokyo, Japan', 3, '09:00', 90, 'Aeltester Tempel Tokyos. Morgens weniger Touristen.', null],
|
[t1, 'Senso-ji Temple', 35.7148, 139.7967, '2 Chome-3-1 Asakusa, Taito City, Tokyo 111-0032, Japan', 3, '09:00', 90, 'Oldest temple in Tokyo. Fewer tourists in the early morning.', null, 'ChIJ8T1GpMGOGGARDYGSgpoOdfg', 'https://www.senso-ji.jp/', '+81 3-3842-0181'],
|
||||||
[t1, 'Shibuya Crossing', 35.6595, 139.7004, 'Shibuya, Tokyo, Japan', 3, '18:00', 45, 'Die beruehmteste Kreuzung der Welt. Abends am beeindruckendsten.', null],
|
[t1, 'Shibuya Crossing', 35.6595, 139.7004, '2 Chome-2-1 Dogenzaka, Shibuya City, Tokyo 150-0043, Japan', 3, '18:00', 45, 'World\'s busiest pedestrian crossing. Most impressive at night.', null, 'ChIJLyzOhmyLGGARMKWbl5z6wGg', null, null],
|
||||||
[t1, 'Tsukiji Outer Market', 35.6654, 139.7707, 'Tsukiji, Tokyo, Japan', 2, '08:00', 120, 'Frisches Sushi zum Fruehstueck! Strassenstaende erkunden.', null],
|
[t1, 'Tsukiji Outer Market', 35.6654, 139.7707, '4 Chome-16-2 Tsukiji, Chuo City, Tokyo 104-0045, Japan', 2, '08:00', 120, 'Fresh sushi for breakfast! Explore the street food stalls.', null, 'ChIJq2i1dZCLGGAR1TfoBRo25VU', 'https://www.tsukiji.or.jp/', null],
|
||||||
[t1, 'Meiji-Schrein', 35.6764, 139.6993, 'Shibuya, Tokyo, Japan', 3, '10:00', 75, 'Ruhige Oase mitten in der Stadt. Durch den Wald zum Schrein.', null],
|
[t1, 'Meiji Jingu Shrine', 35.6764, 139.6993, '1-1 Yoyogikamizonocho, Shibuya City, Tokyo 151-8557, Japan', 3, '10:00', 75, 'Peaceful oasis in the middle of the city. Walk through the forest to the shrine.', null, 'ChIJ5SuJSByMGGARMg9qOlTFgkc', 'https://www.meijijingu.or.jp/', '+81 3-3379-5511'],
|
||||||
[t1, 'Akihabara', 35.7023, 139.7745, 'Akihabara, Tokyo, Japan', 3, '14:00', 180, 'Electric Town — Anime, Manga, Elektronik. Retro-Gaming Shops!', null],
|
[t1, 'Akihabara Electric Town', 35.7023, 139.7745, 'Sotokanda, Chiyoda City, Tokyo, Japan', 3, '14:00', 180, 'Electric Town — anime, manga, electronics. Retro gaming shops!', null, 'ChIJGz1usEyMGGAR1mYByqOOJao', null, null],
|
||||||
[t1, 'Shinkansen nach Kyoto', 35.6812, 139.7671, 'Tokyo Station, Japan', 5, '08:30', 140, 'Nozomi Shinkansen, ca. 2h15. Fensterplatz fuer Fuji-Blick!', null],
|
[t1, 'Shinkansen to Kyoto', 35.6812, 139.7671, '1 Chome Marunouchi, Chiyoda City, Tokyo 100-0005, Japan', 5, '08:30', 140, 'Nozomi Shinkansen, approx. 2h15. Window seat for Mt. Fuji views!', null, 'ChIJC3Cf2PuLGGAROO00ukl8JwA', null, null],
|
||||||
[t1, 'Hotel Granvia Kyoto', 34.9856, 135.7580, 'Kyoto Station, Kyoto, Japan', 1, '14:00', 60, 'Direkt am Bahnhof. Perfekte Lage fuer Tagesausfluege.', null],
|
[t1, 'Hotel Granvia Kyoto', 34.9856, 135.7580, 'Karasuma-dori Shiokoji-sagaru, Shimogyo-ku, Kyoto 600-8216, Japan', 1, '14:00', 60, 'Right at Kyoto Station. Perfect base for day trips.', null, 'ChIJUf6MDFcIAWARLihjKC9FWDY', 'https://www.granvia-kyoto.co.jp/', '+81 75-344-8888'],
|
||||||
[t1, 'Fushimi Inari Taisha', 34.9671, 135.7727, 'Fushimi, Kyoto, Japan', 3, '07:00', 150, '10.000 rote Torii-Tore. Frueh morgens starten fuer leere Wege!', null],
|
[t1, 'Fushimi Inari Taisha', 34.9671, 135.7727, '68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto 612-0882, Japan', 3, '07:00', 150, '10,000 vermillion torii gates. Start early for empty paths!', null, 'ChIJIW0JRbMIAWARPYEzP5LVHGE', 'http://inari.jp/', '+81 75-641-7331'],
|
||||||
[t1, 'Kinkaku-ji (Goldener Pavillon)', 35.0394, 135.7292, 'Kita, Kyoto, Japan', 3, '10:00', 60, 'Der goldene Tempel am See. Ikonisches Fotomotiv.', null],
|
[t1, 'Kinkaku-ji (Golden Pavilion)', 35.0394, 135.7292, '1 Kinkakujicho, Kita Ward, Kyoto 603-8361, Japan', 3, '10:00', 60, 'The golden temple reflected in the mirror pond. Iconic photo spot.', null, 'ChIJvUbrwCCoAWAR5-uyAXPzBHg', null, '+81 75-461-0013'],
|
||||||
[t1, 'Arashiyama Bambushain', 35.0095, 135.6673, 'Arashiyama, Kyoto, Japan', 9, '09:00', 90, 'Magischer Bambuswald. Am besten morgens vor den Massen.', null],
|
[t1, 'Arashiyama Bamboo Grove', 35.0095, 135.6673, 'Sagatenryuji Susukinobabacho, Ukyo Ward, Kyoto 616-8385, Japan', 9, '09:00', 90, 'Magical bamboo forest. Best visited in the morning before the crowds.', null, 'ChIJFS4EvA6pAWARQsAPVijvW7I', null, null],
|
||||||
[t1, 'Nishiki Market', 35.0050, 135.7647, 'Nakagyo, Kyoto, Japan', 2, '12:00', 90, 'Kyotos Kuechengasse. Matcha-Eis und frische Mochi probieren!', null],
|
[t1, 'Nishiki Market', 35.0050, 135.7647, 'Nishiki-koji Dori, Nakagyo Ward, Kyoto 604-8054, Japan', 2, '12:00', 90, 'Kyoto\'s kitchen street. Try the matcha ice cream and fresh mochi!', null, 'ChIJ09zzUigJAWARXzIdh1NE3hQ', 'http://www.kyoto-nishiki.or.jp/', null],
|
||||||
[t1, 'Gion Viertel', 35.0037, 135.7755, 'Gion, Kyoto, Japan', 3, '17:00', 120, 'Historisches Geisha-Viertel. Abends beste Chance auf Maiko-Sichtung.', null],
|
[t1, 'Gion District', 35.0037, 135.7755, 'Gionmachi Minamigawa, Higashiyama Ward, Kyoto 605-0074, Japan', 3, '17:00', 120, 'Historic geisha district. Best chance of spotting a maiko in the evening.', null, 'ChIJ7WWWjfYJAWARGqEHAfXIzgQ', null, null],
|
||||||
];
|
];
|
||||||
|
|
||||||
const t1pIds = t1places.map(p => Number(insertPlace.run(...p).lastInsertRowid));
|
const t1pIds = t1places.map(p => Number(insertPlace.run(...p).lastInsertRowid));
|
||||||
|
|
||||||
// Assign places to days
|
|
||||||
// Day 1: Hotel Check-in, Shibuya
|
// Day 1: Hotel Check-in, Shibuya
|
||||||
insertAssignment.run(t1days[0], t1pIds[0], 0);
|
insertAssignment.run(t1days[0], t1pIds[0], 0);
|
||||||
insertAssignment.run(t1days[0], t1pIds[2], 1);
|
insertAssignment.run(t1days[0], t1pIds[2], 1);
|
||||||
|
insertNote.run(t1days[0], t1, 'Pick up Pocket WiFi at airport', '13:00', 'Info', 0.5);
|
||||||
// Day 2: Tsukiji, Senso-ji, Akihabara
|
// Day 2: Tsukiji, Senso-ji, Akihabara
|
||||||
insertAssignment.run(t1days[1], t1pIds[3], 0);
|
insertAssignment.run(t1days[1], t1pIds[3], 0);
|
||||||
insertAssignment.run(t1days[1], t1pIds[1], 1);
|
insertAssignment.run(t1days[1], t1pIds[1], 1);
|
||||||
insertAssignment.run(t1days[1], t1pIds[5], 2);
|
insertAssignment.run(t1days[1], t1pIds[5], 2);
|
||||||
// Day 3: Meiji-Schrein, free afternoon
|
// Day 3: Meiji Shrine, free afternoon
|
||||||
insertAssignment.run(t1days[2], t1pIds[4], 0);
|
insertAssignment.run(t1days[2], t1pIds[4], 0);
|
||||||
|
insertNote.run(t1days[2], t1, 'Explore Harajuku after the shrine', '12:00', 'MapPin', 1);
|
||||||
// Day 4: Shinkansen to Kyoto, Hotel
|
// Day 4: Shinkansen to Kyoto, Hotel
|
||||||
insertAssignment.run(t1days[3], t1pIds[6], 0);
|
insertAssignment.run(t1days[3], t1pIds[6], 0);
|
||||||
insertAssignment.run(t1days[3], t1pIds[7], 1);
|
insertAssignment.run(t1days[3], t1pIds[7], 1);
|
||||||
|
insertNote.run(t1days[3], t1, 'Sit on right side for Mt. Fuji views!', '08:30', 'Train', 0.5);
|
||||||
// Day 5: Fushimi Inari, Nishiki Market
|
// Day 5: Fushimi Inari, Nishiki Market
|
||||||
insertAssignment.run(t1days[4], t1pIds[8], 0);
|
insertAssignment.run(t1days[4], t1pIds[8], 0);
|
||||||
insertAssignment.run(t1days[4], t1pIds[11], 1);
|
insertAssignment.run(t1days[4], t1pIds[11], 1);
|
||||||
@@ -120,34 +124,34 @@ function seedExampleTrips(db, adminId, demoId) {
|
|||||||
insertAssignment.run(t1days[5], t1pIds[10], 1);
|
insertAssignment.run(t1days[5], t1pIds[10], 1);
|
||||||
// Day 7: Gion
|
// Day 7: Gion
|
||||||
insertAssignment.run(t1days[6], t1pIds[12], 0);
|
insertAssignment.run(t1days[6], t1pIds[12], 0);
|
||||||
|
insertNote.run(t1days[6], t1, 'Last evening — farewell dinner at Pontocho Alley', '19:00', 'Star', 1);
|
||||||
|
|
||||||
// Packing
|
// Packing
|
||||||
const t1packing = [
|
const t1packing = [
|
||||||
['Reisepass', 1, 'Dokumente', 0], ['Japan Rail Pass', 1, 'Dokumente', 1],
|
['Passport', 1, 'Documents', 0], ['Japan Rail Pass', 1, 'Documents', 1],
|
||||||
['Adapter Typ A/B', 0, 'Elektronik', 2], ['Kamera + Ladegeraet', 0, 'Elektronik', 3],
|
['Power adapter Type A/B', 0, 'Electronics', 2], ['Camera + charger', 0, 'Electronics', 3],
|
||||||
['Bequeme Laufschuhe', 0, 'Kleidung', 4], ['Regenjacke', 0, 'Kleidung', 5],
|
['Comfortable walking shoes', 0, 'Clothing', 4], ['Rain jacket', 0, 'Clothing', 5],
|
||||||
['Sonnencreme', 0, 'Hygiene', 6], ['Reiseapotheke', 0, 'Hygiene', 7],
|
['Sunscreen', 0, 'Toiletries', 6], ['Travel first aid kit', 0, 'Toiletries', 7],
|
||||||
['Pocket WiFi Bestaetigung', 1, 'Elektronik', 8], ['Yen Bargeld', 0, 'Dokumente', 9],
|
['Pocket WiFi confirmation', 1, 'Electronics', 8], ['Yen cash', 0, 'Documents', 9],
|
||||||
];
|
];
|
||||||
t1packing.forEach(p => insertPacking.run(t1, ...p));
|
t1packing.forEach(p => insertPacking.run(t1, ...p));
|
||||||
|
|
||||||
// Budget
|
// Budget
|
||||||
insertBudget.run(t1, 'Unterkunft', 'Hotel Shinjuku (3 Naechte)', 450, 2, 'Doppelzimmer');
|
insertBudget.run(t1, 'Accommodation', 'Hotel Shinjuku (3 nights)', 67500, 2, 'Double room');
|
||||||
insertBudget.run(t1, 'Unterkunft', 'Hotel Granvia Kyoto (4 Naechte)', 680, 2, 'Superior Room');
|
insertBudget.run(t1, 'Accommodation', 'Hotel Granvia Kyoto (4 nights)', 102000, 2, 'Superior room');
|
||||||
insertBudget.run(t1, 'Transport', 'Fluege FRA-NRT', 1200, 2, 'Lufthansa Direktflug');
|
insertBudget.run(t1, 'Transport', 'Flights FRA-NRT return', 180000, 2, 'Lufthansa direct');
|
||||||
insertBudget.run(t1, 'Transport', 'Japan Rail Pass (7 Tage)', 380, 2, 'Ordinaer');
|
insertBudget.run(t1, 'Transport', 'Japan Rail Pass (7 days)', 57000, 2, 'Ordinary');
|
||||||
insertBudget.run(t1, 'Essen', 'Tagesbudget Essen', 350, 2, 'Ca. 50 EUR/Tag');
|
insertBudget.run(t1, 'Food', 'Daily food budget', 52500, 2, 'Approx. 7,500 JPY/day');
|
||||||
insertBudget.run(t1, 'Aktivitaeten', 'Tempel-Eintritte & Erlebnisse', 120, 2, null);
|
insertBudget.run(t1, 'Activities', 'Temple entries & experiences', 18000, 2, null);
|
||||||
|
|
||||||
// Reservations
|
// Reservations
|
||||||
insertReservation.run(t1, t1days[0], 'Hotel Shinjuku Check-in', '15:00', 'SG-2026-78432', 'confirmed', 'hotel', 'Shinjuku, Tokyo');
|
insertReservation.run(t1, t1days[0], 'Hotel Shinjuku Check-in', '15:00', 'SG-2026-78432', 'confirmed', 'hotel', 'Shinjuku, Tokyo');
|
||||||
insertReservation.run(t1, t1days[3], 'Shinkansen Tokyo → Kyoto', '08:30', 'JR-NOZOMI-445', 'confirmed', 'transport', 'Tokyo Station');
|
insertReservation.run(t1, t1days[3], 'Shinkansen Tokyo → Kyoto', '08:30', 'JR-NOZOMI-445', 'confirmed', 'transport', 'Tokyo Station');
|
||||||
|
|
||||||
// Share with demo user
|
|
||||||
insertMember.run(t1, demoId, adminId);
|
insertMember.run(t1, demoId, adminId);
|
||||||
|
|
||||||
// ─── Trip 2: Barcelona Citytrip ───
|
// ─── Trip 2: Barcelona Long Weekend ────────────────────────────────────────
|
||||||
const trip2 = insertTrip.run(adminId, 'Barcelona Citytrip', 'Gaudi, Tapas und Meerblick — ein langes Wochenende in Kataloniens Hauptstadt.', '2026-05-21', '2026-05-24', 'EUR');
|
const trip2 = insertTrip.run(adminId, 'Barcelona Long Weekend', 'Gaudi, tapas, and Mediterranean vibes — a long weekend in the Catalan capital.', '2026-05-21', '2026-05-24', 'EUR');
|
||||||
const t2 = Number(trip2.lastInsertRowid);
|
const t2 = Number(trip2.lastInsertRowid);
|
||||||
|
|
||||||
const t2days = [];
|
const t2days = [];
|
||||||
@@ -157,14 +161,14 @@ function seedExampleTrips(db, adminId, demoId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const t2places = [
|
const t2places = [
|
||||||
[t2, 'Hotel W Barcelona', 41.3686, 2.1920, 'Barceloneta, Barcelona, Spain', 1, '14:00', 60, 'Direkt am Strand. Rooftop-Bar mit Panorama!', null],
|
[t2, 'W Barcelona', 41.3686, 2.1920, 'Placa de la Rosa dels Vents 1, 08039 Barcelona, Spain', 1, '14:00', 60, 'Right on the beach. Rooftop bar with panoramic views!', null, 'ChIJKfj5C8yjpBIRCPC3RPI0JO4', 'https://www.marriott.com/hotels/travel/bcnwh-w-barcelona/', '+34 932 95 28 00'],
|
||||||
[t2, 'Sagrada Familia', 41.4036, 2.1744, 'Eixample, Barcelona, Spain', 3, '10:00', 120, 'Gaudis Meisterwerk. Tickets unbedingt vorher online buchen!', null],
|
[t2, 'Sagrada Familia', 41.4036, 2.1744, 'C/ de Mallorca, 401, 08013 Barcelona, Spain', 3, '10:00', 120, 'Gaudi\'s masterpiece. Book tickets online in advance — sells out fast!', null, 'ChIJk_s92NyipBIRUMnDG8Kq2Js', 'https://sagradafamilia.org/', '+34 932 08 04 14'],
|
||||||
[t2, 'Park Gueell', 41.4145, 2.1527, 'Gracia, Barcelona, Spain', 3, '09:00', 90, 'Mosaik-Terrasse mit Stadtblick. Frueh buchen fuer Monumental Zone.', null],
|
[t2, 'Park Guell', 41.4145, 2.1527, '08024 Barcelona, Spain', 3, '09:00', 90, 'Mosaic terrace with city views. Book early for the Monumental Zone.', null, 'ChIJ4eQMeOmipBIRb65JRUzGE8k', 'https://parkguell.barcelona/', '+34 934 09 18 31'],
|
||||||
[t2, 'La Boqueria', 41.3816, 2.1717, 'La Rambla, Barcelona, Spain', 2, '12:00', 75, 'Beruehmter Markt an der Rambla. Frischer Saft und Jamon Iberico!', null],
|
[t2, 'La Boqueria Market', 41.3816, 2.1717, 'La Rambla, 91, 08001 Barcelona, Spain', 2, '12:00', 75, 'Famous market on La Rambla. Fresh juice, jamon iberico, and seafood!', null, 'ChIJB_RfKcuipBIRkPKW7MzVGKg', 'http://www.boqueria.barcelona/', '+34 933 18 25 84'],
|
||||||
[t2, 'Barceloneta Beach', 41.3784, 2.1925, 'Barceloneta, Barcelona, Spain', 8, '16:00', 120, 'Stadtstrand zum Entspannen nach dem Sightseeing.', null],
|
[t2, 'Barceloneta Beach', 41.3784, 2.1925, 'Passeig Maritim de la Barceloneta, 08003 Barcelona, Spain', 8, '16:00', 120, 'City beach to unwind after sightseeing. Great chiringuitos nearby.', null, 'ChIJAQCl79-ipBIRUKF3myrMYkM', null, null],
|
||||||
[t2, 'Barri Gotic', 41.3834, 2.1762, 'Ciutat Vella, Barcelona, Spain', 3, '15:00', 90, 'Mittelalterliche Gassen. Kathedrale und Placa Reial entdecken.', null],
|
[t2, 'Gothic Quarter', 41.3834, 2.1762, 'Barri Gotic, 08002 Barcelona, Spain', 3, '15:00', 90, 'Medieval lanes, the cathedral, and Placa Reial. Get lost in the alleys!', null, 'ChIJ4_xkvv2ipBIRrK3bdd-lHgo', null, null],
|
||||||
[t2, 'Casa Batllo', 41.3916, 2.1650, 'Passeig de Gracia, Barcelona, Spain', 3, '11:00', 75, 'Gaudis Drachen-Haus. Die Fassade allein ist schon ein Erlebnis.', null],
|
[t2, 'Casa Batllo', 41.3916, 2.1650, 'Passeig de Gracia, 43, 08007 Barcelona, Spain', 3, '11:00', 75, 'Gaudi\'s dragon house. The facade alone is worth the visit.', null, 'ChIJ-2VKIcaipBIRKK63H5PYjqQ', 'https://www.casabatllo.es/', '+34 932 16 03 06'],
|
||||||
[t2, 'El Born & Tapas', 41.3856, 2.1825, 'El Born, Barcelona, Spain', 7, '20:00', 120, 'Trendviertel mit den besten Tapas-Bars. Cal Pep oder El Xampanyet!', null],
|
[t2, 'El Born & Tapas', 41.3856, 2.1825, 'El Born, 08003 Barcelona, Spain', 7, '20:00', 120, 'Trendy neighborhood with the best tapas bars. Try Cal Pep or El Xampanyet!', null, 'ChIJNY56dxuipBIRbqjSczmLvIA', null, null],
|
||||||
];
|
];
|
||||||
|
|
||||||
const t2pIds = t2places.map(p => Number(insertPlace.run(...p).lastInsertRowid));
|
const t2pIds = t2places.map(p => Number(insertPlace.run(...p).lastInsertRowid));
|
||||||
@@ -177,68 +181,94 @@ function seedExampleTrips(db, adminId, demoId) {
|
|||||||
insertAssignment.run(t2days[1], t2pIds[1], 0);
|
insertAssignment.run(t2days[1], t2pIds[1], 0);
|
||||||
insertAssignment.run(t2days[1], t2pIds[6], 1);
|
insertAssignment.run(t2days[1], t2pIds[6], 1);
|
||||||
insertAssignment.run(t2days[1], t2pIds[3], 2);
|
insertAssignment.run(t2days[1], t2pIds[3], 2);
|
||||||
// Day 3: Park Gueell, Barri Gotic
|
insertNote.run(t2days[1], t2, 'Tickets already booked for 10:00 AM slot', '09:30', 'Ticket', 0.5);
|
||||||
|
// Day 3: Park Guell, Gothic Quarter
|
||||||
insertAssignment.run(t2days[2], t2pIds[2], 0);
|
insertAssignment.run(t2days[2], t2pIds[2], 0);
|
||||||
insertAssignment.run(t2days[2], t2pIds[5], 1);
|
insertAssignment.run(t2days[2], t2pIds[5], 1);
|
||||||
// Day 4: Free morning, departure
|
// Day 4: Beach morning, departure
|
||||||
insertAssignment.run(t2days[3], t2pIds[4], 0);
|
insertAssignment.run(t2days[3], t2pIds[4], 0);
|
||||||
|
insertNote.run(t2days[3], t2, 'Flight departs at 18:30 — leave hotel by 15:00', '14:00', 'Plane', 1);
|
||||||
|
|
||||||
// Packing
|
// Packing
|
||||||
['Reisepass', 'Sonnencreme SPF50', 'Badehose/Bikini', 'Sonnenbrille', 'Bequeme Sandalen', 'Strandtuch'].forEach((name, i) => {
|
['Passport', 'Sunscreen SPF50', 'Swimwear', 'Sunglasses', 'Comfortable sandals', 'Beach towel'].forEach((name, i) => {
|
||||||
insertPacking.run(t2, name, 0, i < 1 ? 'Dokumente' : 'Sommer', i);
|
insertPacking.run(t2, name, 0, i < 1 ? 'Documents' : 'Summer', i);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Budget
|
// Budget
|
||||||
insertBudget.run(t2, 'Unterkunft', 'Hotel W Barcelona (3 Naechte)', 780, 2, 'Sea View Room');
|
insertBudget.run(t2, 'Accommodation', 'W Barcelona (3 nights)', 780, 2, 'Sea View Room');
|
||||||
insertBudget.run(t2, 'Transport', 'Fluege BER-BCN', 180, 2, 'Eurowings');
|
insertBudget.run(t2, 'Transport', 'Flights BER-BCN return', 180, 2, 'Eurowings');
|
||||||
insertBudget.run(t2, 'Essen', 'Restaurants & Tapas', 300, 2, 'Ca. 75 EUR/Tag');
|
insertBudget.run(t2, 'Food', 'Restaurants & tapas', 300, 2, 'Approx. 75 EUR/day');
|
||||||
insertBudget.run(t2, 'Aktivitaeten', 'Sagrada Familia + Park Gueell + Casa Batllo', 95, 2, 'Online-Tickets');
|
insertBudget.run(t2, 'Activities', 'Sagrada Familia + Park Guell + Casa Batllo', 95, 2, 'Online tickets');
|
||||||
|
|
||||||
insertReservation.run(t2, t2days[1], 'Sagrada Familia Eintritt', '10:00', 'SF-2026-11234', 'confirmed', 'activity', 'Eixample, Barcelona');
|
insertReservation.run(t2, t2days[1], 'Sagrada Familia Entry', '10:00', 'SF-2026-11234', 'confirmed', 'activity', 'Eixample, Barcelona');
|
||||||
|
|
||||||
insertMember.run(t2, demoId, adminId);
|
insertMember.run(t2, demoId, adminId);
|
||||||
|
|
||||||
// ─── Trip 3: Wochenende in Wien ───
|
// ─── Trip 3: New York City ─────────────────────────────────────────────────
|
||||||
const trip3 = insertTrip.run(adminId, 'Wochenende in Wien', 'Kaffeehaus-Kultur, imperiale Pracht und Sachertorte.', '2026-06-12', '2026-06-14', 'EUR');
|
const trip3 = insertTrip.run(adminId, 'New York City', 'The city that never sleeps — iconic landmarks, world-class food, and Broadway lights.', '2026-09-18', '2026-09-22', 'USD');
|
||||||
const t3 = Number(trip3.lastInsertRowid);
|
const t3 = Number(trip3.lastInsertRowid);
|
||||||
|
|
||||||
const t3days = [];
|
const t3days = [];
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
const d = insertDay.run(t3, i + 1, `2026-06-${12 + i}`);
|
const d = insertDay.run(t3, i + 1, `2026-09-${18 + i}`);
|
||||||
t3days.push(Number(d.lastInsertRowid));
|
t3days.push(Number(d.lastInsertRowid));
|
||||||
}
|
}
|
||||||
|
|
||||||
const t3places = [
|
const t3places = [
|
||||||
[t3, 'Hotel Sacher Wien', 48.2038, 16.3699, 'Philharmonikerstrasse 4, Wien, Austria', 1, '15:00', 45, 'Das legendaere Hotel. Sachertorte im Cafe muss sein!', null],
|
[t3, 'The Plaza Hotel', 40.7645, -73.9744, '768 5th Ave, New York, NY 10019, USA', 1, '15:00', 60, 'Iconic luxury hotel on Central Park. The lobby alone is worth a visit.', null, 'ChIJYbISlAVYwokRn6ORbSPV0xk', 'https://www.theplazany.com/', '+1 212-759-3000'],
|
||||||
[t3, 'Stephansdom', 48.2082, 16.3738, 'Stephansplatz, Wien, Austria', 3, '10:00', 60, 'Wahrzeichen Wiens. Turmbesteigung fuer 360-Grad-Blick.', null],
|
[t3, 'Statue of Liberty', 40.6892, -74.0445, 'Liberty Island, New York, NY 10004, USA', 3, '09:00', 180, 'Book crown access tickets months in advance. Ferry from Battery Park.', null, 'ChIJPTacEpBQwokRKwIlDXelxkA', 'https://www.nps.gov/stli/', '+1 212-363-3200'],
|
||||||
[t3, 'Schloss Schoenbrunn', 48.1845, 16.3122, 'Schoenbrunn, Wien, Austria', 3, '09:30', 150, 'Imperiale Pracht. Grand Tour Ticket fuer alle 40 Raeume.', null],
|
[t3, 'Central Park', 40.7829, -73.9654, 'Central Park, New York, NY 10024, USA', 9, '10:00', 120, 'Bethesda Fountain, Bow Bridge, and Strawberry Fields. Rent bikes!', null, 'ChIJ4zGFAZpYwokRGUGph3Mf37k', 'https://www.centralparknyc.org/', null],
|
||||||
[t3, 'Naschmarkt', 48.1986, 16.3633, 'Wienzeile, Wien, Austria', 2, '12:00', 75, 'Wiens groesster Markt. Orientalische Gewuerze bis Wiener Schnitzel.', null],
|
[t3, 'Times Square', 40.7580, -73.9855, 'Manhattan, NY 10036, USA', 3, '19:00', 60, 'The crossroads of the world. Best experienced at night with all the lights.', null, 'ChIJmQJIxlVYwokRLgeuocVOGVU', 'https://www.timessquarenyc.org/', null],
|
||||||
[t3, 'Cafe Central', 48.2107, 16.3654, 'Herrengasse 14, Wien, Austria', 7, '15:00', 60, 'Wo einst Trotzki Schach spielte. Melange und Apfelstrudel!', null],
|
[t3, 'Empire State Building', 40.7484, -73.9857, '350 5th Ave, New York, NY 10118, USA', 3, '11:00', 90, '86th floor observation deck. Go at sunset for the best views.', null, 'ChIJaXQRs6lZwokRY6EFpJnhNNE', 'https://www.esbnyc.com/', '+1 212-736-3100'],
|
||||||
[t3, 'Prater & Riesenrad', 48.2166, 16.3964, 'Prater, Wien, Austria', 6, '17:00', 90, 'Riesenrad bei Sonnenuntergang. Blick ueber die ganze Stadt.', null],
|
[t3, 'Brooklyn Bridge', 40.7061, -73.9969, 'Brooklyn Bridge, New York, NY 10038, USA', 3, '16:00', 75, 'Walk from Manhattan to Brooklyn. DUMBO has great pizza and views.', null, 'ChIJK3vOQyNawokRXEYwET2GUtY', null, null],
|
||||||
|
[t3, 'The Metropolitan Museum of Art', 40.7794, -73.9632, '1000 5th Ave, New York, NY 10028, USA', 3, '10:00', 180, 'One of the world\'s greatest art museums. Could spend days here.', null, 'ChIJb8Jg766MwokR1YWG0nV7k-E', 'https://www.metmuseum.org/', '+1 212-535-7710'],
|
||||||
|
[t3, 'Joe\'s Pizza', 40.7309, -73.9969, '7 Carmine St, New York, NY 10014, USA', 2, '13:00', 30, 'New York\'s most famous pizza slice. Cash only, always a line, always worth it.', null, 'ChIJrfCL1IZZwokRwO3NKN22ZBc', 'http://www.joespizzanyc.com/', '+1 212-366-1182'],
|
||||||
|
[t3, 'Top of the Rock', 40.7593, -73.9794, '30 Rockefeller Plaza, New York, NY 10112, USA', 3, '17:30', 60, 'Better views than Empire State because you can SEE the Empire State.', null, 'ChIJ_y2Fb1JYwokRT_iGzhTLdBo', 'https://www.topoftherocknyc.com/', '+1 212-698-2000'],
|
||||||
|
[t3, 'Chelsea Market', 40.7424, -74.0061, '75 9th Ave, New York, NY 10011, USA', 2, '12:00', 90, 'Food hall in a converted factory. Lobster rolls, tacos, doughnuts, and more.', null, 'ChIJw2FNFyZZwokRcP9th_vIbkE', 'https://www.chelseamarket.com/', null],
|
||||||
|
[t3, 'Broadway Show', 40.7590, -73.9845, 'Broadway, Manhattan, NY 10019, USA', 6, '20:00', 150, 'Can\'t visit NYC without seeing a show. Book TKTS booth for discounts.', null, 'ChIJMYQhxFtYwokR7cJBcNqfKDY', null, null],
|
||||||
];
|
];
|
||||||
|
|
||||||
const t3pIds = t3places.map(p => Number(insertPlace.run(...p).lastInsertRowid));
|
const t3pIds = t3places.map(p => Number(insertPlace.run(...p).lastInsertRowid));
|
||||||
|
|
||||||
// Day 1: Arrival, Stephansdom, Cafe Central
|
// Day 1: Arrival, Times Square, Broadway
|
||||||
insertAssignment.run(t3days[0], t3pIds[0], 0);
|
insertAssignment.run(t3days[0], t3pIds[0], 0);
|
||||||
insertAssignment.run(t3days[0], t3pIds[1], 1);
|
insertAssignment.run(t3days[0], t3pIds[3], 1);
|
||||||
insertAssignment.run(t3days[0], t3pIds[4], 2);
|
insertAssignment.run(t3days[0], t3pIds[10], 2);
|
||||||
// Day 2: Schoenbrunn, Naschmarkt, Prater
|
// Day 2: Statue of Liberty, Brooklyn Bridge, Joe's Pizza
|
||||||
insertAssignment.run(t3days[1], t3pIds[2], 0);
|
insertAssignment.run(t3days[1], t3pIds[1], 0);
|
||||||
insertAssignment.run(t3days[1], t3pIds[3], 1);
|
insertAssignment.run(t3days[1], t3pIds[5], 1);
|
||||||
insertAssignment.run(t3days[1], t3pIds[5], 2);
|
insertAssignment.run(t3days[1], t3pIds[7], 2);
|
||||||
// Day 3: Free morning
|
insertNote.run(t3days[1], t3, 'First ferry at 8:30 AM — arrive early at Battery Park', '08:00', 'Ship', 0.5);
|
||||||
insertAssignment.run(t3days[2], t3pIds[4], 0);
|
// Day 3: Central Park, Met Museum, Top of the Rock sunset
|
||||||
|
insertAssignment.run(t3days[2], t3pIds[2], 0);
|
||||||
|
insertAssignment.run(t3days[2], t3pIds[6], 1);
|
||||||
|
insertAssignment.run(t3days[2], t3pIds[8], 2);
|
||||||
|
// Day 4: Empire State Building, Chelsea Market, shopping
|
||||||
|
insertAssignment.run(t3days[3], t3pIds[4], 0);
|
||||||
|
insertAssignment.run(t3days[3], t3pIds[9], 1);
|
||||||
|
insertNote.run(t3days[3], t3, 'SoHo and 5th Avenue shopping in the afternoon', '14:00', 'ShoppingBag', 1.5);
|
||||||
|
// Day 5: Free morning, departure
|
||||||
|
insertNote.run(t3days[4], t3, 'Flight departs JFK at 17:00 — last bagel at Russ & Daughters!', '10:00', 'Plane', 0);
|
||||||
|
|
||||||
// Packing
|
// Packing
|
||||||
['Personalausweis', 'Regenschirm', 'Bequeme Schuhe', 'Kamera'].forEach((name, i) => {
|
const t3packing = [
|
||||||
insertPacking.run(t3, name, 0, i < 1 ? 'Dokumente' : 'Sonstiges', i);
|
['Passport', 1, 'Documents', 0], ['ESTA confirmation', 1, 'Documents', 1],
|
||||||
});
|
['Travel insurance', 0, 'Documents', 2], ['Comfortable sneakers', 0, 'Clothing', 3],
|
||||||
|
['Light jacket', 0, 'Clothing', 4], ['Portable charger', 0, 'Electronics', 5],
|
||||||
|
['Camera', 0, 'Electronics', 6], ['Subway card (OMNY)', 0, 'Transport', 7],
|
||||||
|
];
|
||||||
|
t3packing.forEach(p => insertPacking.run(t3, ...p));
|
||||||
|
|
||||||
// Budget
|
// Budget
|
||||||
insertBudget.run(t3, 'Unterkunft', 'Hotel Sacher (2 Naechte)', 520, 2, 'Classic Doppelzimmer');
|
insertBudget.run(t3, 'Accommodation', 'The Plaza Hotel (4 nights)', 2400, 2, 'Park View Room');
|
||||||
insertBudget.run(t3, 'Transport', 'Zug MUC-VIE', 60, 2, 'OeBB Sparschiene');
|
insertBudget.run(t3, 'Transport', 'Flights FRA-JFK return', 850, 2, 'United Airlines');
|
||||||
insertBudget.run(t3, 'Essen', 'Restaurants & Cafes', 200, 2, null);
|
insertBudget.run(t3, 'Food', 'Daily food budget', 500, 2, 'Approx. 100 USD/day');
|
||||||
|
insertBudget.run(t3, 'Activities', 'Statue of Liberty + Empire State + Top of the Rock + Met', 180, 2, 'CityPASS');
|
||||||
|
insertBudget.run(t3, 'Entertainment', 'Broadway show tickets', 300, 2, 'Hamilton or Wicked');
|
||||||
|
|
||||||
|
insertReservation.run(t3, t3days[0], 'The Plaza Hotel Check-in', '15:00', 'PZ-2026-55891', 'confirmed', 'hotel', '768 5th Ave, New York');
|
||||||
|
insertReservation.run(t3, t3days[0], 'Broadway Show', '20:00', 'BW-HAM-2026-1192', 'pending', 'activity', 'Richard Rodgers Theatre');
|
||||||
|
insertReservation.run(t3, t3days[1], 'Statue of Liberty Ferry', '08:30', 'SOL-2026-3347', 'confirmed', 'transport', 'Battery Park');
|
||||||
|
|
||||||
insertMember.run(t3, demoId, adminId);
|
insertMember.run(t3, demoId, adminId);
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +57,7 @@ app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
|
|||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const tripsRoutes = require('./routes/trips');
|
const tripsRoutes = require('./routes/trips');
|
||||||
const daysRoutes = require('./routes/days');
|
const daysRoutes = require('./routes/days');
|
||||||
|
const accommodationsRoutes = require('./routes/days').accommodationsRouter;
|
||||||
const placesRoutes = require('./routes/places');
|
const placesRoutes = require('./routes/places');
|
||||||
const assignmentsRoutes = require('./routes/assignments');
|
const assignmentsRoutes = require('./routes/assignments');
|
||||||
const packingRoutes = require('./routes/packing');
|
const packingRoutes = require('./routes/packing');
|
||||||
@@ -81,6 +78,7 @@ app.use('/api/auth', authRoutes);
|
|||||||
app.use('/api/auth/oidc', oidcRoutes);
|
app.use('/api/auth/oidc', oidcRoutes);
|
||||||
app.use('/api/trips', tripsRoutes);
|
app.use('/api/trips', tripsRoutes);
|
||||||
app.use('/api/trips/:tripId/days', daysRoutes);
|
app.use('/api/trips/:tripId/days', daysRoutes);
|
||||||
|
app.use('/api/trips/:tripId/accommodations', accommodationsRoutes);
|
||||||
app.use('/api/trips/:tripId/places', placesRoutes);
|
app.use('/api/trips/:tripId/places', placesRoutes);
|
||||||
app.use('/api/trips/:tripId/packing', packingRoutes);
|
app.use('/api/trips/:tripId/packing', packingRoutes);
|
||||||
app.use('/api/trips/:tripId/files', filesRoutes);
|
app.use('/api/trips/:tripId/files', filesRoutes);
|
||||||
@@ -139,4 +137,25 @@ const server = app.listen(PORT, () => {
|
|||||||
setupWebSocket(server);
|
setupWebSocket(server);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
function shutdown(signal) {
|
||||||
|
console.log(`\n${signal} received — shutting down gracefully...`);
|
||||||
|
scheduler.stop();
|
||||||
|
server.close(() => {
|
||||||
|
console.log('HTTP server closed');
|
||||||
|
const { closeDb } = require('./db/database');
|
||||||
|
closeDb();
|
||||||
|
console.log('Shutdown complete');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
// Force exit after 10s if connections don't close
|
||||||
|
setTimeout(() => {
|
||||||
|
console.error('Forced shutdown after timeout');
|
||||||
|
process.exit(1);
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
const { db } = require('../db/database');
|
const { db } = require('../db/database');
|
||||||
const { authenticate, adminOnly } = require('../middleware/auth');
|
const { authenticate, adminOnly } = require('../middleware/auth');
|
||||||
|
|
||||||
@@ -28,18 +30,18 @@ router.post('/users', (req, res) => {
|
|||||||
const { username, email, password, role } = req.body;
|
const { username, email, password, role } = req.body;
|
||||||
|
|
||||||
if (!username?.trim() || !email?.trim() || !password?.trim()) {
|
if (!username?.trim() || !email?.trim() || !password?.trim()) {
|
||||||
return res.status(400).json({ error: 'Benutzername, E-Mail und Passwort sind erforderlich' });
|
return res.status(400).json({ error: 'Username, email and password are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role && !['user', 'admin'].includes(role)) {
|
if (role && !['user', 'admin'].includes(role)) {
|
||||||
return res.status(400).json({ error: 'Ungültige Rolle' });
|
return res.status(400).json({ error: 'Invalid role' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUsername = db.prepare('SELECT id FROM users WHERE username = ?').get(username.trim());
|
const existingUsername = db.prepare('SELECT id FROM users WHERE username = ?').get(username.trim());
|
||||||
if (existingUsername) return res.status(409).json({ error: 'Benutzername bereits vergeben' });
|
if (existingUsername) return res.status(409).json({ error: 'Username already taken' });
|
||||||
|
|
||||||
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email.trim());
|
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email.trim());
|
||||||
if (existingEmail) return res.status(409).json({ error: 'E-Mail bereits vergeben' });
|
if (existingEmail) return res.status(409).json({ error: 'Email already taken' });
|
||||||
|
|
||||||
const passwordHash = bcrypt.hashSync(password.trim(), 10);
|
const passwordHash = bcrypt.hashSync(password.trim(), 10);
|
||||||
|
|
||||||
@@ -59,19 +61,19 @@ router.put('/users/:id', (req, res) => {
|
|||||||
const { username, email, role, password } = req.body;
|
const { username, email, role, password } = req.body;
|
||||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
||||||
|
|
||||||
if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
if (role && !['user', 'admin'].includes(role)) {
|
if (role && !['user', 'admin'].includes(role)) {
|
||||||
return res.status(400).json({ error: 'Ungültige Rolle' });
|
return res.status(400).json({ error: 'Invalid role' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (username && username !== user.username) {
|
if (username && username !== user.username) {
|
||||||
const conflict = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, req.params.id);
|
const conflict = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, req.params.id);
|
||||||
if (conflict) return res.status(409).json({ error: 'Benutzername bereits vergeben' });
|
if (conflict) return res.status(409).json({ error: 'Username already taken' });
|
||||||
}
|
}
|
||||||
if (email && email !== user.email) {
|
if (email && email !== user.email) {
|
||||||
const conflict = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, req.params.id);
|
const conflict = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, req.params.id);
|
||||||
if (conflict) return res.status(409).json({ error: 'E-Mail bereits vergeben' });
|
if (conflict) return res.status(409).json({ error: 'Email already taken' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = password ? bcrypt.hashSync(password, 10) : null;
|
const passwordHash = password ? bcrypt.hashSync(password, 10) : null;
|
||||||
@@ -96,11 +98,11 @@ router.put('/users/:id', (req, res) => {
|
|||||||
// DELETE /api/admin/users/:id
|
// DELETE /api/admin/users/:id
|
||||||
router.delete('/users/:id', (req, res) => {
|
router.delete('/users/:id', (req, res) => {
|
||||||
if (parseInt(req.params.id) === req.user.id) {
|
if (parseInt(req.params.id) === req.user.id) {
|
||||||
return res.status(400).json({ error: 'Eigenes Konto kann nicht gelöscht werden' });
|
return res.status(400).json({ error: 'Cannot delete own account' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
|
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
|
||||||
if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
|
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
@@ -152,6 +154,86 @@ router.post('/save-demo-baseline', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Version check ──────────────────────────────────────────
|
||||||
|
|
||||||
|
// Detect if running inside Docker
|
||||||
|
const isDocker = (() => {
|
||||||
|
try {
|
||||||
|
const fs = require('fs');
|
||||||
|
return fs.existsSync('/.dockerenv') || (fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker'));
|
||||||
|
} catch { return false }
|
||||||
|
})();
|
||||||
|
|
||||||
|
router.get('/version-check', async (req, res) => {
|
||||||
|
const { version: currentVersion } = require('../../package.json');
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
'https://api.github.com/repos/mauriceboe/NOMAD/releases/latest',
|
||||||
|
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'NOMAD-Server' } }
|
||||||
|
);
|
||||||
|
if (!resp.ok) return res.json({ current: currentVersion, latest: currentVersion, update_available: false });
|
||||||
|
const data = await resp.json();
|
||||||
|
const latest = (data.tag_name || '').replace(/^v/, '');
|
||||||
|
const update_available = latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
|
||||||
|
res.json({ current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker });
|
||||||
|
} catch {
|
||||||
|
res.json({ current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function compareVersions(a, b) {
|
||||||
|
const pa = a.split('.').map(Number);
|
||||||
|
const pb = b.split('.').map(Number);
|
||||||
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||||
|
const na = pa[i] || 0, nb = pb[i] || 0;
|
||||||
|
if (na > nb) return 1;
|
||||||
|
if (na < nb) return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/update — pull latest code, install deps, restart
|
||||||
|
router.post('/update', async (req, res) => {
|
||||||
|
const rootDir = path.resolve(__dirname, '../../..');
|
||||||
|
const serverDir = path.resolve(__dirname, '../..');
|
||||||
|
const clientDir = path.join(rootDir, 'client');
|
||||||
|
const steps = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. git pull
|
||||||
|
const pullOutput = execSync('git pull origin main', { cwd: rootDir, timeout: 60000, encoding: 'utf8' });
|
||||||
|
steps.push({ step: 'git pull', success: true, output: pullOutput.trim() });
|
||||||
|
|
||||||
|
// 2. npm install server
|
||||||
|
execSync('npm install --production', { cwd: serverDir, timeout: 120000, encoding: 'utf8' });
|
||||||
|
steps.push({ step: 'npm install (server)', success: true });
|
||||||
|
|
||||||
|
// 3. npm install + build client (production only)
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
execSync('npm install', { cwd: clientDir, timeout: 120000, encoding: 'utf8' });
|
||||||
|
execSync('npm run build', { cwd: clientDir, timeout: 120000, encoding: 'utf8' });
|
||||||
|
steps.push({ step: 'npm install + build (client)', success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read new version
|
||||||
|
delete require.cache[require.resolve('../../package.json')];
|
||||||
|
const { version: newVersion } = require('../../package.json');
|
||||||
|
steps.push({ step: 'version', version: newVersion });
|
||||||
|
|
||||||
|
// 4. Send response before restart
|
||||||
|
res.json({ success: true, steps, restarting: true });
|
||||||
|
|
||||||
|
// 5. Graceful restart — exit and let process manager (Docker/systemd/pm2) restart
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('[Update] Restarting after update...');
|
||||||
|
process.exit(0);
|
||||||
|
}, 1000);
|
||||||
|
} catch (err) {
|
||||||
|
steps.push({ step: 'error', success: false, output: err.message });
|
||||||
|
res.status(500).json({ success: false, steps });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Addons ─────────────────────────────────────────────────
|
// ── Addons ─────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/addons', (req, res) => {
|
router.get('/addons', (req, res) => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function getAssignmentWithPlace(assignmentId) {
|
|||||||
const a = db.prepare(`
|
const a = db.prepare(`
|
||||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||||
p.reservation_status, p.reservation_notes, p.reservation_datetime, p.place_time, p.duration_minutes, p.notes as place_notes,
|
p.place_time, p.end_time, p.duration_minutes, p.notes as place_notes,
|
||||||
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
||||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||||
FROM day_assignments da
|
FROM day_assignments da
|
||||||
@@ -46,10 +46,8 @@ function getAssignmentWithPlace(assignmentId) {
|
|||||||
category_id: a.category_id,
|
category_id: a.category_id,
|
||||||
price: a.price,
|
price: a.price,
|
||||||
currency: a.place_currency,
|
currency: a.place_currency,
|
||||||
reservation_status: a.reservation_status,
|
|
||||||
reservation_notes: a.reservation_notes,
|
|
||||||
reservation_datetime: a.reservation_datetime,
|
|
||||||
place_time: a.place_time,
|
place_time: a.place_time,
|
||||||
|
end_time: a.end_time,
|
||||||
duration_minutes: a.duration_minutes,
|
duration_minutes: a.duration_minutes,
|
||||||
notes: a.place_notes,
|
notes: a.place_notes,
|
||||||
image_url: a.image_url,
|
image_url: a.image_url,
|
||||||
@@ -73,15 +71,15 @@ router.get('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) =>
|
|||||||
const { tripId, dayId } = req.params;
|
const { tripId, dayId } = req.params;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
||||||
if (!day) return res.status(404).json({ error: 'Tag nicht gefunden' });
|
if (!day) return res.status(404).json({ error: 'Day not found' });
|
||||||
|
|
||||||
const assignments = db.prepare(`
|
const assignments = db.prepare(`
|
||||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||||
p.reservation_status, p.reservation_notes, p.reservation_datetime, p.place_time, p.duration_minutes, p.notes as place_notes,
|
p.place_time, p.end_time, p.duration_minutes, p.notes as place_notes,
|
||||||
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
||||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||||
FROM day_assignments da
|
FROM day_assignments da
|
||||||
@@ -124,9 +122,6 @@ router.get('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) =>
|
|||||||
category_id: a.category_id,
|
category_id: a.category_id,
|
||||||
price: a.price,
|
price: a.price,
|
||||||
currency: a.place_currency,
|
currency: a.place_currency,
|
||||||
reservation_status: a.reservation_status,
|
|
||||||
reservation_notes: a.reservation_notes,
|
|
||||||
reservation_datetime: a.reservation_datetime,
|
|
||||||
place_time: a.place_time,
|
place_time: a.place_time,
|
||||||
duration_minutes: a.duration_minutes,
|
duration_minutes: a.duration_minutes,
|
||||||
notes: a.place_notes,
|
notes: a.place_notes,
|
||||||
@@ -155,16 +150,13 @@ router.post('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) =
|
|||||||
const { place_id, notes } = req.body;
|
const { place_id, notes } = req.body;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
||||||
if (!day) return res.status(404).json({ error: 'Tag nicht gefunden' });
|
if (!day) return res.status(404).json({ error: 'Day not found' });
|
||||||
|
|
||||||
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId);
|
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId);
|
||||||
if (!place) return res.status(404).json({ error: 'Ort nicht gefunden' });
|
if (!place) return res.status(404).json({ error: 'Place not found' });
|
||||||
|
|
||||||
const existing = db.prepare('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?').get(dayId, place_id);
|
|
||||||
if (existing) return res.status(409).json({ error: 'Ort ist bereits diesem Tag zugewiesen' });
|
|
||||||
|
|
||||||
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId);
|
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId);
|
||||||
const orderIndex = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
const orderIndex = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||||
@@ -183,13 +175,13 @@ router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, (req,
|
|||||||
const { tripId, dayId, id } = req.params;
|
const { tripId, dayId, id } = req.params;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const assignment = db.prepare(
|
const assignment = db.prepare(
|
||||||
'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND d.trip_id = ?'
|
'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND d.trip_id = ?'
|
||||||
).get(id, dayId, tripId);
|
).get(id, dayId, tripId);
|
||||||
|
|
||||||
if (!assignment) return res.status(404).json({ error: 'Zuweisung nicht gefunden' });
|
if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
|
||||||
|
|
||||||
db.prepare('DELETE FROM day_assignments WHERE id = ?').run(id);
|
db.prepare('DELETE FROM day_assignments WHERE id = ?').run(id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
@@ -202,10 +194,10 @@ router.put('/trips/:tripId/days/:dayId/assignments/reorder', authenticate, (req,
|
|||||||
const { orderedIds } = req.body;
|
const { orderedIds } = req.body;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
||||||
if (!day) return res.status(404).json({ error: 'Tag nicht gefunden' });
|
if (!day) return res.status(404).json({ error: 'Day not found' });
|
||||||
|
|
||||||
const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?');
|
const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?');
|
||||||
db.exec('BEGIN');
|
db.exec('BEGIN');
|
||||||
@@ -228,7 +220,7 @@ router.put('/trips/:tripId/assignments/:id/move', authenticate, (req, res) => {
|
|||||||
const { new_day_id, order_index } = req.body;
|
const { new_day_id, order_index } = req.body;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const assignment = db.prepare(`
|
const assignment = db.prepare(`
|
||||||
SELECT da.* FROM day_assignments da
|
SELECT da.* FROM day_assignments da
|
||||||
@@ -236,10 +228,10 @@ router.put('/trips/:tripId/assignments/:id/move', authenticate, (req, res) => {
|
|||||||
WHERE da.id = ? AND d.trip_id = ?
|
WHERE da.id = ? AND d.trip_id = ?
|
||||||
`).get(id, tripId);
|
`).get(id, tripId);
|
||||||
|
|
||||||
if (!assignment) return res.status(404).json({ error: 'Zuweisung nicht gefunden' });
|
if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
|
||||||
|
|
||||||
const newDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(new_day_id, tripId);
|
const newDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(new_day_id, tripId);
|
||||||
if (!newDay) return res.status(404).json({ error: 'Zieltag nicht gefunden' });
|
if (!newDay) return res.status(404).json({ error: 'Target day not found' });
|
||||||
|
|
||||||
const oldDayId = assignment.day_id;
|
const oldDayId = assignment.day_id;
|
||||||
db.prepare('UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?').run(new_day_id, order_index || 0, id);
|
db.prepare('UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?').run(new_day_id, order_index || 0, id);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ router.post('/register', authLimiter, (req, res) => {
|
|||||||
|
|
||||||
res.status(201).json({ token, user: { ...user, avatar_url: null } });
|
res.status(201).json({ token, user: { ...user, avatar_url: null } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: 'Fehler beim Erstellen des Benutzers' });
|
res.status(500).json({ error: 'Error creating user' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,17 +154,17 @@ router.post('/login', authLimiter, (req, res) => {
|
|||||||
const { email, password } = req.body;
|
const { email, password } = req.body;
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
return res.status(400).json({ error: 'E-Mail und Passwort sind erforderlich' });
|
return res.status(400).json({ error: 'Email and password are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email);
|
const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(401).json({ error: 'Ungültige E-Mail oder Passwort' });
|
return res.status(401).json({ error: 'Invalid email or password' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validPassword = bcrypt.compareSync(password, user.password_hash);
|
const validPassword = bcrypt.compareSync(password, user.password_hash);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
return res.status(401).json({ error: 'Ungültige E-Mail oder Passwort' });
|
return res.status(401).json({ error: 'Invalid email or password' });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
|
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
|
||||||
@@ -181,7 +181,7 @@ router.get('/me', authenticate, (req, res) => {
|
|||||||
).get(req.user.id);
|
).get(req.user.id);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
return res.status(404).json({ error: 'User not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ user: { ...user, avatar_url: avatarUrl(user) } });
|
res.json({ user: { ...user, avatar_url: avatarUrl(user) } });
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ router.get('/list', (req, res) => {
|
|||||||
|
|
||||||
res.json({ backups: files });
|
res.json({ backups: files });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: 'Fehler beim Laden der Backups' });
|
res.status(500).json({ error: 'Error loading backups' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ router.post('/create', async (req, res) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Backup error:', err);
|
console.error('Backup error:', err);
|
||||||
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
||||||
res.status(500).json({ error: 'Fehler beim Erstellen des Backups' });
|
res.status(500).json({ error: 'Error creating backup' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ router.get('/download/:filename', (req, res) => {
|
|||||||
|
|
||||||
const filePath = path.join(backupsDir, filename);
|
const filePath = path.join(backupsDir, filename);
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
return res.status(404).json({ error: 'Backup nicht gefunden' });
|
return res.status(404).json({ error: 'Backup not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.download(filePath, filename);
|
res.download(filePath, filename);
|
||||||
@@ -132,36 +132,48 @@ async function restoreFromZip(zipPath, res) {
|
|||||||
const extractedDb = path.join(extractDir, 'travel.db');
|
const extractedDb = path.join(extractDir, 'travel.db');
|
||||||
if (!fs.existsSync(extractedDb)) {
|
if (!fs.existsSync(extractedDb)) {
|
||||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||||
return res.status(400).json({ error: 'Ungültiges Backup: travel.db nicht gefunden' });
|
return res.status(400).json({ error: 'Invalid backup: travel.db not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true });
|
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true });
|
||||||
if (!res.headersSent) res.status(500).json({ error: err.message || 'Fehler beim Wiederherstellen' });
|
if (!res.headersSent) res.status(500).json({ error: err.message || 'Error restoring backup' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +185,7 @@ router.post('/restore/:filename', async (req, res) => {
|
|||||||
}
|
}
|
||||||
const zipPath = path.join(backupsDir, filename);
|
const zipPath = path.join(backupsDir, filename);
|
||||||
if (!fs.existsSync(zipPath)) {
|
if (!fs.existsSync(zipPath)) {
|
||||||
return res.status(404).json({ error: 'Backup nicht gefunden' });
|
return res.status(404).json({ error: 'Backup not found' });
|
||||||
}
|
}
|
||||||
await restoreFromZip(zipPath, res);
|
await restoreFromZip(zipPath, res);
|
||||||
});
|
});
|
||||||
@@ -183,13 +195,13 @@ const uploadTmp = multer({
|
|||||||
dest: path.join(dataDir, 'tmp/'),
|
dest: path.join(dataDir, 'tmp/'),
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
if (file.originalname.endsWith('.zip')) cb(null, true);
|
if (file.originalname.endsWith('.zip')) cb(null, true);
|
||||||
else cb(new Error('Nur ZIP-Dateien erlaubt'));
|
else cb(new Error('Only ZIP files allowed'));
|
||||||
},
|
},
|
||||||
limits: { fileSize: 500 * 1024 * 1024 },
|
limits: { fileSize: 500 * 1024 * 1024 },
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/upload-restore', uploadTmp.single('backup'), async (req, res) => {
|
router.post('/upload-restore', uploadTmp.single('backup'), async (req, res) => {
|
||||||
if (!req.file) return res.status(400).json({ error: 'Keine Datei hochgeladen' });
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||||
const zipPath = req.file.path;
|
const zipPath = req.file.path;
|
||||||
await restoreFromZip(zipPath, res);
|
await restoreFromZip(zipPath, res);
|
||||||
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
|
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
|
||||||
@@ -223,7 +235,7 @@ router.delete('/:filename', (req, res) => {
|
|||||||
|
|
||||||
const filePath = path.join(backupsDir, filename);
|
const filePath = path.join(backupsDir, filename);
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
return res.status(404).json({ error: 'Backup nicht gefunden' });
|
return res.status(404).json({ error: 'Backup not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.unlinkSync(filePath);
|
fs.unlinkSync(filePath);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const items = db.prepare(
|
const items = db.prepare(
|
||||||
'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC'
|
'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC'
|
||||||
@@ -29,9 +29,9 @@ router.post('/', authenticate, (req, res) => {
|
|||||||
const { category, name, total_price, persons, days, note } = req.body;
|
const { category, name, total_price, persons, days, note } = req.body;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
if (!name) return res.status(400).json({ error: 'Name ist erforderlich' });
|
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||||
|
|
||||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?').get(tripId);
|
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?').get(tripId);
|
||||||
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||||
@@ -40,7 +40,7 @@ router.post('/', authenticate, (req, res) => {
|
|||||||
'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
).run(
|
).run(
|
||||||
tripId,
|
tripId,
|
||||||
category || 'Sonstiges',
|
category || 'Other',
|
||||||
name,
|
name,
|
||||||
total_price || 0,
|
total_price || 0,
|
||||||
persons != null ? persons : null,
|
persons != null ? persons : null,
|
||||||
@@ -60,10 +60,10 @@ router.put('/:id', authenticate, (req, res) => {
|
|||||||
const { category, name, total_price, persons, days, note, sort_order } = req.body;
|
const { category, name, total_price, persons, days, note, sort_order } = req.body;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!item) return res.status(404).json({ error: 'Budget-Eintrag nicht gefunden' });
|
if (!item) return res.status(404).json({ error: 'Budget item not found' });
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE budget_items SET
|
UPDATE budget_items SET
|
||||||
@@ -96,10 +96,10 @@ router.delete('/:id', authenticate, (req, res) => {
|
|||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!item) return res.status(404).json({ error: 'Budget-Eintrag nicht gefunden' });
|
if (!item) return res.status(404).json({ error: 'Budget item not found' });
|
||||||
|
|
||||||
db.prepare('DELETE FROM budget_items WHERE id = ?').run(id);
|
db.prepare('DELETE FROM budget_items WHERE id = ?').run(id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
router.post('/', authenticate, adminOnly, (req, res) => {
|
router.post('/', authenticate, adminOnly, (req, res) => {
|
||||||
const { name, color, icon } = req.body;
|
const { name, color, icon } = req.body;
|
||||||
|
|
||||||
if (!name) return res.status(400).json({ error: 'Kategoriename ist erforderlich' });
|
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||||
|
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
'INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)'
|
'INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)'
|
||||||
@@ -31,7 +31,7 @@ router.put('/:id', authenticate, adminOnly, (req, res) => {
|
|||||||
const { name, color, icon } = req.body;
|
const { name, color, icon } = req.body;
|
||||||
const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(req.params.id);
|
const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(req.params.id);
|
||||||
|
|
||||||
if (!category) return res.status(404).json({ error: 'Kategorie nicht gefunden' });
|
if (!category) return res.status(404).json({ error: 'Category not found' });
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE categories SET
|
UPDATE categories SET
|
||||||
@@ -49,7 +49,7 @@ router.put('/:id', authenticate, adminOnly, (req, res) => {
|
|||||||
router.delete('/:id', authenticate, adminOnly, (req, res) => {
|
router.delete('/:id', authenticate, adminOnly, (req, res) => {
|
||||||
const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(req.params.id);
|
const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(req.params.id);
|
||||||
|
|
||||||
if (!category) return res.status(404).json({ error: 'Kategorie nicht gefunden' });
|
if (!category) return res.status(404).json({ error: 'Category not found' });
|
||||||
|
|
||||||
db.prepare('DELETE FROM categories WHERE id = ?').run(req.params.id);
|
db.prepare('DELETE FROM categories WHERE id = ?').run(req.params.id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function verifyAccess(tripId, userId) {
|
|||||||
// GET /api/trips/:tripId/days/:dayId/notes
|
// GET /api/trips/:tripId/days/:dayId/notes
|
||||||
router.get('/', authenticate, (req, res) => {
|
router.get('/', authenticate, (req, res) => {
|
||||||
const { tripId, dayId } = req.params;
|
const { tripId, dayId } = req.params;
|
||||||
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const notes = db.prepare(
|
const notes = db.prepare(
|
||||||
'SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC'
|
'SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC'
|
||||||
@@ -24,13 +24,13 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
// POST /api/trips/:tripId/days/:dayId/notes
|
// POST /api/trips/:tripId/days/:dayId/notes
|
||||||
router.post('/', authenticate, (req, res) => {
|
router.post('/', authenticate, (req, res) => {
|
||||||
const { tripId, dayId } = req.params;
|
const { tripId, dayId } = req.params;
|
||||||
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
||||||
if (!day) return res.status(404).json({ error: 'Tag nicht gefunden' });
|
if (!day) return res.status(404).json({ error: 'Day not found' });
|
||||||
|
|
||||||
const { text, time, icon, sort_order } = req.body;
|
const { text, time, icon, sort_order } = req.body;
|
||||||
if (!text?.trim()) return res.status(400).json({ error: 'Text erforderlich' });
|
if (!text?.trim()) return res.status(400).json({ error: 'Text required' });
|
||||||
|
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
|
'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
@@ -44,10 +44,10 @@ router.post('/', authenticate, (req, res) => {
|
|||||||
// PUT /api/trips/:tripId/days/:dayId/notes/:id
|
// PUT /api/trips/:tripId/days/:dayId/notes/:id
|
||||||
router.put('/:id', authenticate, (req, res) => {
|
router.put('/:id', authenticate, (req, res) => {
|
||||||
const { tripId, dayId, id } = req.params;
|
const { tripId, dayId, id } = req.params;
|
||||||
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const note = db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId);
|
const note = db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId);
|
||||||
if (!note) return res.status(404).json({ error: 'Notiz nicht gefunden' });
|
if (!note) return res.status(404).json({ error: 'Note not found' });
|
||||||
|
|
||||||
const { text, time, icon, sort_order } = req.body;
|
const { text, time, icon, sort_order } = req.body;
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@@ -68,10 +68,10 @@ router.put('/:id', authenticate, (req, res) => {
|
|||||||
// DELETE /api/trips/:tripId/days/:dayId/notes/:id
|
// DELETE /api/trips/:tripId/days/:dayId/notes/:id
|
||||||
router.delete('/:id', authenticate, (req, res) => {
|
router.delete('/:id', authenticate, (req, res) => {
|
||||||
const { tripId, dayId, id } = req.params;
|
const { tripId, dayId, id } = req.params;
|
||||||
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const note = db.prepare('SELECT id FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId);
|
const note = db.prepare('SELECT id FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId);
|
||||||
if (!note) return res.status(404).json({ error: 'Notiz nicht gefunden' });
|
if (!note) return res.status(404).json({ error: 'Note not found' });
|
||||||
|
|
||||||
db.prepare('DELETE FROM day_notes WHERE id = ?').run(id);
|
db.prepare('DELETE FROM day_notes WHERE id = ?').run(id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function getAssignmentsForDay(dayId) {
|
|||||||
const assignments = db.prepare(`
|
const assignments = db.prepare(`
|
||||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||||
p.reservation_status, p.reservation_notes, p.reservation_datetime, p.place_time, p.duration_minutes, p.notes as place_notes,
|
p.place_time, p.end_time, p.duration_minutes, p.notes as place_notes,
|
||||||
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
||||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||||
FROM day_assignments da
|
FROM day_assignments da
|
||||||
@@ -46,10 +46,8 @@ function getAssignmentsForDay(dayId) {
|
|||||||
category_id: a.category_id,
|
category_id: a.category_id,
|
||||||
price: a.price,
|
price: a.price,
|
||||||
currency: a.place_currency,
|
currency: a.place_currency,
|
||||||
reservation_status: a.reservation_status,
|
|
||||||
reservation_notes: a.reservation_notes,
|
|
||||||
reservation_datetime: a.reservation_datetime,
|
|
||||||
place_time: a.place_time,
|
place_time: a.place_time,
|
||||||
|
end_time: a.end_time,
|
||||||
duration_minutes: a.duration_minutes,
|
duration_minutes: a.duration_minutes,
|
||||||
notes: a.place_notes,
|
notes: a.place_notes,
|
||||||
image_url: a.image_url,
|
image_url: a.image_url,
|
||||||
@@ -75,7 +73,7 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) {
|
if (!trip) {
|
||||||
return res.status(404).json({ error: 'Reise nicht gefunden' });
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId);
|
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId);
|
||||||
@@ -91,7 +89,7 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
const allAssignments = db.prepare(`
|
const allAssignments = db.prepare(`
|
||||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||||
p.reservation_status, p.reservation_notes, p.reservation_datetime, p.place_time, p.duration_minutes, p.notes as place_notes,
|
p.place_time, p.end_time, p.duration_minutes, p.notes as place_notes,
|
||||||
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
||||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||||
FROM day_assignments da
|
FROM day_assignments da
|
||||||
@@ -137,10 +135,8 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
category_id: a.category_id,
|
category_id: a.category_id,
|
||||||
price: a.price,
|
price: a.price,
|
||||||
currency: a.place_currency,
|
currency: a.place_currency,
|
||||||
reservation_status: a.reservation_status,
|
|
||||||
reservation_notes: a.reservation_notes,
|
|
||||||
reservation_datetime: a.reservation_datetime,
|
|
||||||
place_time: a.place_time,
|
place_time: a.place_time,
|
||||||
|
end_time: a.end_time,
|
||||||
duration_minutes: a.duration_minutes,
|
duration_minutes: a.duration_minutes,
|
||||||
notes: a.place_notes,
|
notes: a.place_notes,
|
||||||
image_url: a.image_url,
|
image_url: a.image_url,
|
||||||
@@ -184,7 +180,7 @@ router.post('/', authenticate, (req, res) => {
|
|||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) {
|
if (!trip) {
|
||||||
return res.status(404).json({ error: 'Reise nicht gefunden' });
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { date, notes } = req.body;
|
const { date, notes } = req.body;
|
||||||
@@ -209,12 +205,12 @@ router.put('/:id', authenticate, (req, res) => {
|
|||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) {
|
if (!trip) {
|
||||||
return res.status(404).json({ error: 'Reise nicht gefunden' });
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!day) {
|
if (!day) {
|
||||||
return res.status(404).json({ error: 'Tag nicht gefunden' });
|
return res.status(404).json({ error: 'Day not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { notes, title } = req.body;
|
const { notes, title } = req.body;
|
||||||
@@ -232,12 +228,12 @@ router.delete('/:id', authenticate, (req, res) => {
|
|||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) {
|
if (!trip) {
|
||||||
return res.status(404).json({ error: 'Reise nicht gefunden' });
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!day) {
|
if (!day) {
|
||||||
return res.status(404).json({ error: 'Tag nicht gefunden' });
|
return res.status(404).json({ error: 'Day not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare('DELETE FROM days WHERE id = ?').run(id);
|
db.prepare('DELETE FROM days WHERE id = ?').run(id);
|
||||||
@@ -245,4 +241,149 @@ router.delete('/:id', authenticate, (req, res) => {
|
|||||||
broadcast(tripId, 'day:deleted', { dayId: Number(id) }, req.headers['x-socket-id']);
|
broadcast(tripId, 'day:deleted', { dayId: Number(id) }, req.headers['x-socket-id']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// === Accommodation routes ===
|
||||||
|
const accommodationsRouter = express.Router({ mergeParams: true });
|
||||||
|
|
||||||
|
function getAccommodationWithPlace(id) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng
|
||||||
|
FROM day_accommodations a
|
||||||
|
JOIN places p ON a.place_id = p.id
|
||||||
|
WHERE a.id = ?
|
||||||
|
`).get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/trips/:tripId/accommodations
|
||||||
|
accommodationsRouter.get('/', authenticate, (req, res) => {
|
||||||
|
const { tripId } = req.params;
|
||||||
|
|
||||||
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
|
if (!trip) {
|
||||||
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const accommodations = db.prepare(`
|
||||||
|
SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng
|
||||||
|
FROM day_accommodations a
|
||||||
|
JOIN places p ON a.place_id = p.id
|
||||||
|
WHERE a.trip_id = ?
|
||||||
|
ORDER BY a.created_at ASC
|
||||||
|
`).all(tripId);
|
||||||
|
|
||||||
|
res.json({ accommodations });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/trips/:tripId/accommodations
|
||||||
|
accommodationsRouter.post('/', authenticate, (req, res) => {
|
||||||
|
const { tripId } = req.params;
|
||||||
|
|
||||||
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
|
if (!trip) {
|
||||||
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = req.body;
|
||||||
|
|
||||||
|
if (!place_id || !start_day_id || !end_day_id) {
|
||||||
|
return res.status(400).json({ error: 'place_id, start_day_id, and end_day_id are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId);
|
||||||
|
if (!place) {
|
||||||
|
return res.status(404).json({ error: 'Place not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(start_day_id, tripId);
|
||||||
|
if (!startDay) {
|
||||||
|
return res.status(404).json({ error: 'Start day not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(end_day_id, tripId);
|
||||||
|
if (!endDay) {
|
||||||
|
return res.status(404).json({ error: 'End day not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_out || null, confirmation || null, notes || null);
|
||||||
|
|
||||||
|
const accommodation = getAccommodationWithPlace(result.lastInsertRowid);
|
||||||
|
res.status(201).json({ accommodation });
|
||||||
|
broadcast(tripId, 'accommodation:created', { accommodation }, req.headers['x-socket-id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/trips/:tripId/accommodations/:id
|
||||||
|
accommodationsRouter.put('/:id', authenticate, (req, res) => {
|
||||||
|
const { tripId, id } = req.params;
|
||||||
|
|
||||||
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
|
if (!trip) {
|
||||||
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'Accommodation not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = req.body;
|
||||||
|
|
||||||
|
const newPlaceId = place_id !== undefined ? place_id : existing.place_id;
|
||||||
|
const newStartDayId = start_day_id !== undefined ? start_day_id : existing.start_day_id;
|
||||||
|
const newEndDayId = end_day_id !== undefined ? end_day_id : existing.end_day_id;
|
||||||
|
const newCheckIn = check_in !== undefined ? check_in : existing.check_in;
|
||||||
|
const newCheckOut = check_out !== undefined ? check_out : existing.check_out;
|
||||||
|
const newConfirmation = confirmation !== undefined ? confirmation : existing.confirmation;
|
||||||
|
const newNotes = notes !== undefined ? notes : existing.notes;
|
||||||
|
|
||||||
|
if (place_id !== undefined) {
|
||||||
|
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId);
|
||||||
|
if (!place) {
|
||||||
|
return res.status(404).json({ error: 'Place not found' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start_day_id !== undefined) {
|
||||||
|
const startDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(start_day_id, tripId);
|
||||||
|
if (!startDay) {
|
||||||
|
return res.status(404).json({ error: 'Start day not found' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end_day_id !== undefined) {
|
||||||
|
const endDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(end_day_id, tripId);
|
||||||
|
if (!endDay) {
|
||||||
|
return res.status(404).json({ error: 'End day not found' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?'
|
||||||
|
).run(newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckOut, newConfirmation, newNotes, id);
|
||||||
|
|
||||||
|
const accommodation = getAccommodationWithPlace(id);
|
||||||
|
res.json({ accommodation });
|
||||||
|
broadcast(tripId, 'accommodation:updated', { accommodation }, req.headers['x-socket-id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/trips/:tripId/accommodations/:id
|
||||||
|
accommodationsRouter.delete('/:id', authenticate, (req, res) => {
|
||||||
|
const { tripId, id } = req.params;
|
||||||
|
|
||||||
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
|
if (!trip) {
|
||||||
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'Accommodation not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(id);
|
||||||
|
res.json({ success: true });
|
||||||
|
broadcast(tripId, 'accommodation:deleted', { accommodationId: Number(id) }, req.headers['x-socket-id']);
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
module.exports.accommodationsRouter = accommodationsRouter;
|
||||||
|
|||||||
@@ -35,10 +35,15 @@ 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 {
|
||||||
cb(new Error('Dateityp nicht erlaubt'));
|
cb(new Error('File type not allowed'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -59,7 +64,7 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const files = db.prepare(`
|
const files = db.prepare(`
|
||||||
SELECT f.*, r.title as reservation_title
|
SELECT f.*, r.title as reservation_title
|
||||||
@@ -79,11 +84,11 @@ router.post('/', authenticate, demoUploadBlock, upload.single('file'), (req, res
|
|||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) {
|
if (!trip) {
|
||||||
if (req.file) fs.unlinkSync(req.file.path);
|
if (req.file) fs.unlinkSync(req.file.path);
|
||||||
return res.status(404).json({ error: 'Reise nicht gefunden' });
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ error: 'Keine Datei hochgeladen' });
|
return res.status(400).json({ error: 'No file uploaded' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
@@ -116,10 +121,10 @@ router.put('/:id', authenticate, (req, res) => {
|
|||||||
const { description, place_id, reservation_id } = req.body;
|
const { description, place_id, reservation_id } = req.body;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
if (!file) return res.status(404).json({ error: 'File not found' });
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE trip_files SET
|
UPDATE trip_files SET
|
||||||
@@ -149,10 +154,10 @@ router.delete('/:id', authenticate, (req, res) => {
|
|||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
if (!file) return res.status(404).json({ error: 'File not found' });
|
||||||
|
|
||||||
const filePath = path.join(filesDir, file.filename);
|
const filePath = path.join(filesDir, file.filename);
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ async function searchNominatim(query, lang) {
|
|||||||
router.post('/search', authenticate, async (req, res) => {
|
router.post('/search', authenticate, async (req, res) => {
|
||||||
const { query } = req.body;
|
const { query } = req.body;
|
||||||
|
|
||||||
if (!query) return res.status(400).json({ error: 'Suchanfrage ist erforderlich' });
|
if (!query) return res.status(400).json({ error: 'Search query is required' });
|
||||||
|
|
||||||
const apiKey = getMapsKey(req.user.id);
|
const apiKey = getMapsKey(req.user.id);
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ router.post('/search', authenticate, async (req, res) => {
|
|||||||
return res.json({ places, source: 'openstreetmap' });
|
return res.json({ places, source: 'openstreetmap' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Nominatim search error:', err);
|
console.error('Nominatim search error:', err);
|
||||||
return res.status(500).json({ error: 'Fehler bei der OpenStreetMap Suche' });
|
return res.status(500).json({ error: 'OpenStreetMap search error' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ router.post('/search', authenticate, async (req, res) => {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return res.status(response.status).json({ error: data.error?.message || 'Google Places API Fehler' });
|
return res.status(response.status).json({ error: data.error?.message || 'Google Places API error' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const places = (data.places || []).map(p => ({
|
const places = (data.places || []).map(p => ({
|
||||||
@@ -96,7 +96,7 @@ router.post('/search', authenticate, async (req, res) => {
|
|||||||
res.json({ places, source: 'google' });
|
res.json({ places, source: 'google' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Maps search error:', err);
|
console.error('Maps search error:', err);
|
||||||
res.status(500).json({ error: 'Fehler bei der Google Places Suche' });
|
res.status(500).json({ error: 'Google Places search error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ router.get('/details/:placeId', authenticate, async (req, res) => {
|
|||||||
|
|
||||||
const apiKey = getMapsKey(req.user.id);
|
const apiKey = getMapsKey(req.user.id);
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return res.status(400).json({ error: 'Google Maps API-Schlüssel nicht konfiguriert' });
|
return res.status(400).json({ error: 'Google Maps API key not configured' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -122,7 +122,7 @@ router.get('/details/:placeId', authenticate, async (req, res) => {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return res.status(response.status).json({ error: data.error?.message || 'Google Places API Fehler' });
|
return res.status(response.status).json({ error: data.error?.message || 'Google Places API error' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const place = {
|
const place = {
|
||||||
@@ -151,7 +151,7 @@ router.get('/details/:placeId', authenticate, async (req, res) => {
|
|||||||
res.json({ place });
|
res.json({ place });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Maps details error:', err);
|
console.error('Maps details error:', err);
|
||||||
res.status(500).json({ error: 'Fehler beim Abrufen der Ortsdetails' });
|
res.status(500).json({ error: 'Error fetching place details' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ router.get('/place-photo/:placeId', authenticate, async (req, res) => {
|
|||||||
|
|
||||||
const apiKey = getMapsKey(req.user.id);
|
const apiKey = getMapsKey(req.user.id);
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return res.status(400).json({ error: 'Google Maps API-Schlüssel nicht konfiguriert' });
|
return res.status(400).json({ error: 'Google Maps API key not configured' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -183,11 +183,11 @@ router.get('/place-photo/:placeId', authenticate, async (req, res) => {
|
|||||||
|
|
||||||
if (!detailsRes.ok) {
|
if (!detailsRes.ok) {
|
||||||
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
|
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
|
||||||
return res.status(404).json({ error: 'Foto konnte nicht abgerufen werden' });
|
return res.status(404).json({ error: 'Photo could not be retrieved' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!details.photos?.length) {
|
if (!details.photos?.length) {
|
||||||
return res.status(404).json({ error: 'Kein Foto verfügbar' });
|
return res.status(404).json({ error: 'No photo available' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const photo = details.photos[0];
|
const photo = details.photos[0];
|
||||||
@@ -202,7 +202,7 @@ router.get('/place-photo/:placeId', authenticate, async (req, res) => {
|
|||||||
const photoUrl = mediaData.photoUri;
|
const photoUrl = mediaData.photoUri;
|
||||||
|
|
||||||
if (!photoUrl) {
|
if (!photoUrl) {
|
||||||
return res.status(404).json({ error: 'Foto-URL nicht verfügbar' });
|
return res.status(404).json({ error: 'Photo URL not available' });
|
||||||
}
|
}
|
||||||
|
|
||||||
photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() });
|
photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() });
|
||||||
@@ -220,7 +220,7 @@ router.get('/place-photo/:placeId', authenticate, async (req, res) => {
|
|||||||
res.json({ photoUrl, attribution });
|
res.json({ photoUrl, attribution });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Place photo error:', err);
|
console.error('Place photo error:', err);
|
||||||
res.status(500).json({ error: 'Fehler beim Abrufen des Fotos' });
|
res.status(500).json({ error: 'Error fetching photo' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const items = db.prepare(
|
const items = db.prepare(
|
||||||
'SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC'
|
'SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC'
|
||||||
@@ -29,9 +29,9 @@ router.post('/', authenticate, (req, res) => {
|
|||||||
const { name, category, checked } = req.body;
|
const { name, category, checked } = req.body;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
if (!name) return res.status(400).json({ error: 'Artikelname ist erforderlich' });
|
if (!name) return res.status(400).json({ error: 'Item name is required' });
|
||||||
|
|
||||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId);
|
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId);
|
||||||
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||||
@@ -51,10 +51,10 @@ router.put('/:id', authenticate, (req, res) => {
|
|||||||
const { name, checked, category } = req.body;
|
const { name, checked, category } = req.body;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const item = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const item = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!item) return res.status(404).json({ error: 'Artikel nicht gefunden' });
|
if (!item) return res.status(404).json({ error: 'Item not found' });
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE packing_items SET
|
UPDATE packing_items SET
|
||||||
@@ -80,10 +80,10 @@ router.delete('/:id', authenticate, (req, res) => {
|
|||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!item) return res.status(404).json({ error: 'Artikel nicht gefunden' });
|
if (!item) return res.status(404).json({ error: 'Item not found' });
|
||||||
|
|
||||||
db.prepare('DELETE FROM packing_items WHERE id = ?').run(id);
|
db.prepare('DELETE FROM packing_items WHERE id = ?').run(id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
@@ -96,7 +96,7 @@ router.put('/reorder', authenticate, (req, res) => {
|
|||||||
const { orderedIds } = req.body;
|
const { orderedIds } = req.body;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const update = db.prepare('UPDATE packing_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
|
const update = db.prepare('UPDATE packing_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
|
||||||
const updateMany = db.transaction((ids) => {
|
const updateMany = db.transaction((ids) => {
|
||||||
|
|||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -46,7 +48,7 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
const { day_id, place_id } = req.query;
|
const { day_id, place_id } = req.query;
|
||||||
|
|
||||||
const trip = canAccessTrip(tripId, req.user.id);
|
const trip = canAccessTrip(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
let query = 'SELECT * FROM photos WHERE trip_id = ?';
|
let query = 'SELECT * FROM photos WHERE trip_id = ?';
|
||||||
const params = [tripId];
|
const params = [tripId];
|
||||||
@@ -76,11 +78,11 @@ router.post('/', authenticate, demoUploadBlock, upload.array('photos', 20), (req
|
|||||||
if (!trip) {
|
if (!trip) {
|
||||||
// Delete uploaded files on auth failure
|
// Delete uploaded files on auth failure
|
||||||
if (req.files) req.files.forEach(f => fs.unlinkSync(f.path));
|
if (req.files) req.files.forEach(f => fs.unlinkSync(f.path));
|
||||||
return res.status(404).json({ error: 'Reise nicht gefunden' });
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.files || req.files.length === 0) {
|
if (!req.files || req.files.length === 0) {
|
||||||
return res.status(400).json({ error: 'Keine Dateien hochgeladen' });
|
return res.status(400).json({ error: 'No files uploaded' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertPhoto = db.prepare(`
|
const insertPhoto = db.prepare(`
|
||||||
@@ -120,10 +122,10 @@ router.put('/:id', authenticate, (req, res) => {
|
|||||||
const { caption, day_id, place_id } = req.body;
|
const { caption, day_id, place_id } = req.body;
|
||||||
|
|
||||||
const trip = canAccessTrip(tripId, req.user.id);
|
const trip = canAccessTrip(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const photo = db.prepare('SELECT * FROM photos WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const photo = db.prepare('SELECT * FROM photos WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!photo) return res.status(404).json({ error: 'Foto nicht gefunden' });
|
if (!photo) return res.status(404).json({ error: 'Photo not found' });
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE photos SET
|
UPDATE photos SET
|
||||||
@@ -147,10 +149,10 @@ router.delete('/:id', authenticate, (req, res) => {
|
|||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params;
|
||||||
|
|
||||||
const trip = canAccessTrip(tripId, req.user.id);
|
const trip = canAccessTrip(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const photo = db.prepare('SELECT * FROM photos WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const photo = db.prepare('SELECT * FROM photos WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!photo) return res.status(404).json({ error: 'Foto nicht gefunden' });
|
if (!photo) return res.status(404).json({ error: 'Photo not found' });
|
||||||
|
|
||||||
// Delete file
|
// Delete file
|
||||||
const filePath = path.join(photosDir, photo.filename);
|
const filePath = path.join(photosDir, photo.filename);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) {
|
if (!trip) {
|
||||||
return res.status(404).json({ error: 'Reise nicht gefunden' });
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = `
|
let query = `
|
||||||
@@ -89,30 +89,29 @@ router.post('/', authenticate, (req, res) => {
|
|||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) {
|
if (!trip) {
|
||||||
return res.status(404).json({ error: 'Reise nicht gefunden' });
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
name, description, lat, lng, address, category_id, price, currency,
|
name, description, lat, lng, address, category_id, price, currency,
|
||||||
reservation_status, reservation_notes, reservation_datetime, place_time,
|
place_time, end_time,
|
||||||
duration_minutes, notes, image_url, google_place_id, website, phone,
|
duration_minutes, notes, image_url, google_place_id, website, phone,
|
||||||
transport_mode, tags = []
|
transport_mode, tags = []
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return res.status(400).json({ error: 'Ortsname ist erforderlich' });
|
return res.status(400).json({ error: 'Place name is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
|
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
|
||||||
reservation_status, reservation_notes, reservation_datetime, place_time,
|
place_time, end_time,
|
||||||
duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode)
|
duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
tripId, name, description || null, lat || null, lng || null, address || null,
|
tripId, name, description || null, lat || null, lng || null, address || null,
|
||||||
category_id || null, price || null, currency || null,
|
category_id || null, price || null, currency || null,
|
||||||
reservation_status || 'none', reservation_notes || null, reservation_datetime || null,
|
place_time || null, end_time || null, duration_minutes || 60, notes || null, image_url || null,
|
||||||
place_time || null, duration_minutes || 60, notes || null, image_url || null,
|
|
||||||
google_place_id || null, website || null, phone || null, transport_mode || 'walking'
|
google_place_id || null, website || null, phone || null, transport_mode || 'walking'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -136,12 +135,12 @@ router.get('/:id', authenticate, (req, res) => {
|
|||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) {
|
if (!trip) {
|
||||||
return res.status(404).json({ error: 'Reise nicht gefunden' });
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!placeCheck) {
|
if (!placeCheck) {
|
||||||
return res.status(404).json({ error: 'Ort nicht gefunden' });
|
return res.status(404).json({ error: 'Place not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const place = getPlaceWithTags(id);
|
const place = getPlaceWithTags(id);
|
||||||
@@ -154,17 +153,17 @@ router.get('/:id/image', authenticate, async (req, res) => {
|
|||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) {
|
if (!trip) {
|
||||||
return res.status(404).json({ error: 'Reise nicht gefunden' });
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!place) {
|
if (!place) {
|
||||||
return res.status(404).json({ error: 'Ort nicht gefunden' });
|
return res.status(404).json({ error: 'Place not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = db.prepare('SELECT unsplash_api_key FROM users WHERE id = ?').get(req.user.id);
|
const user = db.prepare('SELECT unsplash_api_key FROM users WHERE id = ?').get(req.user.id);
|
||||||
if (!user || !user.unsplash_api_key) {
|
if (!user || !user.unsplash_api_key) {
|
||||||
return res.status(400).json({ error: 'Kein Unsplash API-Schlüssel konfiguriert' });
|
return res.status(400).json({ error: 'No Unsplash API key configured' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -175,7 +174,7 @@ router.get('/:id/image', authenticate, async (req, res) => {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return res.status(response.status).json({ error: data.errors?.[0] || 'Unsplash API Fehler' });
|
return res.status(response.status).json({ error: data.errors?.[0] || 'Unsplash API error' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const photos = (data.results || []).map(p => ({
|
const photos = (data.results || []).map(p => ({
|
||||||
@@ -190,7 +189,7 @@ router.get('/:id/image', authenticate, async (req, res) => {
|
|||||||
res.json({ photos });
|
res.json({ photos });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Unsplash error:', err);
|
console.error('Unsplash error:', err);
|
||||||
res.status(500).json({ error: 'Fehler beim Suchen des Bildes' });
|
res.status(500).json({ error: 'Error searching for image' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,17 +199,17 @@ router.put('/:id', authenticate, (req, res) => {
|
|||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) {
|
if (!trip) {
|
||||||
return res.status(404).json({ error: 'Reise nicht gefunden' });
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!existingPlace) {
|
if (!existingPlace) {
|
||||||
return res.status(404).json({ error: 'Ort nicht gefunden' });
|
return res.status(404).json({ error: 'Place not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
name, description, lat, lng, address, category_id, price, currency,
|
name, description, lat, lng, address, category_id, price, currency,
|
||||||
reservation_status, reservation_notes, reservation_datetime, place_time,
|
place_time, end_time,
|
||||||
duration_minutes, notes, image_url, google_place_id, website, phone,
|
duration_minutes, notes, image_url, google_place_id, website, phone,
|
||||||
transport_mode, tags
|
transport_mode, tags
|
||||||
} = req.body;
|
} = req.body;
|
||||||
@@ -225,10 +224,8 @@ router.put('/:id', authenticate, (req, res) => {
|
|||||||
category_id = ?,
|
category_id = ?,
|
||||||
price = ?,
|
price = ?,
|
||||||
currency = COALESCE(?, currency),
|
currency = COALESCE(?, currency),
|
||||||
reservation_status = COALESCE(?, reservation_status),
|
|
||||||
reservation_notes = ?,
|
|
||||||
reservation_datetime = ?,
|
|
||||||
place_time = ?,
|
place_time = ?,
|
||||||
|
end_time = ?,
|
||||||
duration_minutes = COALESCE(?, duration_minutes),
|
duration_minutes = COALESCE(?, duration_minutes),
|
||||||
notes = ?,
|
notes = ?,
|
||||||
image_url = ?,
|
image_url = ?,
|
||||||
@@ -247,10 +244,8 @@ router.put('/:id', authenticate, (req, res) => {
|
|||||||
category_id !== undefined ? category_id : existingPlace.category_id,
|
category_id !== undefined ? category_id : existingPlace.category_id,
|
||||||
price !== undefined ? price : existingPlace.price,
|
price !== undefined ? price : existingPlace.price,
|
||||||
currency || null,
|
currency || null,
|
||||||
reservation_status || null,
|
|
||||||
reservation_notes !== undefined ? reservation_notes : existingPlace.reservation_notes,
|
|
||||||
reservation_datetime !== undefined ? reservation_datetime : existingPlace.reservation_datetime,
|
|
||||||
place_time !== undefined ? place_time : existingPlace.place_time,
|
place_time !== undefined ? place_time : existingPlace.place_time,
|
||||||
|
end_time !== undefined ? end_time : existingPlace.end_time,
|
||||||
duration_minutes || null,
|
duration_minutes || null,
|
||||||
notes !== undefined ? notes : existingPlace.notes,
|
notes !== undefined ? notes : existingPlace.notes,
|
||||||
image_url !== undefined ? image_url : existingPlace.image_url,
|
image_url !== undefined ? image_url : existingPlace.image_url,
|
||||||
@@ -282,12 +277,12 @@ router.delete('/:id', authenticate, (req, res) => {
|
|||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) {
|
if (!trip) {
|
||||||
return res.status(404).json({ error: 'Reise nicht gefunden' });
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!place) {
|
if (!place) {
|
||||||
return res.status(404).json({ error: 'Ort nicht gefunden' });
|
return res.status(404).json({ error: 'Place not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare('DELETE FROM places WHERE id = ?').run(id);
|
db.prepare('DELETE FROM places WHERE id = ?').run(id);
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const reservations = db.prepare(`
|
const reservations = db.prepare(`
|
||||||
SELECT r.*, d.day_number, p.name as place_name
|
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id
|
||||||
FROM reservations r
|
FROM reservations r
|
||||||
LEFT JOIN days d ON r.day_id = d.id
|
LEFT JOIN days d ON r.day_id = d.id
|
||||||
LEFT JOIN places p ON r.place_id = p.id
|
LEFT JOIN places p ON r.place_id = p.id
|
||||||
@@ -31,20 +31,21 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
// POST /api/trips/:tripId/reservations
|
// POST /api/trips/:tripId/reservations
|
||||||
router.post('/', authenticate, (req, res) => {
|
router.post('/', authenticate, (req, res) => {
|
||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
const { title, reservation_time, location, confirmation_number, notes, day_id, place_id, status, type } = req.body;
|
const { title, reservation_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
if (!title) return res.status(400).json({ error: 'Titel ist erforderlich' });
|
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO reservations (trip_id, day_id, place_id, title, reservation_time, location, confirmation_number, notes, status, type)
|
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, location, confirmation_number, notes, status, type)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
tripId,
|
tripId,
|
||||||
day_id || null,
|
day_id || null,
|
||||||
place_id || null,
|
place_id || null,
|
||||||
|
assignment_id || null,
|
||||||
title,
|
title,
|
||||||
reservation_time || null,
|
reservation_time || null,
|
||||||
location || null,
|
location || null,
|
||||||
@@ -55,7 +56,7 @@ router.post('/', authenticate, (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const reservation = db.prepare(`
|
const reservation = db.prepare(`
|
||||||
SELECT r.*, d.day_number, p.name as place_name
|
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id
|
||||||
FROM reservations r
|
FROM reservations r
|
||||||
LEFT JOIN days d ON r.day_id = d.id
|
LEFT JOIN days d ON r.day_id = d.id
|
||||||
LEFT JOIN places p ON r.place_id = p.id
|
LEFT JOIN places p ON r.place_id = p.id
|
||||||
@@ -69,13 +70,13 @@ router.post('/', authenticate, (req, res) => {
|
|||||||
// PUT /api/trips/:tripId/reservations/:id
|
// PUT /api/trips/:tripId/reservations/:id
|
||||||
router.put('/:id', authenticate, (req, res) => {
|
router.put('/:id', authenticate, (req, res) => {
|
||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params;
|
||||||
const { title, reservation_time, location, confirmation_number, notes, day_id, place_id, status, type } = req.body;
|
const { title, reservation_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!reservation) return res.status(404).json({ error: 'Reservierung nicht gefunden' });
|
if (!reservation) return res.status(404).json({ error: 'Reservation not found' });
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE reservations SET
|
UPDATE reservations SET
|
||||||
@@ -86,6 +87,7 @@ router.put('/:id', authenticate, (req, res) => {
|
|||||||
notes = ?,
|
notes = ?,
|
||||||
day_id = ?,
|
day_id = ?,
|
||||||
place_id = ?,
|
place_id = ?,
|
||||||
|
assignment_id = ?,
|
||||||
status = COALESCE(?, status),
|
status = COALESCE(?, status),
|
||||||
type = COALESCE(?, type)
|
type = COALESCE(?, type)
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
@@ -97,13 +99,14 @@ router.put('/:id', authenticate, (req, res) => {
|
|||||||
notes !== undefined ? (notes || null) : reservation.notes,
|
notes !== undefined ? (notes || null) : reservation.notes,
|
||||||
day_id !== undefined ? (day_id || null) : reservation.day_id,
|
day_id !== undefined ? (day_id || null) : reservation.day_id,
|
||||||
place_id !== undefined ? (place_id || null) : reservation.place_id,
|
place_id !== undefined ? (place_id || null) : reservation.place_id,
|
||||||
|
assignment_id !== undefined ? (assignment_id || null) : reservation.assignment_id,
|
||||||
status || null,
|
status || null,
|
||||||
type || null,
|
type || null,
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
|
|
||||||
const updated = db.prepare(`
|
const updated = db.prepare(`
|
||||||
SELECT r.*, d.day_number, p.name as place_name
|
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id
|
||||||
FROM reservations r
|
FROM reservations r
|
||||||
LEFT JOIN days d ON r.day_id = d.id
|
LEFT JOIN days d ON r.day_id = d.id
|
||||||
LEFT JOIN places p ON r.place_id = p.id
|
LEFT JOIN places p ON r.place_id = p.id
|
||||||
@@ -119,10 +122,10 @@ router.delete('/:id', authenticate, (req, res) => {
|
|||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const reservation = db.prepare('SELECT id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const reservation = db.prepare('SELECT id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!reservation) return res.status(404).json({ error: 'Reservierung nicht gefunden' });
|
if (!reservation) return res.status(404).json({ error: 'Reservation not found' });
|
||||||
|
|
||||||
db.prepare('DELETE FROM reservations WHERE id = ?').run(id);
|
db.prepare('DELETE FROM reservations WHERE id = ?').run(id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
router.put('/', authenticate, (req, res) => {
|
router.put('/', authenticate, (req, res) => {
|
||||||
const { key, value } = req.body;
|
const { key, value } = req.body;
|
||||||
|
|
||||||
if (!key) return res.status(400).json({ error: 'Schlüssel ist erforderlich' });
|
if (!key) return res.status(400).json({ error: 'Key is required' });
|
||||||
|
|
||||||
const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
|
const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ router.post('/bulk', authenticate, (req, res) => {
|
|||||||
const { settings } = req.body;
|
const { settings } = req.body;
|
||||||
|
|
||||||
if (!settings || typeof settings !== 'object') {
|
if (!settings || typeof settings !== 'object') {
|
||||||
return res.status(400).json({ error: 'Einstellungen-Objekt ist erforderlich' });
|
return res.status(400).json({ error: 'Settings object is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const upsert = db.prepare(`
|
const upsert = db.prepare(`
|
||||||
@@ -56,7 +56,7 @@ router.post('/bulk', authenticate, (req, res) => {
|
|||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
db.exec('ROLLBACK');
|
db.exec('ROLLBACK');
|
||||||
return res.status(500).json({ error: 'Fehler beim Speichern der Einstellungen', detail: err.message });
|
return res.status(500).json({ error: 'Error saving settings', detail: err.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, updated: Object.keys(settings).length });
|
res.json({ success: true, updated: Object.keys(settings).length });
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
router.post('/', authenticate, (req, res) => {
|
router.post('/', authenticate, (req, res) => {
|
||||||
const { name, color } = req.body;
|
const { name, color } = req.body;
|
||||||
|
|
||||||
if (!name) return res.status(400).json({ error: 'Tag-Name ist erforderlich' });
|
if (!name) return res.status(400).json({ error: 'Tag name is required' });
|
||||||
|
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
'INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)'
|
'INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)'
|
||||||
@@ -31,7 +31,7 @@ router.put('/:id', authenticate, (req, res) => {
|
|||||||
const { name, color } = req.body;
|
const { name, color } = req.body;
|
||||||
const tag = db.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
const tag = db.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
||||||
|
|
||||||
if (!tag) return res.status(404).json({ error: 'Tag nicht gefunden' });
|
if (!tag) return res.status(404).json({ error: 'Tag not found' });
|
||||||
|
|
||||||
db.prepare('UPDATE tags SET name = COALESCE(?, name), color = COALESCE(?, color) WHERE id = ?')
|
db.prepare('UPDATE tags SET name = COALESCE(?, name), color = COALESCE(?, color) WHERE id = ?')
|
||||||
.run(name || null, color || null, req.params.id);
|
.run(name || null, color || null, req.params.id);
|
||||||
@@ -43,7 +43,7 @@ router.put('/:id', authenticate, (req, res) => {
|
|||||||
// DELETE /api/tags/:id
|
// DELETE /api/tags/:id
|
||||||
router.delete('/:id', authenticate, (req, res) => {
|
router.delete('/:id', authenticate, (req, res) => {
|
||||||
const tag = db.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
const tag = db.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
||||||
if (!tag) return res.status(404).json({ error: 'Tag nicht gefunden' });
|
if (!tag) return res.status(404).json({ error: 'Tag not found' });
|
||||||
|
|
||||||
db.prepare('DELETE FROM tags WHERE id = ?').run(req.params.id);
|
db.prepare('DELETE FROM tags WHERE id = ?').run(req.params.id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,9 +79,9 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
// POST /api/trips
|
// POST /api/trips
|
||||||
router.post('/', authenticate, (req, res) => {
|
router.post('/', authenticate, (req, res) => {
|
||||||
const { title, description, start_date, end_date, currency } = req.body;
|
const { title, description, start_date, end_date, currency } = req.body;
|
||||||
if (!title) return res.status(400).json({ error: 'Titel ist erforderlich' });
|
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||||
if (start_date && end_date && new Date(end_date) < new Date(start_date))
|
if (start_date && end_date && new Date(end_date) < new Date(start_date))
|
||||||
return res.status(400).json({ error: 'Enddatum muss nach dem Startdatum liegen' });
|
return res.status(400).json({ error: 'End date must be after start date' });
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO trips (user_id, title, description, start_date, end_date, currency)
|
INSERT INTO trips (user_id, title, description, start_date, end_date, currency)
|
||||||
@@ -97,24 +102,24 @@ router.get('/:id', authenticate, (req, res) => {
|
|||||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
|
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
|
||||||
WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
|
WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
|
||||||
`).get({ userId, tripId: req.params.id });
|
`).get({ userId, tripId: req.params.id });
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
res.json({ trip });
|
res.json({ trip });
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/trips/:id — all members can edit; archive/cover owner-only
|
// PUT /api/trips/:id — all members can edit; archive/cover owner-only
|
||||||
router.put('/:id', authenticate, (req, res) => {
|
router.put('/:id', authenticate, (req, res) => {
|
||||||
const access = canAccessTrip(req.params.id, req.user.id);
|
const access = canAccessTrip(req.params.id, req.user.id);
|
||||||
if (!access) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const ownerOnly = req.body.is_archived !== undefined || req.body.cover_image !== undefined;
|
const ownerOnly = req.body.is_archived !== undefined || req.body.cover_image !== undefined;
|
||||||
if (ownerOnly && !isOwner(req.params.id, req.user.id))
|
if (ownerOnly && !isOwner(req.params.id, req.user.id))
|
||||||
return res.status(403).json({ error: 'Nur der Eigentümer kann diese Einstellung ändern' });
|
return res.status(403).json({ error: 'Only the owner can change this setting' });
|
||||||
|
|
||||||
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id);
|
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id);
|
||||||
const { title, description, start_date, end_date, currency, is_archived, cover_image } = req.body;
|
const { title, description, start_date, end_date, currency, is_archived, cover_image } = req.body;
|
||||||
|
|
||||||
if (start_date && end_date && new Date(end_date) < new Date(start_date))
|
if (start_date && end_date && new Date(end_date) < new Date(start_date))
|
||||||
return res.status(400).json({ error: 'Enddatum muss nach dem Startdatum liegen' });
|
return res.status(400).json({ error: 'End date must be after start date' });
|
||||||
|
|
||||||
const newTitle = title || trip.title;
|
const newTitle = title || trip.title;
|
||||||
const newDesc = description !== undefined ? description : trip.description;
|
const newDesc = description !== undefined ? description : trip.description;
|
||||||
@@ -141,11 +146,11 @@ router.put('/:id', authenticate, (req, res) => {
|
|||||||
// POST /api/trips/:id/cover
|
// POST /api/trips/:id/cover
|
||||||
router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cover'), (req, res) => {
|
router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cover'), (req, res) => {
|
||||||
if (!isOwner(req.params.id, req.user.id))
|
if (!isOwner(req.params.id, req.user.id))
|
||||||
return res.status(403).json({ error: 'Nur der Eigentümer kann das Titelbild ändern' });
|
return res.status(403).json({ error: 'Only the owner can change the cover image' });
|
||||||
|
|
||||||
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id);
|
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
if (!req.file) return res.status(400).json({ error: 'Kein Bild hochgeladen' });
|
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
|
||||||
|
|
||||||
if (trip.cover_image) {
|
if (trip.cover_image) {
|
||||||
const oldPath = path.join(__dirname, '../../', trip.cover_image.replace(/^\//, ''));
|
const oldPath = path.join(__dirname, '../../', trip.cover_image.replace(/^\//, ''));
|
||||||
@@ -164,7 +169,7 @@ router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cov
|
|||||||
// DELETE /api/trips/:id — owner only
|
// DELETE /api/trips/:id — owner only
|
||||||
router.delete('/:id', authenticate, (req, res) => {
|
router.delete('/:id', authenticate, (req, res) => {
|
||||||
if (!isOwner(req.params.id, req.user.id))
|
if (!isOwner(req.params.id, req.user.id))
|
||||||
return res.status(403).json({ error: 'Nur der Eigentümer kann die Reise löschen' });
|
return res.status(403).json({ error: 'Only the owner can delete the trip' });
|
||||||
const deletedTripId = Number(req.params.id);
|
const deletedTripId = Number(req.params.id);
|
||||||
db.prepare('DELETE FROM trips WHERE id = ?').run(req.params.id);
|
db.prepare('DELETE FROM trips WHERE id = ?').run(req.params.id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
@@ -176,7 +181,7 @@ router.delete('/:id', authenticate, (req, res) => {
|
|||||||
// GET /api/trips/:id/members
|
// GET /api/trips/:id/members
|
||||||
router.get('/:id/members', authenticate, (req, res) => {
|
router.get('/:id/members', authenticate, (req, res) => {
|
||||||
if (!canAccessTrip(req.params.id, req.user.id))
|
if (!canAccessTrip(req.params.id, req.user.id))
|
||||||
return res.status(404).json({ error: 'Reise nicht gefunden' });
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id);
|
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id);
|
||||||
const members = db.prepare(`
|
const members = db.prepare(`
|
||||||
@@ -203,23 +208,23 @@ router.get('/:id/members', authenticate, (req, res) => {
|
|||||||
// POST /api/trips/:id/members — add by email or username
|
// POST /api/trips/:id/members — add by email or username
|
||||||
router.post('/:id/members', authenticate, (req, res) => {
|
router.post('/:id/members', authenticate, (req, res) => {
|
||||||
if (!canAccessTrip(req.params.id, req.user.id))
|
if (!canAccessTrip(req.params.id, req.user.id))
|
||||||
return res.status(404).json({ error: 'Reise nicht gefunden' });
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const { identifier } = req.body; // email or username
|
const { identifier } = req.body; // email or username
|
||||||
if (!identifier) return res.status(400).json({ error: 'E-Mail oder Benutzername erforderlich' });
|
if (!identifier) return res.status(400).json({ error: 'Email or username required' });
|
||||||
|
|
||||||
const target = db.prepare(
|
const target = db.prepare(
|
||||||
'SELECT id, username, email, avatar FROM users WHERE email = ? OR username = ?'
|
'SELECT id, username, email, avatar FROM users WHERE email = ? OR username = ?'
|
||||||
).get(identifier.trim(), identifier.trim());
|
).get(identifier.trim(), identifier.trim());
|
||||||
|
|
||||||
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id);
|
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id);
|
||||||
if (target.id === trip.user_id)
|
if (target.id === trip.user_id)
|
||||||
return res.status(400).json({ error: 'Der Eigentümer der Reise ist bereits Mitglied' });
|
return res.status(400).json({ error: 'Trip owner is already a member' });
|
||||||
|
|
||||||
const existing = db.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(req.params.id, target.id);
|
const existing = db.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(req.params.id, target.id);
|
||||||
if (existing) return res.status(400).json({ error: 'Benutzer hat bereits Zugriff' });
|
if (existing) return res.status(400).json({ error: 'User already has access' });
|
||||||
|
|
||||||
db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(req.params.id, target.id, req.user.id);
|
db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(req.params.id, target.id, req.user.id);
|
||||||
|
|
||||||
@@ -229,12 +234,12 @@ router.post('/:id/members', authenticate, (req, res) => {
|
|||||||
// DELETE /api/trips/:id/members/:userId — owner removes anyone; member removes self
|
// DELETE /api/trips/:id/members/:userId — owner removes anyone; member removes self
|
||||||
router.delete('/:id/members/:userId', authenticate, (req, res) => {
|
router.delete('/:id/members/:userId', authenticate, (req, res) => {
|
||||||
if (!canAccessTrip(req.params.id, req.user.id))
|
if (!canAccessTrip(req.params.id, req.user.id))
|
||||||
return res.status(404).json({ error: 'Reise nicht gefunden' });
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const targetId = parseInt(req.params.userId);
|
const targetId = parseInt(req.params.userId);
|
||||||
const isSelf = targetId === req.user.id;
|
const isSelf = targetId === req.user.id;
|
||||||
if (!isSelf && !isOwner(req.params.id, req.user.id))
|
if (!isSelf && !isOwner(req.params.id, req.user.id))
|
||||||
return res.status(403).json({ error: 'Keine Berechtigung' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
|
|
||||||
db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(req.params.id, targetId);
|
db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(req.params.id, targetId);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ const CACHE_TTL = 24 * 60 * 60 * 1000;
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
|
|
||||||
// Broadcast vacay updates to all users in the same plan
|
// Broadcast vacay updates to all users in the same plan (exclude only the triggering socket, not the whole user)
|
||||||
function notifyPlanUsers(planId, excludeUserId, event = 'vacay:update') {
|
function notifyPlanUsers(planId, excludeSid, event = 'vacay:update') {
|
||||||
try {
|
try {
|
||||||
const { broadcastToUser } = require('../websocket');
|
const { broadcastToUser } = require('../websocket');
|
||||||
const plan = db.prepare('SELECT owner_id FROM vacay_plans WHERE id = ?').get(planId);
|
const plan = db.prepare('SELECT owner_id FROM vacay_plans WHERE id = ?').get(planId);
|
||||||
@@ -18,7 +18,7 @@ function notifyPlanUsers(planId, excludeUserId, event = 'vacay:update') {
|
|||||||
const userIds = [plan.owner_id];
|
const userIds = [plan.owner_id];
|
||||||
const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(planId);
|
const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(planId);
|
||||||
members.forEach(m => userIds.push(m.user_id));
|
members.forEach(m => userIds.push(m.user_id));
|
||||||
userIds.filter(id => id !== excludeUserId).forEach(id => broadcastToUser(id, { type: event }));
|
userIds.forEach(id => broadcastToUser(id, { type: event }, excludeSid));
|
||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +191,7 @@ router.put('/plan', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyPlanUsers(planId, req.user.id, 'vacay:settings');
|
notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:settings');
|
||||||
|
|
||||||
const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
|
const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
|
||||||
res.json({
|
res.json({
|
||||||
@@ -213,7 +213,7 @@ router.put('/color', (req, res) => {
|
|||||||
INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)
|
INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)
|
||||||
ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color
|
ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color
|
||||||
`).run(userId, planId, color || '#6366f1');
|
`).run(userId, planId, color || '#6366f1');
|
||||||
notifyPlanUsers(planId, req.user.id, 'vacay:update');
|
notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:update');
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -300,7 +300,7 @@ router.post('/invite/accept', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Notify all plan users (not just owner)
|
// Notify all plan users (not just owner)
|
||||||
notifyPlanUsers(plan_id, req.user.id, 'vacay:accepted');
|
notifyPlanUsers(plan_id, req.headers['x-socket-id'], 'vacay:accepted');
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
@@ -310,7 +310,7 @@ router.post('/invite/decline', (req, res) => {
|
|||||||
const { plan_id } = req.body;
|
const { plan_id } = req.body;
|
||||||
db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(plan_id, req.user.id);
|
db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(plan_id, req.user.id);
|
||||||
|
|
||||||
notifyPlanUsers(plan_id, req.user.id, 'vacay:declined');
|
notifyPlanUsers(plan_id, req.headers['x-socket-id'], 'vacay:declined');
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
@@ -417,7 +417,7 @@ router.post('/years', (req, res) => {
|
|||||||
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?)').run(u.id, planId, year, carriedOver);
|
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?)').run(u.id, planId, year, carriedOver);
|
||||||
}
|
}
|
||||||
} catch { /* exists */ }
|
} catch { /* exists */ }
|
||||||
notifyPlanUsers(planId, req.user.id, 'vacay:settings');
|
notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:settings');
|
||||||
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId);
|
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId);
|
||||||
res.json({ years: years.map(y => y.year) });
|
res.json({ years: years.map(y => y.year) });
|
||||||
});
|
});
|
||||||
@@ -428,7 +428,7 @@ router.delete('/years/:year', (req, res) => {
|
|||||||
db.prepare('DELETE FROM vacay_years WHERE plan_id = ? AND year = ?').run(planId, year);
|
db.prepare('DELETE FROM vacay_years WHERE plan_id = ? AND year = ?').run(planId, year);
|
||||||
db.prepare("DELETE FROM vacay_entries WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
|
db.prepare("DELETE FROM vacay_entries WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
|
||||||
db.prepare("DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
|
db.prepare("DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
|
||||||
notifyPlanUsers(planId, req.user.id, 'vacay:settings');
|
notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:settings');
|
||||||
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId);
|
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId);
|
||||||
res.json({ years: years.map(y => y.year) });
|
res.json({ years: years.map(y => y.year) });
|
||||||
});
|
});
|
||||||
@@ -466,11 +466,11 @@ router.post('/entries/toggle', (req, res) => {
|
|||||||
const existing = db.prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId);
|
const existing = db.prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id);
|
db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id);
|
||||||
notifyPlanUsers(planId, req.user.id);
|
notifyPlanUsers(planId, req.headers['x-socket-id']);
|
||||||
res.json({ action: 'removed' });
|
res.json({ action: 'removed' });
|
||||||
} else {
|
} else {
|
||||||
db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, userId, date, '');
|
db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, userId, date, '');
|
||||||
notifyPlanUsers(planId, req.user.id);
|
notifyPlanUsers(planId, req.headers['x-socket-id']);
|
||||||
res.json({ action: 'added' });
|
res.json({ action: 'added' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -481,13 +481,13 @@ router.post('/entries/company-holiday', (req, res) => {
|
|||||||
const existing = db.prepare('SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').get(planId, date);
|
const existing = db.prepare('SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').get(planId, date);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.prepare('DELETE FROM vacay_company_holidays WHERE id = ?').run(existing.id);
|
db.prepare('DELETE FROM vacay_company_holidays WHERE id = ?').run(existing.id);
|
||||||
notifyPlanUsers(planId, req.user.id);
|
notifyPlanUsers(planId, req.headers['x-socket-id']);
|
||||||
res.json({ action: 'removed' });
|
res.json({ action: 'removed' });
|
||||||
} else {
|
} else {
|
||||||
db.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, date, note || '');
|
db.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, date, note || '');
|
||||||
// Remove any vacation entries on this date
|
// Remove any vacation entries on this date
|
||||||
db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, date);
|
db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, date);
|
||||||
notifyPlanUsers(planId, req.user.id);
|
notifyPlanUsers(planId, req.headers['x-socket-id']);
|
||||||
res.json({ action: 'added' });
|
res.json({ action: 'added' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -544,7 +544,7 @@ router.put('/stats/:year', (req, res) => {
|
|||||||
INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, 0)
|
INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, 0)
|
||||||
ON CONFLICT(user_id, plan_id, year) DO UPDATE SET vacation_days = excluded.vacation_days
|
ON CONFLICT(user_id, plan_id, year) DO UPDATE SET vacation_days = excluded.vacation_days
|
||||||
`).run(userId, planId, year, vacation_days);
|
`).run(userId, planId, year, vacation_days);
|
||||||
notifyPlanUsers(planId, req.user.id);
|
notifyPlanUsers(planId, req.headers['x-socket-id']);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||