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

|

|
||||||
|
|
||||||
@@ -26,33 +32,52 @@ A self-hosted, real-time collaborative travel planner for organizing trips with
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Real-Time Collaboration** — Plan together via WebSocket live sync — changes appear instantly across all connected users
|
### Trip Planning
|
||||||
- **Interactive Map** — Leaflet map with marker clustering, route visualization, and customizable tile sources
|
|
||||||
- **Google Places Integration** — Search places, auto-fill details including ratings, reviews, opening hours, and photos (requires API key)
|
|
||||||
- **Drag & Drop Planner** — Organize places into day plans with reordering and cross-day moves
|
- **Drag & Drop Planner** — Organize places into day plans with reordering and cross-day moves
|
||||||
- **Weather Forecasts** — Current weather and 5-day forecasts with smart caching (requires API key)
|
- **Interactive Map** — Leaflet map with photo markers, clustering, route visualization, and customizable tile sources
|
||||||
|
- **Place Search** — Search via Google Places (with photos, ratings, opening hours) or OpenStreetMap (free, no API key needed)
|
||||||
|
- **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering
|
||||||
|
- **Route Optimization** — Auto-optimize place order and export to Google Maps
|
||||||
|
- **Weather Forecasts** — Current weather and 5-day forecasts with smart caching
|
||||||
|
|
||||||
|
### Travel Management
|
||||||
|
- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments
|
||||||
- **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support
|
- **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support
|
||||||
- **Packing Lists** — Categorized checklists with progress tracking, color coding, and smart suggestions
|
- **Packing Lists** — Categorized checklists with progress tracking, color coding, and smart suggestions
|
||||||
- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments
|
|
||||||
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
|
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
|
||||||
- **PDF Export** — Export complete trip plans as PDF with images and notes
|
- **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and NOMAD branding
|
||||||
|
|
||||||
|
### Mobile & PWA
|
||||||
|
- **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed
|
||||||
|
- **Offline Support** — Service Worker caches map tiles, API data, uploads, and static assets via Workbox
|
||||||
|
- **Native App Feel** — Fullscreen standalone mode, custom app icon, themed status bar, and splash screen
|
||||||
|
- **Touch Optimized** — Responsive design with mobile-specific layouts, touch-friendly controls, and safe area handling
|
||||||
|
|
||||||
|
### Collaboration
|
||||||
|
- **Real-Time Sync** — Plan together via WebSocket — changes appear instantly across all connected users
|
||||||
- **Multi-User** — Invite members to collaborate on shared trips with role-based access
|
- **Multi-User** — Invite members to collaborate on shared trips with role-based access
|
||||||
- **Admin Panel** — User management, create users, global categories, API key configuration, and backups
|
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||||
- **Auto-Backups** — Scheduled backups with configurable interval and retention
|
|
||||||
- **Route Optimization** — Auto-optimize place order and export to Google Maps
|
### Addons (modular, admin-toggleable)
|
||||||
- **Day Notes** — Add timestamped notes to individual days
|
- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with live sync, and carry-over tracking
|
||||||
- **Dark Mode** — Full light and dark theme support
|
- **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
|
||||||
|
|
||||||
|
### Customization & Admin
|
||||||
|
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
|
||||||
- **Multilingual** — English and German (i18n)
|
- **Multilingual** — English and German (i18n)
|
||||||
- **Mobile Friendly** — Responsive design with touch-optimized controls
|
- **Admin Panel** — User management, global categories, addon management, API keys, and backups
|
||||||
|
- **Auto-Backups** — Scheduled backups with configurable interval and retention
|
||||||
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: Node.js 22 + Express + SQLite (`node:sqlite`)
|
- **Backend**: Node.js 22 + Express + SQLite (`node:sqlite`)
|
||||||
- **Frontend**: React 18 + Vite + Tailwind CSS
|
- **Frontend**: React 18 + Vite + Tailwind CSS
|
||||||
|
- **PWA**: vite-plugin-pwa + Workbox
|
||||||
- **Real-Time**: WebSocket (`ws`)
|
- **Real-Time**: WebSocket (`ws`)
|
||||||
- **State**: Zustand
|
- **State**: Zustand
|
||||||
- **Auth**: JWT
|
- **Auth**: JWT + OIDC
|
||||||
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
||||||
- **Weather**: OpenWeatherMap API (optional)
|
- **Weather**: OpenWeatherMap API (optional)
|
||||||
- **Icons**: lucide-react
|
- **Icons**: lucide-react
|
||||||
@@ -65,6 +90,15 @@ docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads maurice
|
|||||||
|
|
||||||
The app runs on port `3000`. The first user to register becomes the admin.
|
The app runs on port `3000`. The first user to register becomes the admin.
|
||||||
|
|
||||||
|
### Install as App (PWA)
|
||||||
|
|
||||||
|
NOMAD works as a Progressive Web App — no App Store needed:
|
||||||
|
|
||||||
|
1. Open your NOMAD instance in the browser (HTTPS required)
|
||||||
|
2. **iOS**: Share button → "Add to Home Screen"
|
||||||
|
3. **Android**: Menu → "Install app" or "Add to Home Screen"
|
||||||
|
4. NOMAD launches fullscreen with its own icon, just like a native app
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Docker Compose (recommended for production)</summary>
|
<summary>Docker Compose (recommended for production)</summary>
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,25 @@
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23111827' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M17.8 19.2 16 11l3.5-3.5C21 6 21 4 19 2c-2-2-4-2-5.5-.5L10 5 1.8 6.2c-.5.1-.9.6-.6 1.1l1.9 2.9 2.5-.9 4-4 2.7 2.7-4 4 .9 2.5 2.9 1.9c.5.3 1 0 1.1-.5z'/></svg>" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>NOMAD</title>
|
<title>NOMAD</title>
|
||||||
|
|
||||||
|
<!-- PWA / iOS -->
|
||||||
|
<meta name="theme-color" content="#09090b" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="NOMAD" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
|
<!-- Leaflet -->
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "nomad-client",
|
"name": "nomad-client",
|
||||||
"version": "2.0.0",
|
"version": "2.5.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"prebuild": "node scripts/generate-icons.mjs",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
@@ -30,7 +31,9 @@
|
|||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"autoprefixer": "^10.4.18",
|
"autoprefixer": "^10.4.18",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.35",
|
||||||
|
"sharp": "^0.33.0",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"vite": "^5.1.4"
|
"vite": "^5.1.4",
|
||||||
|
"vite-plugin-pwa": "^0.21.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="a5b4275efd"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="61932b752f"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.753906 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.753906 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#a5b4275efd)"><g clip-path="url(#61932b752f)"><path fill="#000000" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="ff6253e8fa"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="c6b14a8188"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#ff6253e8fa)"><g clip-path="url(#c6b14a8188)"><path fill="#ffffff" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#1e293b"/>
|
||||||
|
<stop offset="100%" stop-color="#0f172a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="icon">
|
||||||
|
<path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" fill="url(#bg)"/>
|
||||||
|
<g transform="translate(56,51) scale(0.267)">
|
||||||
|
<rect width="1500" height="1500" fill="#ffffff" clip-path="url(#icon)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Generates PNG icons for PWA from the master SVG icon.
|
||||||
|
* Run: node scripts/generate-icons.mjs
|
||||||
|
* Called automatically via the "prebuild" npm script.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const iconsDir = join(__dirname, '..', 'public', 'icons');
|
||||||
|
const svgBuffer = readFileSync(join(iconsDir, 'icon.svg'));
|
||||||
|
|
||||||
|
const sizes = [
|
||||||
|
{ name: 'apple-touch-icon-180x180.png', size: 180 },
|
||||||
|
{ name: 'icon-192x192.png', size: 192 },
|
||||||
|
{ name: 'icon-512x512.png', size: 512 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, size } of sizes) {
|
||||||
|
await sharp(svgBuffer, { density: 300 })
|
||||||
|
.resize(size, size)
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toFile(join(iconsDir, name));
|
||||||
|
console.log(` \u2713 ${name} (${size}x${size})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('PWA icons generated.');
|
||||||
@@ -10,6 +10,8 @@ import TripPlannerPage from './pages/TripPlannerPage'
|
|||||||
import FilesPage from './pages/FilesPage'
|
import FilesPage from './pages/FilesPage'
|
||||||
import AdminPage from './pages/AdminPage'
|
import AdminPage from './pages/AdminPage'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
|
import VacayPage from './pages/VacayPage'
|
||||||
|
import AtlasPage from './pages/AtlasPage'
|
||||||
import { ToastContainer } from './components/shared/Toast'
|
import { ToastContainer } from './components/shared/Toast'
|
||||||
import { TranslationProvider } from './i18n'
|
import { TranslationProvider } from './i18n'
|
||||||
import DemoBanner from './components/Layout/DemoBanner'
|
import DemoBanner from './components/Layout/DemoBanner'
|
||||||
@@ -33,7 +35,7 @@ function ProtectedRoute({ children, adminRequired = false }) {
|
|||||||
return <Navigate to="/login" replace />
|
return <Navigate to="/login" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (adminRequired && user?.role !== 'admin') {
|
if (adminRequired && user && user.role !== 'admin') {
|
||||||
return <Navigate to="/dashboard" replace />
|
return <Navigate to="/dashboard" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +57,7 @@ function RootRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadUser, token, isAuthenticated, demoMode, setDemoMode } = useAuthStore()
|
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey } = useAuthStore()
|
||||||
const { loadSettings } = useSettingsStore()
|
const { loadSettings } = useSettingsStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,6 +66,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
authApi.getAppConfig().then(config => {
|
authApi.getAppConfig().then(config => {
|
||||||
if (config?.demo_mode) setDemoMode(true)
|
if (config?.demo_mode) setDemoMode(true)
|
||||||
|
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -75,13 +78,17 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated])
|
}, [isAuthenticated])
|
||||||
|
|
||||||
// Apply dark mode class to <html>
|
// Apply dark mode class to <html> + update PWA theme-color
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings.dark_mode) {
|
if (settings.dark_mode) {
|
||||||
document.documentElement.classList.add('dark')
|
document.documentElement.classList.add('dark')
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.remove('dark')
|
document.documentElement.classList.remove('dark')
|
||||||
}
|
}
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]')
|
||||||
|
if (meta) {
|
||||||
|
meta.setAttribute('content', settings.dark_mode ? '#09090b' : '#ffffff')
|
||||||
|
}
|
||||||
}, [settings.dark_mode])
|
}, [settings.dark_mode])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -131,6 +138,22 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/vacay"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<VacayPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/atlas"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AtlasPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</TranslationProvider>
|
</TranslationProvider>
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export const authApi = {
|
|||||||
updateAppSettings: (data) => apiClient.put('/auth/app-settings', data).then(r => r.data),
|
updateAppSettings: (data) => apiClient.put('/auth/app-settings', data).then(r => r.data),
|
||||||
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
|
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
|
||||||
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
|
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
|
||||||
|
changePassword: (data) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
||||||
|
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
||||||
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +125,14 @@ export const adminApi = {
|
|||||||
deleteUser: (id) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
|
deleteUser: (id) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
|
||||||
stats: () => apiClient.get('/admin/stats').then(r => r.data),
|
stats: () => apiClient.get('/admin/stats').then(r => r.data),
|
||||||
saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data),
|
saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data),
|
||||||
|
getOidc: () => apiClient.get('/admin/oidc').then(r => r.data),
|
||||||
|
updateOidc: (data) => apiClient.put('/admin/oidc', data).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),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addonsApi = {
|
||||||
|
enabled: () => apiClient.get('/addons').then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mapsApi = {
|
export const mapsApi = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { adminApi } from '../../api/client'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase } from 'lucide-react'
|
||||||
|
|
||||||
|
const ICON_MAP = {
|
||||||
|
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase,
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddonIcon({ name, size = 20 }) {
|
||||||
|
const Icon = ICON_MAP[name] || Puzzle
|
||||||
|
return <Icon size={size} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddonManager() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const dark = useSettingsStore(s => s.settings.dark_mode)
|
||||||
|
const toast = useToast()
|
||||||
|
const [addons, setAddons] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAddons()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadAddons = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.addons()
|
||||||
|
setAddons(data.addons)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t('admin.addons.toast.error'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggle = async (addon) => {
|
||||||
|
const newEnabled = !addon.enabled
|
||||||
|
// Optimistic update
|
||||||
|
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
|
||||||
|
try {
|
||||||
|
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
|
||||||
|
window.dispatchEvent(new Event('addons-changed'))
|
||||||
|
toast.success(t('admin.addons.toast.updated'))
|
||||||
|
} catch (err) {
|
||||||
|
// Rollback
|
||||||
|
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a))
|
||||||
|
toast.error(t('admin.addons.toast.error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tripAddons = addons.filter(a => a.type === 'trip')
|
||||||
|
const globalAddons = addons.filter(a => a.type === 'global')
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" style={{ borderTopColor: 'var(--text-primary)' }}></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<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)' }}>
|
||||||
|
<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)', 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>
|
||||||
|
|
||||||
|
{addons.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-sm" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
{t('admin.addons.noAddons')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{/* Trip Addons */}
|
||||||
|
{tripAddons.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="px-6 py-2.5 border-b flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
|
||||||
|
<Briefcase size={13} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{t('admin.addons.type.trip')} — {t('admin.addons.tripHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{tripAddons.map(addon => (
|
||||||
|
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Global Addons */}
|
||||||
|
{globalAddons.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
|
||||||
|
<Globe size={13} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{t('admin.addons.type.global')} — {t('admin.addons.globalHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{globalAddons.map(addon => (
|
||||||
|
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddonRow({ addon, onToggle, t }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
|
||||||
|
<AddonIcon name={addon.icon} size={20} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{addon.name}</span>
|
||||||
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{
|
||||||
|
background: addon.type === 'global' ? 'var(--bg-secondary)' : 'var(--bg-secondary)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
{addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{addon.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className="text-xs font-medium" style={{ color: addon.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||||
|
{addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle(addon)}
|
||||||
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
style={{ background: addon.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block h-4 w-4 transform rounded-full transition-transform"
|
||||||
|
style={{
|
||||||
|
background: addon.enabled ? 'var(--bg-card)' : 'var(--bg-card)',
|
||||||
|
transform: addon.enabled ? 'translateX(22px)' : 'translateX(4px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,8 +164,8 @@ export default function BackupPanel() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<HardDrive className="w-5 h-5 text-gray-400" />
|
<HardDrive className="w-5 h-5 text-gray-400" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{t('backup.title')}</h2>
|
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.title')}</h2>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">{t('backup.subtitle')}</p>
|
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -179,26 +190,28 @@ export default function BackupPanel() {
|
|||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
|
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
|
||||||
|
title={isUploading ? t('backup.uploading') : t('backup.upload')}
|
||||||
>
|
>
|
||||||
{isUploading ? (
|
{isUploading ? (
|
||||||
<div className="w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
<div className="w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
{isUploading ? t('backup.uploading') : t('backup.upload')}
|
<span className="hidden sm:inline">{isUploading ? t('backup.uploading') : t('backup.upload')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
disabled={isCreating}
|
disabled={isCreating}
|
||||||
className="flex items-center gap-2 bg-slate-700 text-white px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
|
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
|
||||||
|
title={isCreating ? t('backup.creating') : t('backup.create')}
|
||||||
>
|
>
|
||||||
{isCreating ? (
|
{isCreating ? (
|
||||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
{isCreating ? t('backup.creating') : t('backup.create')}
|
<span className="hidden sm:inline">{isCreating ? t('backup.creating') : t('backup.create')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,23 +288,23 @@ export default function BackupPanel() {
|
|||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<Clock className="w-5 h-5 text-gray-400" />
|
<Clock className="w-5 h-5 text-gray-400" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{t('backup.auto.title')}</h2>
|
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.auto.title')}</h2>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">{t('backup.auto.subtitle')}</p>
|
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.auto.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
{/* Enable toggle */}
|
{/* Enable toggle */}
|
||||||
<label className="flex items-center justify-between cursor-pointer">
|
<label className="flex items-center justify-between gap-4 cursor-pointer">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
|
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
|
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-700' : 'bg-gray-200'}`}
|
className={`relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-900 dark:bg-slate-100' : 'bg-gray-200 dark:bg-gray-600'}`}
|
||||||
>
|
>
|
||||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${autoSettings.enabled ? 'translate-x-6' : 'translate-x-1'}`} />
|
<span className={`absolute left-1 h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${autoSettings.enabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -307,7 +320,7 @@ export default function BackupPanel() {
|
|||||||
onClick={() => handleAutoSettingsChange('interval', opt.value)}
|
onClick={() => handleAutoSettingsChange('interval', opt.value)}
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
autoSettings.interval === opt.value
|
autoSettings.interval === opt.value
|
||||||
? 'bg-slate-700 text-white border-slate-700'
|
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -327,7 +340,7 @@ export default function BackupPanel() {
|
|||||||
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
|
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
autoSettings.keep_days === opt.value
|
autoSettings.keep_days === opt.value
|
||||||
? 'bg-slate-700 text-white border-slate-700'
|
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -344,7 +357,7 @@ export default function BackupPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleSaveAutoSettings}
|
onClick={handleSaveAutoSettings}
|
||||||
disabled={autoSettingsSaving || !autoSettingsDirty}
|
disabled={autoSettingsSaving || !autoSettingsDirty}
|
||||||
className="flex items-center gap-2 bg-slate-700 text-white px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
|
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{autoSettingsSaving
|
{autoSettingsSaving
|
||||||
? <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
? <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
@@ -355,6 +368,71 @@ export default function BackupPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Restore Warning Modal */}
|
||||||
|
{restoreConfirm && (
|
||||||
|
<div
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||||
|
onClick={() => setRestoreConfirm(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
|
||||||
|
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
{/* Red header */}
|
||||||
|
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<AlertTriangle size={20} style={{ color: 'white' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>
|
||||||
|
{language === 'de' ? 'Backup wiederherstellen?' : 'Restore Backup?'}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
|
||||||
|
{restoreConfirm.filename}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div style={{ padding: '20px 24px' }}>
|
||||||
|
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||||
|
{language === 'de'
|
||||||
|
? 'Alle aktuellen Daten (Reisen, Orte, Benutzer, Uploads) werden unwiderruflich durch das Backup ersetzt. Dieser Vorgang kann nicht rückgängig gemacht werden.'
|
||||||
|
: 'All current data (trips, places, users, uploads) will be permanently replaced by the backup. This action cannot be undone.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||||
|
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
|
||||||
|
>
|
||||||
|
{language === 'de'
|
||||||
|
? 'Tipp: Erstelle zuerst ein Backup des aktuellen Stands, bevor du wiederherstellst.'
|
||||||
|
: 'Tip: Create a backup of the current state before restoring.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setRestoreConfirm(null)}
|
||||||
|
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{language === 'de' ? 'Abbrechen' : 'Cancel'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={executeRestore}
|
||||||
|
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: '#dc2626', color: 'white' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
|
||||||
|
>
|
||||||
|
{language === 'de' ? 'Ja, wiederherstellen' : 'Yes, restore'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,13 +190,14 @@ export default function CategoryManager() {
|
|||||||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{t('categories.title')}</h2>
|
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('categories.title')}</h2>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">{t('categories.subtitle')}</p>
|
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('categories.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleStartCreate}
|
<button onClick={handleStartCreate}
|
||||||
className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
{t('categories.new')}
|
<span className="hidden sm:inline">{t('categories.new')}</span>
|
||||||
|
<span className="sm:hidden">Add</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { ArrowRightLeft, RefreshCw } from 'lucide-react'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
|
||||||
|
const CURRENCIES = [
|
||||||
|
'EUR','USD','GBP','JPY','CHF','CAD','AUD','NZD','CNY','HKD',
|
||||||
|
'SGD','THB','TRY','SEK','NOK','DKK','PLN','CZK','HUF','RON',
|
||||||
|
'BGN','HRK','ISK','RUB','UAH','BRL','MXN','ARS','CLP','COP',
|
||||||
|
'INR','IDR','MYR','PHP','KRW','TWD','VND','ZAR','EGP','MAD',
|
||||||
|
'NGN','KES','AED','SAR','QAR','KWD','BHD','OMR','ILS',
|
||||||
|
]
|
||||||
|
|
||||||
|
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
|
||||||
|
|
||||||
|
export default function CurrencyWidget() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
|
||||||
|
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
|
||||||
|
const [amount, setAmount] = useState('100')
|
||||||
|
const [rate, setRate] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const fetchRate = useCallback(async () => {
|
||||||
|
if (from === to) { setRate(1); return }
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`https://api.exchangerate-api.com/v4/latest/${from}`)
|
||||||
|
const data = await resp.json()
|
||||||
|
setRate(data.rates?.[to] || null)
|
||||||
|
} catch { setRate(null) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}, [from, to])
|
||||||
|
|
||||||
|
useEffect(() => { fetchRate() }, [fetchRate])
|
||||||
|
useEffect(() => { localStorage.setItem('currency_from', from) }, [from])
|
||||||
|
useEffect(() => { localStorage.setItem('currency_to', to) }, [to])
|
||||||
|
|
||||||
|
const swap = () => { setFrom(to); setTo(from) }
|
||||||
|
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
|
||||||
|
const formatNumber = (num) => {
|
||||||
|
if (!num || num === '—') return '—'
|
||||||
|
return parseFloat(num).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
}
|
||||||
|
const result = rawResult
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.currency')}</span>
|
||||||
|
<button onClick={fetchRate} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
<div className="rounded-xl px-4 py-3 mb-3" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={e => setAmount(e.target.value)}
|
||||||
|
className="w-full text-2xl font-black tabular-nums outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
style={{ color: 'var(--text-primary)', background: 'transparent', border: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* From / Swap / To */}
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
|
||||||
|
<CustomSelect value={from} onChange={setFrom} options={CURRENCY_OPTIONS} searchable size="sm" />
|
||||||
|
</div>
|
||||||
|
<button onClick={swap} className="p-1.5 rounded-lg shrink-0 transition-colors" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
<ArrowRightLeft size={13} />
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
|
||||||
|
<CustomSelect value={to} onChange={setTo} options={CURRENCY_OPTIONS} searchable size="sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Result */}
|
||||||
|
<div className="rounded-xl p-3" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<p className="text-xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{formatNumber(result)} <span className="text-sm font-semibold" style={{ color: 'var(--text-muted)' }}>{to}</span>
|
||||||
|
</p>
|
||||||
|
{rate && <p className="text-[10px] mt-0.5" style={{ color: 'var(--text-faint)' }}>1 {from} = {rate.toFixed(4)} {to}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { Clock, Plus, X } from 'lucide-react'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
|
const POPULAR_ZONES = [
|
||||||
|
{ label: 'New York', tz: 'America/New_York' },
|
||||||
|
{ label: 'London', tz: 'Europe/London' },
|
||||||
|
{ label: 'Berlin', tz: 'Europe/Berlin' },
|
||||||
|
{ label: 'Paris', tz: 'Europe/Paris' },
|
||||||
|
{ label: 'Dubai', tz: 'Asia/Dubai' },
|
||||||
|
{ label: 'Mumbai', tz: 'Asia/Kolkata' },
|
||||||
|
{ label: 'Bangkok', tz: 'Asia/Bangkok' },
|
||||||
|
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
|
||||||
|
{ label: 'Sydney', tz: 'Australia/Sydney' },
|
||||||
|
{ label: 'Los Angeles', tz: 'America/Los_Angeles' },
|
||||||
|
{ label: 'Chicago', tz: 'America/Chicago' },
|
||||||
|
{ label: 'São Paulo', tz: 'America/Sao_Paulo' },
|
||||||
|
{ label: 'Istanbul', tz: 'Europe/Istanbul' },
|
||||||
|
{ label: 'Singapore', tz: 'Asia/Singapore' },
|
||||||
|
{ label: 'Hong Kong', tz: 'Asia/Hong_Kong' },
|
||||||
|
{ label: 'Seoul', tz: 'Asia/Seoul' },
|
||||||
|
{ label: 'Moscow', tz: 'Europe/Moscow' },
|
||||||
|
{ label: 'Cairo', tz: 'Africa/Cairo' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function getTime(tz) {
|
||||||
|
try {
|
||||||
|
return new Date().toLocaleTimeString('de-DE', { timeZone: tz, hour: '2-digit', minute: '2-digit' })
|
||||||
|
} catch { return '—' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOffset(tz) {
|
||||||
|
try {
|
||||||
|
const now = new Date()
|
||||||
|
const local = new Date(now.toLocaleString('en-US', { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }))
|
||||||
|
const remote = new Date(now.toLocaleString('en-US', { timeZone: tz }))
|
||||||
|
const diff = (remote - local) / 3600000
|
||||||
|
const sign = diff >= 0 ? '+' : ''
|
||||||
|
return `${sign}${diff}h`
|
||||||
|
} catch { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimezoneWidget() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [zones, setZones] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('dashboard_timezones')
|
||||||
|
return saved ? JSON.parse(saved) : [
|
||||||
|
{ label: 'New York', tz: 'America/New_York' },
|
||||||
|
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
const [now, setNow] = useState(Date.now())
|
||||||
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const i = setInterval(() => setNow(Date.now()), 10000)
|
||||||
|
return () => clearInterval(i)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
|
||||||
|
}, [zones])
|
||||||
|
|
||||||
|
const addZone = (zone) => {
|
||||||
|
if (!zones.find(z => z.tz === zone.tz)) {
|
||||||
|
setZones([...zones, zone])
|
||||||
|
}
|
||||||
|
setShowAdd(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
|
||||||
|
|
||||||
|
const localTime = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
|
||||||
|
// Show abbreviated timezone name (e.g. CET, CEST, EST)
|
||||||
|
const tzAbbr = new Date().toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezone')}</span>
|
||||||
|
<button onClick={() => setShowAdd(!showAdd)} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<Plus size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Local time */}
|
||||||
|
<div className="mb-3 pb-3" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||||
|
<p className="text-2xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>{localTime}</p>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{localZone} ({tzAbbr}) · {t('dashboard.localTime')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zone list */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{zones.map(z => (
|
||||||
|
<div key={z.tz} className="flex items-center justify-between group">
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz)}</p>
|
||||||
|
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add zone dropdown */}
|
||||||
|
{showAdd && (
|
||||||
|
<div className="mt-2 rounded-xl p-2 max-h-[200px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
{POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
|
||||||
|
<button key={z.tz} onClick={() => addZone(z)}
|
||||||
|
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left transition-colors"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||||
|
<span className="font-medium">{z.label}</span>
|
||||||
|
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { useTranslation } from '../../i18n'
|
|||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
|
||||||
// Numeric ISO → country name lookup (countries-110m uses numeric IDs)
|
// Numeric ISO → country name lookup (countries-110m uses numeric IDs)
|
||||||
const NUMERIC_TO_NAME = {"004":"Afghanistan","008":"Albania","012":"Algeria","024":"Angola","032":"Argentina","036":"Australia","040":"Austria","050":"Bangladesh","056":"Belgium","064":"Bhutan","068":"Bolivia","070":"Bosnia and Herzegovina","072":"Botswana","076":"Brazil","100":"Bulgaria","104":"Myanmar","108":"Burundi","112":"Belarus","116":"Cambodia","120":"Cameroon","124":"Canada","140":"Central African Republic","144":"Sri Lanka","148":"Chad","152":"Chile","156":"China","170":"Colombia","178":"Congo","180":"Democratic Republic of the Congo","188":"Costa Rica","191":"Croatia","192":"Cuba","196":"Cyprus","203":"Czech Republic","204":"Benin","208":"Denmark","214":"Dominican Republic","218":"Ecuador","818":"Egypt","222":"El Salvador","226":"Equatorial Guinea","232":"Eritrea","233":"Estonia","231":"Ethiopia","238":"Falkland Islands","246":"Finland","250":"France","266":"Gabon","270":"Gambia","268":"Georgia","276":"Germany","288":"Ghana","300":"Greece","320":"Guatemala","324":"Guinea","328":"Guyana","332":"Haiti","340":"Honduras","348":"Hungary","352":"Iceland","356":"India","360":"Indonesia","364":"Iran","368":"Iraq","372":"Ireland","376":"Israel","380":"Italy","384":"Ivory Coast","388":"Jamaica","392":"Japan","400":"Jordan","398":"Kazakhstan","404":"Kenya","408":"North Korea","410":"South Korea","414":"Kuwait","417":"Kyrgyzstan","418":"Laos","422":"Lebanon","426":"Lesotho","430":"Liberia","434":"Libya","440":"Lithuania","442":"Luxembourg","450":"Madagascar","454":"Malawi","458":"Malaysia","466":"Mali","478":"Mauritania","484":"Mexico","496":"Mongolia","498":"Moldova","504":"Morocco","508":"Mozambique","516":"Namibia","524":"Nepal","528":"Netherlands","540":"New Caledonia","554":"New Zealand","558":"Nicaragua","562":"Niger","566":"Nigeria","578":"Norway","512":"Oman","586":"Pakistan","591":"Panama","598":"Papua New Guinea","600":"Paraguay","604":"Peru","608":"Philippines","616":"Poland","620":"Portugal","630":"Puerto Rico","634":"Qatar","642":"Romania","643":"Russia","646":"Rwanda","682":"Saudi Arabia","686":"Senegal","688":"Serbia","694":"Sierra Leone","703":"Slovakia","705":"Slovenia","706":"Somalia","710":"South Africa","724":"Spain","144":"Sri Lanka","729":"Sudan","740":"Suriname","748":"Swaziland","752":"Sweden","756":"Switzerland","760":"Syria","762":"Tajikistan","764":"Thailand","768":"Togo","780":"Trinidad and Tobago","788":"Tunisia","792":"Turkey","795":"Turkmenistan","800":"Uganda","804":"Ukraine","784":"United Arab Emirates","826":"United Kingdom","840":"United States of America","858":"Uruguay","860":"Uzbekistan","862":"Venezuela","704":"Vietnam","887":"Yemen","894":"Zambia","716":"Zimbabwe"}
|
const NUMERIC_TO_NAME = {"004":"Afghanistan","008":"Albania","012":"Algeria","024":"Angola","032":"Argentina","036":"Australia","040":"Austria","050":"Bangladesh","056":"Belgium","064":"Bhutan","068":"Bolivia","070":"Bosnia and Herzegovina","072":"Botswana","076":"Brazil","100":"Bulgaria","104":"Myanmar","108":"Burundi","112":"Belarus","116":"Cambodia","120":"Cameroon","124":"Canada","140":"Central African Republic","144":"Sri Lanka","148":"Chad","152":"Chile","156":"China","170":"Colombia","178":"Congo","180":"Democratic Republic of the Congo","188":"Costa Rica","191":"Croatia","192":"Cuba","196":"Cyprus","203":"Czech Republic","204":"Benin","208":"Denmark","214":"Dominican Republic","218":"Ecuador","818":"Egypt","222":"El Salvador","226":"Equatorial Guinea","232":"Eritrea","233":"Estonia","231":"Ethiopia","238":"Falkland Islands","246":"Finland","250":"France","266":"Gabon","270":"Gambia","268":"Georgia","276":"Germany","288":"Ghana","300":"Greece","320":"Guatemala","324":"Guinea","328":"Guyana","332":"Haiti","340":"Honduras","348":"Hungary","352":"Iceland","356":"India","360":"Indonesia","364":"Iran","368":"Iraq","372":"Ireland","376":"Israel","380":"Italy","384":"Ivory Coast","388":"Jamaica","392":"Japan","400":"Jordan","398":"Kazakhstan","404":"Kenya","408":"North Korea","410":"South Korea","414":"Kuwait","417":"Kyrgyzstan","418":"Laos","422":"Lebanon","426":"Lesotho","430":"Liberia","434":"Libya","440":"Lithuania","442":"Luxembourg","450":"Madagascar","454":"Malawi","458":"Malaysia","466":"Mali","478":"Mauritania","484":"Mexico","496":"Mongolia","498":"Moldova","504":"Morocco","508":"Mozambique","516":"Namibia","524":"Nepal","528":"Netherlands","540":"New Caledonia","554":"New Zealand","558":"Nicaragua","562":"Niger","566":"Nigeria","578":"Norway","512":"Oman","586":"Pakistan","591":"Panama","598":"Papua New Guinea","600":"Paraguay","604":"Peru","608":"Philippines","616":"Poland","620":"Portugal","630":"Puerto Rico","634":"Qatar","642":"Romania","643":"Russia","646":"Rwanda","682":"Saudi Arabia","686":"Senegal","688":"Serbia","694":"Sierra Leone","703":"Slovakia","705":"Slovenia","706":"Somalia","710":"South Africa","724":"Spain","729":"Sudan","740":"Suriname","748":"Swaziland","752":"Sweden","756":"Switzerland","760":"Syria","762":"Tajikistan","764":"Thailand","768":"Togo","780":"Trinidad and Tobago","788":"Tunisia","792":"Turkey","795":"Turkmenistan","800":"Uganda","804":"Ukraine","784":"United Arab Emirates","826":"United Kingdom","840":"United States of America","858":"Uruguay","860":"Uzbekistan","862":"Venezuela","704":"Vietnam","887":"Yemen","894":"Zambia","716":"Zimbabwe"}
|
||||||
|
|
||||||
// Our country names from addresses → match against GeoJSON names
|
// Our country names from addresses → match against GeoJSON names
|
||||||
function isCountryMatch(geoName, visitedCountries) {
|
function isCountryMatch(geoName, visitedCountries) {
|
||||||
|
|||||||
@@ -68,10 +68,10 @@ function SourceBadge({ icon: Icon, label }) {
|
|||||||
fontSize: 10.5, color: '#4b5563',
|
fontSize: 10.5, color: '#4b5563',
|
||||||
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
|
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
|
||||||
borderRadius: 6, padding: '2px 7px',
|
borderRadius: 6, padding: '2px 7px',
|
||||||
fontWeight: 500, whiteSpace: 'nowrap',
|
fontWeight: 500, maxWidth: '100%', overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
<Icon size={10} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
<Icon size={10} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||||
{label}
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,76 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { Info, Github, Shield, Key, Users, Database, Upload, Clock } from 'lucide-react'
|
import { Info, Github, Shield, Key, Users, Database, Upload, Clock, Puzzle, CalendarDays, Globe, ArrowRightLeft, Map, Briefcase, ListChecks, Wallet, FileText, Plane } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
const texts = {
|
const texts = {
|
||||||
de: {
|
de: {
|
||||||
|
titleBefore: 'Willkommen bei ',
|
||||||
|
titleAfter: '',
|
||||||
title: 'Willkommen zur NOMAD Demo',
|
title: 'Willkommen zur NOMAD Demo',
|
||||||
description: 'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
|
description: 'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
|
||||||
resetIn: 'Naechster Reset in',
|
resetIn: 'Naechster Reset in',
|
||||||
minutes: 'Minuten',
|
minutes: 'Minuten',
|
||||||
uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.',
|
uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.',
|
||||||
fullVersionTitle: 'In der Vollversion zusaetzlich verfuegbar:',
|
fullVersionTitle: 'In der Vollversion zusaetzlich:',
|
||||||
features: [
|
features: [
|
||||||
'Datei-Uploads (Fotos, Dokumente, Reise-Cover)',
|
'Datei-Uploads (Fotos, Dokumente, Cover)',
|
||||||
'API-Schluessel verwalten (Google Maps, Wetter)',
|
'API-Schluessel (Google Maps, Wetter)',
|
||||||
'Benutzer & Rechte verwalten',
|
'Benutzer- & Rechteverwaltung',
|
||||||
'Automatische Backups & Wiederherstellung',
|
'Automatische Backups',
|
||||||
|
'Addon-Verwaltung (aktivieren/deaktivieren)',
|
||||||
|
'OIDC / SSO Single Sign-On',
|
||||||
],
|
],
|
||||||
selfHost: 'NOMAD ist Open Source — ',
|
addonsTitle: 'Modulare Addons (in der Vollversion deaktivierbar)',
|
||||||
|
addons: [
|
||||||
|
['Vacay', 'Urlaubsplaner mit Kalender, Feiertagen & Fusion'],
|
||||||
|
['Atlas', 'Weltkarte mit besuchten Laendern & Reisestatistiken'],
|
||||||
|
['Packliste', 'Checklisten pro Reise'],
|
||||||
|
['Budget', 'Kostenplanung mit Splitting'],
|
||||||
|
['Dokumente', 'Dateien an Reisen anhaengen'],
|
||||||
|
['Widgets', 'Waehrungsrechner & Zeitzonen'],
|
||||||
|
],
|
||||||
|
whatIs: 'Was ist NOMAD?',
|
||||||
|
whatIsDesc: 'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
|
||||||
|
selfHost: 'Open Source — ',
|
||||||
selfHostLink: 'selbst hosten',
|
selfHostLink: 'selbst hosten',
|
||||||
close: 'Verstanden',
|
close: 'Verstanden',
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
|
titleBefore: 'Welcome to ',
|
||||||
|
titleAfter: '',
|
||||||
title: 'Welcome to the NOMAD Demo',
|
title: 'Welcome to the NOMAD Demo',
|
||||||
description: 'You can view, edit and create trips. All changes are automatically reset every hour.',
|
description: 'You can view, edit and create trips. All changes are automatically reset every hour.',
|
||||||
resetIn: 'Next reset in',
|
resetIn: 'Next reset in',
|
||||||
minutes: 'minutes',
|
minutes: 'minutes',
|
||||||
uploadNote: 'File uploads (photos, documents, covers) are disabled in demo mode.',
|
uploadNote: 'File uploads (photos, documents, covers) are disabled in demo mode.',
|
||||||
fullVersionTitle: 'Additionally available in the full version:',
|
fullVersionTitle: 'Additionally in the full version:',
|
||||||
features: [
|
features: [
|
||||||
'File uploads (photos, documents, trip covers)',
|
'File uploads (photos, documents, covers)',
|
||||||
'API key management (Google Maps, Weather)',
|
'API key management (Google Maps, Weather)',
|
||||||
'User & permission management',
|
'User & permission management',
|
||||||
'Automatic backups & restore',
|
'Automatic backups',
|
||||||
|
'Addon management (enable/disable)',
|
||||||
|
'OIDC / SSO single sign-on',
|
||||||
],
|
],
|
||||||
selfHost: 'NOMAD is open source — ',
|
addonsTitle: 'Modular Addons (can be deactivated in full version)',
|
||||||
|
addons: [
|
||||||
|
['Vacay', 'Vacation planner with calendar, holidays & user fusion'],
|
||||||
|
['Atlas', 'World map with visited countries & travel stats'],
|
||||||
|
['Packing', 'Checklists per trip'],
|
||||||
|
['Budget', 'Expense tracking with splitting'],
|
||||||
|
['Documents', 'Attach files to trips'],
|
||||||
|
['Widgets', 'Currency converter & timezones'],
|
||||||
|
],
|
||||||
|
whatIs: 'What is NOMAD?',
|
||||||
|
whatIsDesc: 'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.',
|
||||||
|
selfHost: 'Open source — ',
|
||||||
selfHostLink: 'self-host it',
|
selfHostLink: 'self-host it',
|
||||||
close: 'Got it',
|
close: 'Got it',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const featureIcons = [Upload, Key, Users, Database]
|
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
|
||||||
|
const addonIcons = [CalendarDays, Globe, ListChecks, Wallet, FileText, ArrowRightLeft]
|
||||||
|
|
||||||
export default function DemoBanner() {
|
export default function DemoBanner() {
|
||||||
const [dismissed, setDismissed] = useState(false)
|
const [dismissed, setDismissed] = useState(false)
|
||||||
@@ -57,85 +88,120 @@ export default function DemoBanner() {
|
|||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', inset: 0, zIndex: 9999,
|
position: 'fixed', inset: 0, zIndex: 9999,
|
||||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
padding: 24,
|
padding: 16, overflow: 'auto',
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
}} onClick={() => setDismissed(true)}>
|
}} onClick={() => setDismissed(true)}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'white', borderRadius: 20, padding: '32px 28px 24px',
|
background: 'white', borderRadius: 20, padding: '28px 24px 20px',
|
||||||
maxWidth: 440, width: '100%',
|
maxWidth: 480, width: '100%',
|
||||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||||
|
maxHeight: '90vh', overflow: 'auto',
|
||||||
}} onClick={e => e.stopPropagation()}>
|
}} onClick={e => e.stopPropagation()}>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
{/* Header */}
|
||||||
<div style={{
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||||
width: 36, height: 36, borderRadius: 10,
|
<img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} />
|
||||||
background: 'linear-gradient(135deg, #f59e0b, #d97706)',
|
<h2 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
{t.titleBefore}<img src="/text-dark.svg" alt="NOMAD" style={{ height: 18 }} />{t.titleAfter}
|
||||||
}}>
|
|
||||||
<Info size={20} style={{ color: 'white' }} />
|
|
||||||
</div>
|
|
||||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>
|
|
||||||
{t.title}
|
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style={{ fontSize: 14, color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}>
|
<p style={{ fontSize: 13, color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}>
|
||||||
{t.description}
|
{t.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{
|
{/* Timer + Upload note */}
|
||||||
display: 'flex', alignItems: 'center', gap: 8, margin: '0 0 12px',
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||||
background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '10px 12px',
|
<div style={{
|
||||||
}}>
|
flex: 1, display: 'flex', alignItems: 'center', gap: 6,
|
||||||
<Clock size={15} style={{ flexShrink: 0, color: '#0284c7' }} />
|
background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '8px 10px',
|
||||||
<span style={{ fontSize: 13, color: '#0369a1', fontWeight: 600 }}>
|
}}>
|
||||||
{t.resetIn} {minutesLeft} {t.minutes}
|
<Clock size={13} style={{ flexShrink: 0, color: '#0284c7' }} />
|
||||||
</span>
|
<span style={{ fontSize: 11, color: '#0369a1', fontWeight: 600 }}>
|
||||||
|
{t.resetIn} {minutesLeft} {t.minutes}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
flex: 1, display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '8px 10px',
|
||||||
|
}}>
|
||||||
|
<Upload size={13} style={{ flexShrink: 0, color: '#b45309' }} />
|
||||||
|
<span style={{ fontSize: 11, color: '#b45309' }}>{t.uploadNote}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style={{
|
{/* What is NOMAD */}
|
||||||
fontSize: 13, color: '#b45309', lineHeight: 1.5, margin: '0 0 20px',
|
<div style={{
|
||||||
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '10px 12px',
|
background: '#f8fafc', borderRadius: 12, padding: '12px 14px', marginBottom: 16,
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
border: '1px solid #e2e8f0',
|
||||||
}}>
|
}}>
|
||||||
<Upload size={15} style={{ flexShrink: 0 }} />
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||||
{t.uploadNote}
|
<Map size={14} style={{ color: '#111827' }} />
|
||||||
</p>
|
<span style={{ fontSize: 12, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
{language === 'de' ? 'Was ist ' : 'What is '}<img src="/text-dark.svg" alt="NOMAD" style={{ height: 13, marginRight: -2 }} />?
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p style={{ fontSize: 12, fontWeight: 700, color: '#374151', margin: '0 0 10px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
{/* Addons */}
|
||||||
|
<p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Puzzle size={12} />
|
||||||
|
{t.addonsTitle}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
|
||||||
|
{t.addons.map(([name, desc], i) => {
|
||||||
|
const Icon = addonIcons[i]
|
||||||
|
return (
|
||||||
|
<div key={name} style={{
|
||||||
|
background: '#f8fafc', borderRadius: 10, padding: '8px 10px',
|
||||||
|
border: '1px solid #f1f5f9',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
||||||
|
<Icon size={12} style={{ flexShrink: 0, color: '#111827' }} />
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: '#111827' }}>{name}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 10, color: '#94a3b8', margin: 0, lineHeight: 1.3, paddingLeft: 18 }}>{desc}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full version features */}
|
||||||
|
<p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Shield size={12} />
|
||||||
{t.fullVersionTitle}
|
{t.fullVersionTitle}
|
||||||
</p>
|
</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 20 }}>
|
|
||||||
{t.features.map((text, i) => {
|
{t.features.map((text, i) => {
|
||||||
const Icon = featureIcons[i]
|
const Icon = featureIcons[i]
|
||||||
return (
|
return (
|
||||||
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, color: '#4b5563' }}>
|
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: '#4b5563', padding: '4px 0' }}>
|
||||||
<Icon size={15} style={{ flexShrink: 0, color: '#d97706' }} />
|
<Icon size={13} style={{ flexShrink: 0, color: '#9ca3af' }} />
|
||||||
<span>{text}</span>
|
<span>{text}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
<div style={{
|
<div style={{
|
||||||
paddingTop: 16, borderTop: '1px solid #e5e7eb',
|
paddingTop: 14, borderTop: '1px solid #e5e7eb',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#9ca3af' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
|
||||||
<Github size={14} />
|
<Github size={13} />
|
||||||
<span>{t.selfHost}</span>
|
<span>{t.selfHost}</span>
|
||||||
<a href="https://github.com/mauriceboe/NOMAD" target="_blank" rel="noopener noreferrer"
|
<a href="https://github.com/mauriceboe/NOMAD" target="_blank" rel="noopener noreferrer"
|
||||||
style={{ color: '#d97706', fontWeight: 600, textDecoration: 'none' }}>
|
style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}>
|
||||||
{t.selfHostLink}
|
{t.selfHostLink}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onClick={() => setDismissed(true)} style={{
|
<button onClick={() => setDismissed(true)} style={{
|
||||||
background: '#111827', color: 'white', border: 'none',
|
background: '#111827', color: 'white', border: 'none',
|
||||||
borderRadius: 10, padding: '8px 20px', fontSize: 13,
|
borderRadius: 10, padding: '8px 20px', fontSize: 12,
|
||||||
fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
}}>
|
}}>
|
||||||
{t.close}
|
{t.close}
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import ReactDOM from 'react-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 { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun } from 'lucide-react'
|
import { addonsApi } from '../../api/client'
|
||||||
|
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, CalendarDays, Briefcase, Globe } from 'lucide-react'
|
||||||
|
|
||||||
|
const ADDON_ICONS = { CalendarDays, Briefcase, Globe }
|
||||||
|
|
||||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }) {
|
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }) {
|
||||||
const { user, logout } = useAuthStore()
|
const { user, logout } = useAuthStore()
|
||||||
const { settings, updateSetting } = useSettingsStore()
|
const { settings, updateSetting } = useSettingsStore()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
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 dark = settings.dark_mode
|
const dark = settings.dark_mode
|
||||||
|
|
||||||
|
const loadAddons = () => {
|
||||||
|
if (user) {
|
||||||
|
addonsApi.enabled().then(data => {
|
||||||
|
setGlobalAddons(data.addons.filter(a => a.type === 'global'))
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(loadAddons, [user, location.pathname])
|
||||||
|
// Listen for addon changes from AddonManager
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => loadAddons()
|
||||||
|
window.addEventListener('addons-changed', handler)
|
||||||
|
return () => window.removeEventListener('addons-changed', handler)
|
||||||
|
}, [user])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
import('../../api/client').then(({ authApi }) => {
|
import('../../api/client').then(({ authApi }) => {
|
||||||
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
|
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
|
||||||
@@ -35,7 +56,10 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
|||||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||||
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
|
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
|
||||||
boxShadow: 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)',
|
||||||
}} className="h-14 flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
|
touchAction: 'manipulation',
|
||||||
|
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 && (
|
||||||
@@ -49,12 +73,47 @@ 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 */}
|
||||||
|
{globalAddons.length > 0 && !tripTitle && (
|
||||||
|
<>
|
||||||
|
<span style={{ color: 'var(--text-faint)' }}>|</span>
|
||||||
|
<Link to="/dashboard"
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
color: location.pathname === '/dashboard' ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||||
|
background: location.pathname === '/dashboard' ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => { if (location.pathname !== '/dashboard') e.currentTarget.style.background = 'transparent' }}>
|
||||||
|
<Briefcase className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden md:inline">{t('nav.myTrips')}</span>
|
||||||
|
</Link>
|
||||||
|
{globalAddons.map(addon => {
|
||||||
|
const Icon = ADDON_ICONS[addon.icon] || CalendarDays
|
||||||
|
const path = `/${addon.id}`
|
||||||
|
const isActive = location.pathname === path
|
||||||
|
return (
|
||||||
|
<Link key={addon.id} to={path}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
color: isActive ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||||
|
background: isActive ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent' }}>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden md:inline">{addon.name}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{tripTitle && (
|
{tripTitle && (
|
||||||
<>
|
<>
|
||||||
<span className="hidden sm:inline" style={{ color: 'var(--text-faint)' }}>/</span>
|
<span className="hidden sm:inline" style={{ color: 'var(--text-faint)' }}>/</span>
|
||||||
@@ -110,11 +169,10 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
|||||||
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-faint)' }} />
|
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-faint)' }} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{userMenuOpen && (
|
{userMenuOpen && ReactDOM.createPortal(
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-10" onClick={() => setUserMenuOpen(false)} />
|
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setUserMenuOpen(false)} />
|
||||||
<div className="absolute right-0 top-full mt-2 w-52 rounded-xl shadow-xl border z-20 overflow-hidden"
|
<div className="w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
|
||||||
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
|
||||||
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
|
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
|
||||||
@@ -154,13 +212,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
|||||||
{t('nav.logout')}
|
{t('nav.logout')}
|
||||||
</button>
|
</button>
|
||||||
{appVersion && (
|
{appVersion && (
|
||||||
<div className="px-4 py-1.5 text-center" style={{ fontSize: 10, color: 'var(--text-faint)' }}>
|
<div className="px-4 pt-2 pb-2.5 text-center" style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
||||||
NOMAD v{appVersion}
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
|
||||||
|
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="NOMAD" style={{ height: 10, opacity: 0.5 }} />
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ L.Icon.Default.mergeOptions({
|
|||||||
* Create a round photo-circle marker.
|
* Create a round photo-circle marker.
|
||||||
* Shows image_url if available, otherwise category icon in colored circle.
|
* Shows image_url if available, otherwise category icon in colored circle.
|
||||||
*/
|
*/
|
||||||
|
function escAttr(s) {
|
||||||
|
if (!s) return ''
|
||||||
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
function createPlaceIcon(place, orderNumber, isSelected) {
|
function createPlaceIcon(place, orderNumber, isSelected) {
|
||||||
const size = isSelected ? 44 : 36
|
const size = isSelected ? 44 : 36
|
||||||
const borderColor = isSelected ? '#111827' : 'white'
|
const borderColor = isSelected ? '#111827' : 'white'
|
||||||
@@ -55,7 +60,7 @@ function createPlaceIcon(place, orderNumber, isSelected) {
|
|||||||
cursor:pointer;flex-shrink:0;position:relative;
|
cursor:pointer;flex-shrink:0;position:relative;
|
||||||
">
|
">
|
||||||
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
|
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
|
||||||
<img src="${place.image_url}" style="width:100%;height:100%;object-fit:cover;" />
|
<img src="${escAttr(place.image_url)}" style="width:100%;height:100%;object-fit:cover;" />
|
||||||
</div>
|
</div>
|
||||||
${badgeHtml}
|
${badgeHtml}
|
||||||
</div>`,
|
</div>`,
|
||||||
@@ -92,7 +97,7 @@ function SelectionController({ places, selectedPlaceId }) {
|
|||||||
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
||||||
const place = places.find(p => p.id === selectedPlaceId)
|
const place = places.find(p => p.id === selectedPlaceId)
|
||||||
if (place?.lat && place?.lng) {
|
if (place?.lat && place?.lng) {
|
||||||
map.setView([place.lat, place.lng], Math.max(map.getZoom(), 15), { animate: true, duration: 0.5 })
|
map.panTo([place.lat, place.lng], { animate: true, duration: 0.5 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prev.current = selectedPlaceId
|
prev.current = selectedPlaceId
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
// Trip PDF via browser print window
|
// Trip PDF via browser print window
|
||||||
import { createElement } from 'react'
|
import { createElement } from 'react'
|
||||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||||
|
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark } from 'lucide-react'
|
||||||
import { mapsApi } from '../../api/client'
|
import { mapsApi } from '../../api/client'
|
||||||
|
|
||||||
|
const NOTE_ICON_MAP = { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark }
|
||||||
|
function noteIconSvg(iconId) {
|
||||||
|
if (!_renderToStaticMarkup) return ''
|
||||||
|
const Icon = NOTE_ICON_MAP[iconId] || FileText
|
||||||
|
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' }))
|
||||||
|
}
|
||||||
|
|
||||||
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
|
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
|
||||||
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
|
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
|
||||||
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
|
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
|
||||||
@@ -104,7 +112,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const cost = dayCost(assignments, day.id, loc)
|
const cost = dayCost(assignments, day.id, loc)
|
||||||
|
|
||||||
const merged = []
|
const merged = []
|
||||||
assigned.forEach(a => merged.push({ type: 'place', k: a.sort_order ?? 0, data: a }))
|
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
||||||
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||||
merged.sort((a, b) => a.k - b.k)
|
merged.sort((a, b) => a.k - b.k)
|
||||||
|
|
||||||
@@ -117,12 +125,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
return `
|
return `
|
||||||
<div class="note-card">
|
<div class="note-card">
|
||||||
<div class="note-line"></div>
|
<div class="note-line"></div>
|
||||||
<svg class="note-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.8" stroke-linecap="round">
|
<span class="note-icon">${noteIconSvg(note.icon)}</span>
|
||||||
<rect x="4" y="3" width="16" height="18" rx="2"/>
|
|
||||||
<line x1="8" y1="8" x2="16" y2="8"/>
|
|
||||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
|
||||||
<line x1="8" y1="16" x2="13" y2="16"/>
|
|
||||||
</svg>
|
|
||||||
<div class="note-body">
|
<div class="note-body">
|
||||||
<div class="note-text">${escHtml(note.text)}</div>
|
<div class="note-text">${escHtml(note.text)}</div>
|
||||||
${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''}
|
${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''}
|
||||||
@@ -200,6 +203,24 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
body { font-family: 'Poppins', sans-serif; background: #fff; color: #1e293b; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
body { font-family: 'Poppins', sans-serif; background: #fff; color: #1e293b; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
svg { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
svg { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
|
||||||
|
/* Footer on every printed page */
|
||||||
|
.pdf-footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
.pdf-footer span {
|
||||||
|
font-size: 7px;
|
||||||
|
color: #64748b;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Cover ─────────────────────────────────────── */
|
/* ── Cover ─────────────────────────────────────── */
|
||||||
.cover {
|
.cover {
|
||||||
width: 100%; min-height: 100vh;
|
width: 100%; min-height: 100vh;
|
||||||
@@ -215,8 +236,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
.cover-dim { position: absolute; inset: 0; background: rgba(8,12,28,0.55); }
|
.cover-dim { position: absolute; inset: 0; background: rgba(8,12,28,0.55); }
|
||||||
.cover-brand {
|
.cover-brand {
|
||||||
position: absolute; top: 36px; right: 52px;
|
position: absolute; top: 36px; right: 52px;
|
||||||
font-size: 9px; font-weight: 600; letter-spacing: 2.5px;
|
z-index: 2;
|
||||||
color: rgba(255,255,255,0.3); text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
.cover-body { position: relative; z-index: 1; }
|
.cover-body { position: relative; z-index: 1; }
|
||||||
.cover-circle {
|
.cover-circle {
|
||||||
@@ -316,11 +336,17 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<!-- Footer on every page -->
|
||||||
|
<div class="pdf-footer">
|
||||||
|
<span>made with</span>
|
||||||
|
<img src="${absUrl('/logo-dark.svg')}" style="height:10px;opacity:0.6;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Cover -->
|
<!-- Cover -->
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(coverImg)}')"></div>` : ''}
|
${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(coverImg)}')"></div>` : ''}
|
||||||
<div class="cover-dim"></div>
|
<div class="cover-dim"></div>
|
||||||
<div class="cover-brand">NOMAD</div>
|
<div class="cover-brand"><img src="${absUrl('/logo-light.svg')}" style="height:28px;opacity:0.5;" /></div>
|
||||||
<div class="cover-body">
|
<div class="cover-body">
|
||||||
${coverImg
|
${coverImg
|
||||||
? `<div class="cover-circle"><img src="${escHtml(coverImg)}" /></div>`
|
? `<div class="cover-circle"><img src="${escHtml(coverImg)}" /></div>`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Modal from '../shared/Modal'
|
|||||||
import { mapsApi, tagsApi, categoriesApi } from '../../api/client'
|
import { mapsApi, tagsApi, categoriesApi } from '../../api/client'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
import { Search, Plus, MapPin, Loader } from 'lucide-react'
|
import { Search, Plus, MapPin, Loader } from 'lucide-react'
|
||||||
|
|
||||||
const STATUSES = [
|
const STATUSES = [
|
||||||
@@ -23,7 +24,8 @@ export default function PlaceFormModal({
|
|||||||
onTagCreated,
|
onTagCreated,
|
||||||
}) {
|
}) {
|
||||||
const isEditing = !!place
|
const isEditing = !!place
|
||||||
const { user } = useAuthStore()
|
const { user, hasMapsKey } = useAuthStore()
|
||||||
|
const { t } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const [categories, setCategories] = useState(initialCategories)
|
const [categories, setCategories] = useState(initialCategories)
|
||||||
@@ -124,14 +126,17 @@ export default function PlaceFormModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [searchSource, setSearchSource] = useState(null)
|
||||||
|
|
||||||
const handleMapSearch = async () => {
|
const handleMapSearch = async () => {
|
||||||
if (!mapQuery.trim()) return
|
if (!mapQuery.trim()) return
|
||||||
setMapSearching(true)
|
setMapSearching(true)
|
||||||
try {
|
try {
|
||||||
const data = await mapsApi.search(mapQuery)
|
const data = await mapsApi.search(mapQuery)
|
||||||
setMapResults(data.places || [])
|
setMapResults(data.places || [])
|
||||||
|
setSearchSource(data.source || 'google')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.response?.data?.error || 'Maps search failed')
|
toast.error(err.response?.data?.error || t('places.mapsSearchError'))
|
||||||
} finally {
|
} finally {
|
||||||
setMapSearching(false)
|
setMapSearching(false)
|
||||||
}
|
}
|
||||||
@@ -218,9 +223,13 @@ export default function PlaceFormModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Google Maps search — always visible when API key is set */}
|
{/* Place search — Google Maps or OpenStreetMap fallback */}
|
||||||
{user?.maps_api_key && (
|
|
||||||
<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 && (
|
||||||
|
<p className="mb-2 text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
{t('places.osmActive')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
@@ -229,7 +238,7 @@ export default function PlaceFormModal({
|
|||||||
value={mapQuery}
|
value={mapQuery}
|
||||||
onChange={e => setMapQuery(e.target.value)}
|
onChange={e => setMapQuery(e.target.value)}
|
||||||
onKeyDown={e => e.key === 'Enter' && handleMapSearch()}
|
onKeyDown={e => e.key === 'Enter' && handleMapSearch()}
|
||||||
placeholder="Google Maps suchen..."
|
placeholder={t('places.mapsSearchPlaceholder')}
|
||||||
className="w-full pl-8 pr-3 py-2 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
|
className="w-full pl-8 pr-3 py-2 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,7 +247,7 @@ export default function PlaceFormModal({
|
|||||||
disabled={mapSearching}
|
disabled={mapSearching}
|
||||||
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-50"
|
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{mapSearching ? <Loader className="w-4 h-4 animate-spin" /> : 'Suchen'}
|
{mapSearching ? <Loader className="w-4 h-4 animate-spin" /> : t('common.search')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -263,7 +272,6 @@ export default function PlaceFormModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, AlertCircle, CheckCircle2, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown } from 'lucide-react'
|
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, AlertCircle, CheckCircle2, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock } from 'lucide-react'
|
||||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
@@ -79,7 +79,7 @@ export default function DayPlanSidebar({
|
|||||||
onAddReservation,
|
onAddReservation,
|
||||||
}) {
|
}) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||||
const tripStore = useTripStore()
|
const tripStore = useTripStore()
|
||||||
|
|
||||||
@@ -97,6 +97,8 @@ export default function DayPlanSidebar({
|
|||||||
const [isCalculating, setIsCalculating] = useState(false)
|
const [isCalculating, setIsCalculating] = useState(false)
|
||||||
const [routeInfo, setRouteInfo] = useState(null)
|
const [routeInfo, setRouteInfo] = useState(null)
|
||||||
const [draggingId, setDraggingId] = useState(null)
|
const [draggingId, setDraggingId] = useState(null)
|
||||||
|
const [lockedIds, setLockedIds] = useState(new Set())
|
||||||
|
const [lockHoverId, setLockHoverId] = useState(null)
|
||||||
const [dropTargetKey, setDropTargetKey] = useState(null)
|
const [dropTargetKey, setDropTargetKey] = useState(null)
|
||||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
const [dragOverDayId, setDragOverDayId] = useState(null)
|
||||||
const [hoveredId, setHoveredId] = useState(null)
|
const [hoveredId, setHoveredId] = useState(null)
|
||||||
@@ -205,16 +207,17 @@ export default function DayPlanSidebar({
|
|||||||
catch (err) { toast.error(err.message) }
|
catch (err) { toast.error(err.message) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId) => {
|
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
|
||||||
const m = getMergedItems(dayId)
|
const m = getMergedItems(dayId)
|
||||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
||||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
||||||
|
|
||||||
// Neue Reihenfolge erstellen — VOR dem Ziel einfügen (Standardkonvention)
|
// Neue Reihenfolge erstellen — VOR dem Ziel einfügen (Standard), oder NACH dem Ziel wenn insertAfter
|
||||||
const newOrder = [...m]
|
const newOrder = [...m]
|
||||||
const [moved] = newOrder.splice(fromIdx, 1)
|
const [moved] = newOrder.splice(fromIdx, 1)
|
||||||
const adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx
|
let adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx
|
||||||
|
if (insertAfter) adjustedTo += 1
|
||||||
newOrder.splice(adjustedTo, 0, moved)
|
newOrder.splice(adjustedTo, 0, moved)
|
||||||
|
|
||||||
// Orte: neuer order_index über onReorder
|
// Orte: neuer order_index über onReorder
|
||||||
@@ -290,15 +293,44 @@ export default function DayPlanSidebar({
|
|||||||
finally { setIsCalculating(false) }
|
finally { setIsCalculating(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleLock = (assignmentId) => {
|
||||||
|
setLockedIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(assignmentId)) next.delete(assignmentId)
|
||||||
|
else next.add(assignmentId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleOptimize = async () => {
|
const handleOptimize = async () => {
|
||||||
if (!selectedDayId) return
|
if (!selectedDayId) return
|
||||||
const da = getDayAssignments(selectedDayId)
|
const da = getDayAssignments(selectedDayId)
|
||||||
if (da.length < 3) return
|
if (da.length < 3) return
|
||||||
const withCoords = da.map(a => a.place).filter(p => p?.lat && p?.lng)
|
|
||||||
const optimized = optimizeRoute(withCoords)
|
// Separate locked (stay at their index) and unlocked assignments
|
||||||
const reorderedIds = optimized.map(p => da.find(a => a.place?.id === p.id)?.id).filter(Boolean)
|
const locked = new Map() // index -> assignment
|
||||||
for (const a of da) { if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id) }
|
const unlocked = []
|
||||||
await onReorder(selectedDayId, reorderedIds)
|
da.forEach((a, i) => {
|
||||||
|
if (lockedIds.has(a.id)) locked.set(i, a)
|
||||||
|
else unlocked.push(a)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Optimize only unlocked places
|
||||||
|
const unlockedWithCoords = unlocked.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||||
|
const optimized = unlockedWithCoords.length >= 2 ? optimizeRoute(unlockedWithCoords) : unlockedWithCoords
|
||||||
|
const optimizedQueue = optimized.map(p => unlocked.find(a => a.place?.id === p.id)).filter(Boolean)
|
||||||
|
// Add unlocked without coords at the end
|
||||||
|
for (const a of unlocked) { if (!optimizedQueue.includes(a)) optimizedQueue.push(a) }
|
||||||
|
|
||||||
|
// Merge: locked stay at their index, fill gaps with optimized
|
||||||
|
const result = new Array(da.length)
|
||||||
|
locked.forEach((a, i) => { result[i] = a })
|
||||||
|
let qi = 0
|
||||||
|
for (let i = 0; i < result.length; i++) {
|
||||||
|
if (!result[i]) result[i] = optimizedQueue[qi++]
|
||||||
|
}
|
||||||
|
|
||||||
|
await onReorder(selectedDayId, result.map(a => a.id))
|
||||||
toast.success(t('dayplan.toast.routeOptimized'))
|
toast.success(t('dayplan.toast.routeOptimized'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,7 +462,7 @@ export default function DayPlanSidebar({
|
|||||||
outlineOffset: -2,
|
outlineOffset: -2,
|
||||||
borderRadius: isDragTarget ? 8 : 0,
|
borderRadius: isDragTarget ? 8 : 0,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
|
||||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
|
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
|
||||||
>
|
>
|
||||||
{/* Tages-Badge */}
|
{/* Tages-Badge */}
|
||||||
@@ -504,7 +536,7 @@ export default function DayPlanSidebar({
|
|||||||
{/* Aufgeklappte Orte + Notizen */}
|
{/* Aufgeklappte Orte + Notizen */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div
|
<div
|
||||||
style={{ background: 'var(--bg-hover)' }}
|
style={{ background: 'var(--bg-hover)', paddingTop: 6 }}
|
||||||
onDragOver={e => { e.preventDefault(); if (draggingId) setDropTargetKey(`end-${day.id}`) }}
|
onDragOver={e => { e.preventDefault(); if (draggingId) setDropTargetKey(`end-${day.id}`) }}
|
||||||
onDrop={e => {
|
onDrop={e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -522,9 +554,9 @@ export default function DayPlanSidebar({
|
|||||||
if (m.length === 0) return
|
if (m.length === 0) return
|
||||||
const lastItem = m[m.length - 1]
|
const lastItem = m[m.length - 1]
|
||||||
if (assignmentId && String(lastItem?.data?.id) !== assignmentId)
|
if (assignmentId && String(lastItem?.data?.id) !== assignmentId)
|
||||||
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id)
|
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true)
|
||||||
else if (noteId && String(lastItem?.data?.id) !== noteId)
|
else if (noteId && String(lastItem?.data?.id) !== noteId)
|
||||||
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id)
|
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{merged.length === 0 && !dayNoteUi ? (
|
{merged.length === 0 && !dayNoteUi ? (
|
||||||
@@ -582,7 +614,7 @@ export default function DayPlanSidebar({
|
|||||||
dragDataRef.current = { assignmentId: String(assignment.id), fromDayId: String(day.id) }
|
dragDataRef.current = { assignmentId: String(assignment.id), fromDayId: String(day.id) }
|
||||||
setDraggingId(assignment.id)
|
setDraggingId(assignment.id)
|
||||||
}}
|
}}
|
||||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); setDropTargetKey(`place-${assignment.id}`) }}
|
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); if (dropTargetKey !== `place-${assignment.id}`) setDropTargetKey(`place-${assignment.id}`) }}
|
||||||
onDrop={e => {
|
onDrop={e => {
|
||||||
e.preventDefault(); e.stopPropagation()
|
e.preventDefault(); e.stopPropagation()
|
||||||
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
|
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
|
||||||
@@ -607,25 +639,61 @@ export default function DayPlanSidebar({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||||
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id); if (!isPlaceSelected) onSelectDay(day.id) }}
|
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
|
||||||
onMouseEnter={() => setHoveredId(assignment.id)}
|
onMouseEnter={() => setHoveredId(assignment.id)}
|
||||||
onMouseLeave={() => setHoveredId(null)}
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
padding: '7px 8px 7px 10px',
|
padding: '7px 8px 7px 10px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
background: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
|
background: lockedIds.has(assignment.id)
|
||||||
borderLeft: hasReservation
|
? 'rgba(220,38,38,0.08)'
|
||||||
? `3px solid ${isConfirmed ? '#10b981' : '#f59e0b'}`
|
: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
|
||||||
: '3px solid transparent',
|
borderLeft: lockedIds.has(assignment.id)
|
||||||
transition: 'background 0.1s',
|
? '3px solid #dc2626'
|
||||||
|
: hasReservation
|
||||||
|
? `3px solid ${isConfirmed ? '#10b981' : '#f59e0b'}`
|
||||||
|
: '3px solid transparent',
|
||||||
|
transition: 'background 0.15s, border-color 0.15s',
|
||||||
opacity: isDraggingThis ? 0.4 : 1,
|
opacity: isDraggingThis ? 0.4 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||||
<GripVertical size={13} strokeWidth={1.8} />
|
<GripVertical size={13} strokeWidth={1.8} />
|
||||||
</div>
|
</div>
|
||||||
<PlaceAvatar place={place} category={cat} size={28} />
|
<div
|
||||||
|
onClick={e => { e.stopPropagation(); toggleLock(assignment.id) }}
|
||||||
|
onMouseEnter={e => { e.stopPropagation(); setLockHoverId(assignment.id) }}
|
||||||
|
onMouseLeave={() => setLockHoverId(null)}
|
||||||
|
style={{ position: 'relative', flexShrink: 0, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<PlaceAvatar place={place} category={cat} size={28} />
|
||||||
|
{/* Hover/locked overlay */}
|
||||||
|
{(lockHoverId === assignment.id || lockedIds.has(assignment.id)) && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, borderRadius: '50%',
|
||||||
|
background: lockedIds.has(assignment.id) ? 'rgba(220,38,38,0.6)' : 'rgba(220,38,38,0.4)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}>
|
||||||
|
<Lock size={14} strokeWidth={2.5} style={{ color: 'white', filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Custom tooltip */}
|
||||||
|
{lockHoverId === assignment.id && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: '100%', top: '50%', transform: 'translateY(-50%)',
|
||||||
|
marginLeft: 8, whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 50,
|
||||||
|
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||||
|
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||||
|
}}>
|
||||||
|
{lockedIds.has(assignment.id)
|
||||||
|
? (language === 'de' ? 'Klicken zum Entsperren' : 'Click to unlock')
|
||||||
|
: (language === 'de' ? 'Position bei Routenoptimierung beibehalten' : 'Keep position during route optimization')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
|
||||||
{cat && (() => {
|
{cat && (() => {
|
||||||
@@ -686,7 +754,7 @@ export default function DayPlanSidebar({
|
|||||||
draggable
|
draggable
|
||||||
onDragStart={e => { e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
|
onDragStart={e => { e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
|
||||||
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDropTargetKey(`note-${note.id}`) }}
|
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
||||||
onDrop={e => {
|
onDrop={e => {
|
||||||
e.preventDefault(); e.stopPropagation()
|
e.preventDefault(); e.stopPropagation()
|
||||||
const { noteId: fromNoteId, assignmentId: fromAssignmentId, fromDayId } = getDragData(e)
|
const { noteId: fromNoteId, assignmentId: fromAssignmentId, fromDayId } = getDragData(e)
|
||||||
@@ -749,10 +817,40 @@ export default function DayPlanSidebar({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
{/* Drop-Indikator am Listenende */}
|
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
|
||||||
{!!draggingId && dropTargetKey === `end-${day.id}` && (
|
<div
|
||||||
<div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />
|
style={{ minHeight: 12, padding: '2px 8px' }}
|
||||||
)}
|
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `end-${day.id}`) setDropTargetKey(`end-${day.id}`) }}
|
||||||
|
onDrop={e => {
|
||||||
|
e.preventDefault(); e.stopPropagation()
|
||||||
|
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||||
|
// Neuer Ort von der Orte-Liste
|
||||||
|
if (placeId) {
|
||||||
|
onAssignToDay?.(parseInt(placeId), day.id)
|
||||||
|
setDropTargetKey(null); window.__dragData = null; return
|
||||||
|
}
|
||||||
|
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||||
|
if (assignmentId && fromDayId !== day.id) {
|
||||||
|
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch(err => toast.error(err.message))
|
||||||
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||||
|
}
|
||||||
|
if (noteId && fromDayId !== day.id) {
|
||||||
|
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch(err => toast.error(err.message))
|
||||||
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||||
|
}
|
||||||
|
const m = getMergedItems(day.id)
|
||||||
|
if (m.length === 0) return
|
||||||
|
const lastItem = m[m.length - 1]
|
||||||
|
if (assignmentId && String(lastItem?.data?.id) !== assignmentId)
|
||||||
|
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true)
|
||||||
|
else if (noteId && String(lastItem?.data?.id) !== noteId)
|
||||||
|
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dropTargetKey === `end-${day.id}` && (
|
||||||
|
<div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1 }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
||||||
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
||||||
@@ -778,15 +876,6 @@ export default function DayPlanSidebar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
<button onClick={handleCalculateRoute} disabled={isCalculating} style={{
|
|
||||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
|
||||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
|
||||||
background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
opacity: isCalculating ? 0.6 : 1,
|
|
||||||
}}>
|
|
||||||
<Navigation size={12} strokeWidth={2} />
|
|
||||||
{isCalculating ? t('dayplan.calculating') : t('dayplan.route')}
|
|
||||||
</button>
|
|
||||||
<button onClick={handleOptimize} style={{
|
<button onClick={handleOptimize} style={{
|
||||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } 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 { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Search } from 'lucide-react'
|
import { Search } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
@@ -44,6 +45,7 @@ export default function PlaceFormModal({
|
|||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, language } = useTranslation()
|
const { t, language } = useTranslation()
|
||||||
|
const { hasMapsKey } = useAuthStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (place) {
|
if (place) {
|
||||||
@@ -139,8 +141,13 @@ export default function PlaceFormModal({
|
|||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* Google Maps 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 && (
|
||||||
|
<p className="mb-2 text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
{t('places.osmActive')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -293,9 +300,9 @@ export default function PlaceFormModal({
|
|||||||
|
|
||||||
{/* Reservation */}
|
{/* Reservation */}
|
||||||
<div className="border border-gray-200 rounded-xl p-3 space-y-3">
|
<div className="border border-gray-200 rounded-xl p-3 space-y-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3">
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('places.formReservation')}</label>
|
<label className="block text-sm font-medium text-gray-700 shrink-0">{t('places.formReservation')}</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{['none', 'pending', 'confirmed'].map(status => (
|
{['none', 'pending', 'confirmed'].map(status => (
|
||||||
<button
|
<button
|
||||||
key={status}
|
key={status}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import React, { useMemo, useState, useCallback } from 'react'
|
||||||
|
import { useVacayStore } from '../../store/vacayStore'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { isWeekend } from './holidays'
|
||||||
|
import VacayMonthCard from './VacayMonthCard'
|
||||||
|
import { Building2, MousePointer2 } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function VacayCalendar() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays } = useVacayStore()
|
||||||
|
const [companyMode, setCompanyMode] = useState(false)
|
||||||
|
|
||||||
|
const companyHolidaySet = useMemo(() => {
|
||||||
|
const s = new Set()
|
||||||
|
companyHolidays.forEach(h => s.add(h.date))
|
||||||
|
return s
|
||||||
|
}, [companyHolidays])
|
||||||
|
|
||||||
|
const entryMap = useMemo(() => {
|
||||||
|
const map = {}
|
||||||
|
entries.forEach(e => {
|
||||||
|
if (!map[e.date]) map[e.date] = []
|
||||||
|
map[e.date].push(e)
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
}, [entries])
|
||||||
|
|
||||||
|
const blockWeekends = plan?.block_weekends !== false
|
||||||
|
const companyHolidaysEnabled = plan?.company_holidays_enabled !== false
|
||||||
|
|
||||||
|
const handleCellClick = useCallback(async (dateStr) => {
|
||||||
|
if (companyMode) {
|
||||||
|
if (!companyHolidaysEnabled) return
|
||||||
|
await toggleCompanyHoliday(dateStr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (holidays[dateStr]) return
|
||||||
|
if (blockWeekends && isWeekend(dateStr)) return
|
||||||
|
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
|
||||||
|
await toggleEntry(dateStr, selectedUserId || undefined)
|
||||||
|
}, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId])
|
||||||
|
|
||||||
|
const selectedUser = users.find(u => u.id === selectedUserId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => (
|
||||||
|
<VacayMonthCard
|
||||||
|
key={i}
|
||||||
|
year={selectedYear}
|
||||||
|
month={i}
|
||||||
|
holidays={holidays}
|
||||||
|
companyHolidaySet={companyHolidaySet}
|
||||||
|
companyHolidaysEnabled={companyHolidaysEnabled}
|
||||||
|
entryMap={entryMap}
|
||||||
|
onCellClick={handleCellClick}
|
||||||
|
companyMode={companyMode}
|
||||||
|
blockWeekends={blockWeekends}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating toolbar */}
|
||||||
|
<div className="sticky bottom-3 sm:bottom-4 mt-3 sm:mt-4 flex items-center justify-center z-30 px-2">
|
||||||
|
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setCompanyMode(false)}
|
||||||
|
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-all"
|
||||||
|
style={{
|
||||||
|
background: !companyMode ? 'var(--text-primary)' : 'transparent',
|
||||||
|
color: !companyMode ? 'var(--bg-card)' : 'var(--text-muted)',
|
||||||
|
border: companyMode ? '1px solid var(--border-primary)' : '1px solid transparent',
|
||||||
|
}}>
|
||||||
|
<MousePointer2 size={13} />
|
||||||
|
{selectedUser && <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: selectedUser.color }} />}
|
||||||
|
{selectedUser ? selectedUser.username : t('vacay.modeVacation')}
|
||||||
|
</button>
|
||||||
|
{companyHolidaysEnabled && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCompanyMode(true)}
|
||||||
|
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-all"
|
||||||
|
style={{
|
||||||
|
background: companyMode ? '#d97706' : 'transparent',
|
||||||
|
color: companyMode ? '#fff' : 'var(--text-muted)',
|
||||||
|
border: !companyMode ? '1px solid var(--border-primary)' : '1px solid transparent',
|
||||||
|
}}>
|
||||||
|
<Building2 size={13} />
|
||||||
|
{t('vacay.modeCompany')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { isWeekend } from './holidays'
|
||||||
|
|
||||||
|
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||||
|
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||||
|
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
||||||
|
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
||||||
|
|
||||||
|
export default function VacayMonthCard({
|
||||||
|
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
|
||||||
|
onCellClick, companyMode, blockWeekends
|
||||||
|
}) {
|
||||||
|
const { language } = useTranslation()
|
||||||
|
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN
|
||||||
|
const monthNames = language === 'de' ? MONTHS_DE : MONTHS_EN
|
||||||
|
|
||||||
|
const weeks = useMemo(() => {
|
||||||
|
const firstDay = new Date(year, month, 1)
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||||
|
let startDow = firstDay.getDay() - 1
|
||||||
|
if (startDow < 0) startDow = 6
|
||||||
|
const cells = []
|
||||||
|
for (let i = 0; i < startDow; i++) cells.push(null)
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
||||||
|
while (cells.length % 7 !== 0) cells.push(null)
|
||||||
|
const w = []
|
||||||
|
for (let i = 0; i < cells.length; i += 7) w.push(cells.slice(i, i + 7))
|
||||||
|
return w
|
||||||
|
}, [year, month])
|
||||||
|
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
<div className="px-3 py-2 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
|
<span className="text-xs font-semibold" style={{ color: 'var(--text-primary)' }}>{monthNames[month]}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
|
{weekdays.map((wd, i) => (
|
||||||
|
<div key={wd} className="text-center text-[10px] font-medium py-1" style={{ color: i >= 5 ? 'var(--text-faint)' : 'var(--text-muted)' }}>
|
||||||
|
{wd}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{weeks.map((week, wi) => (
|
||||||
|
<div key={wi} className="grid grid-cols-7">
|
||||||
|
{week.map((day, di) => {
|
||||||
|
if (day === null) return <div key={di} style={{ height: 28 }} />
|
||||||
|
|
||||||
|
const dateStr = `${year}-${pad(month + 1)}-${pad(day)}`
|
||||||
|
const weekend = di >= 5
|
||||||
|
const holiday = holidays[dateStr]
|
||||||
|
const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr)
|
||||||
|
const dayEntries = entryMap[dateStr] || []
|
||||||
|
const isBlocked = !!holiday || (weekend && blockWeekends) || (isCompany && !companyMode)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={di}
|
||||||
|
className="relative flex items-center justify-center cursor-pointer transition-colors"
|
||||||
|
style={{
|
||||||
|
height: 28,
|
||||||
|
background: weekend ? 'var(--bg-secondary)' : 'transparent',
|
||||||
|
borderTop: '1px solid var(--border-secondary)',
|
||||||
|
borderRight: '1px solid var(--border-secondary)',
|
||||||
|
cursor: isBlocked ? 'default' : 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => onCellClick(dateStr)}
|
||||||
|
onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = weekend ? 'var(--bg-secondary)' : 'transparent' }}
|
||||||
|
>
|
||||||
|
{holiday && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(239,68,68,0.12)' }} />}
|
||||||
|
{isCompany && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(245,158,11,0.15)' }} />}
|
||||||
|
|
||||||
|
{dayEntries.length === 1 && (
|
||||||
|
<div className="absolute inset-0.5 rounded" style={{ backgroundColor: dayEntries[0].person_color, opacity: 0.4 }} />
|
||||||
|
)}
|
||||||
|
{dayEntries.length === 2 && (
|
||||||
|
<div className="absolute inset-0.5 rounded" style={{
|
||||||
|
background: `linear-gradient(135deg, ${dayEntries[0].person_color} 50%, ${dayEntries[1].person_color} 50%)`,
|
||||||
|
opacity: 0.4,
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
{dayEntries.length === 3 && (
|
||||||
|
<div className="absolute inset-0.5 rounded overflow-hidden" style={{ opacity: 0.4 }}>
|
||||||
|
<div className="absolute top-0 left-0 w-1/2 h-full" style={{ backgroundColor: dayEntries[0].person_color }} />
|
||||||
|
<div className="absolute top-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[1].person_color }} />
|
||||||
|
<div className="absolute bottom-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[2].person_color }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dayEntries.length >= 4 && (
|
||||||
|
<div className="absolute inset-0.5 rounded overflow-hidden" style={{ opacity: 0.4 }}>
|
||||||
|
<div className="absolute top-0 left-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[0].person_color }} />
|
||||||
|
<div className="absolute top-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[1].person_color }} />
|
||||||
|
<div className="absolute bottom-0 left-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[2].person_color }} />
|
||||||
|
<div className="absolute bottom-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[3].person_color }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="relative z-[1] text-[11px] font-medium" style={{
|
||||||
|
color: holiday ? '#dc2626' : weekend ? 'var(--text-faint)' : 'var(--text-primary)',
|
||||||
|
fontWeight: dayEntries.length > 0 ? 700 : 500,
|
||||||
|
}}>
|
||||||
|
{day}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import { UserPlus, Unlink, Check, Loader2, Clock, X } from 'lucide-react'
|
||||||
|
import { useVacayStore } from '../../store/vacayStore'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
import apiClient from '../../api/client'
|
||||||
|
|
||||||
|
const PRESET_COLORS = [
|
||||||
|
'#6366f1', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444',
|
||||||
|
'#3b82f6', '#22c55e', '#06b6d4', '#f43f5e', '#a855f7',
|
||||||
|
'#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488',
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function VacayPersons() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
|
const { users, pendingInvites, invite, cancelInvite, updateColor, selectedUserId, setSelectedUserId, isFused } = useVacayStore()
|
||||||
|
const { user: currentUser } = useAuthStore()
|
||||||
|
|
||||||
|
// Default selectedUserId to current user
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedUserId && currentUser) setSelectedUserId(currentUser.id)
|
||||||
|
}, [currentUser, selectedUserId])
|
||||||
|
const [showInvite, setShowInvite] = useState(false)
|
||||||
|
const [showColorPicker, setShowColorPicker] = useState(false)
|
||||||
|
const [colorEditUserId, setColorEditUserId] = useState(null)
|
||||||
|
const [availableUsers, setAvailableUsers] = useState([])
|
||||||
|
const [selectedInviteUser, setSelectedInviteUser] = useState(null)
|
||||||
|
const [inviting, setInviting] = useState(false)
|
||||||
|
|
||||||
|
const loadAvailable = async () => {
|
||||||
|
try {
|
||||||
|
const data = await apiClient.get('/addons/vacay/available-users').then(r => r.data)
|
||||||
|
setAvailableUsers(data.users)
|
||||||
|
} catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInvite = async () => {
|
||||||
|
if (!selectedInviteUser) return
|
||||||
|
setInviting(true)
|
||||||
|
try {
|
||||||
|
await invite(selectedInviteUser)
|
||||||
|
toast.success(t('vacay.inviteSent'))
|
||||||
|
setShowInvite(false)
|
||||||
|
setSelectedInviteUser(null)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.error || t('vacay.inviteError'))
|
||||||
|
} finally {
|
||||||
|
setInviting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleColorChange = async (color) => {
|
||||||
|
await updateColor(color, colorEditUserId)
|
||||||
|
setShowColorPicker(false)
|
||||||
|
setColorEditUserId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const editingUserColor = users.find(u => u.id === colorEditUserId)?.color || '#6366f1'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>{t('vacay.persons')}</span>
|
||||||
|
<button onClick={() => { setShowInvite(true); loadAvailable() }}
|
||||||
|
className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<UserPlus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{users.map(u => {
|
||||||
|
const isSelected = selectedUserId === u.id
|
||||||
|
return (
|
||||||
|
<div key={u.id}
|
||||||
|
onClick={() => { if (isFused) setSelectedUserId(u.id) }}
|
||||||
|
className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group transition-all"
|
||||||
|
style={{
|
||||||
|
background: isSelected ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
border: isSelected ? '1px solid var(--border-primary)' : '1px solid transparent',
|
||||||
|
cursor: isFused ? 'pointer' : 'default',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setColorEditUserId(u.id); setShowColorPicker(true) }}
|
||||||
|
className="w-3.5 h-3.5 rounded-full shrink-0 transition-transform hover:scale-125"
|
||||||
|
style={{ backgroundColor: u.color, cursor: 'pointer' }}
|
||||||
|
title={t('vacay.changeColor')}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-medium flex-1 truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{u.username}
|
||||||
|
{u.id === currentUser?.id && <span style={{ color: 'var(--text-faint)' }}> ({t('vacay.you')})</span>}
|
||||||
|
</span>
|
||||||
|
{isSelected && isFused && (
|
||||||
|
<Check size={12} style={{ color: 'var(--text-primary)' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Pending invites */}
|
||||||
|
{pendingInvites.map(inv => (
|
||||||
|
<div key={inv.id} className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group"
|
||||||
|
style={{ background: 'var(--bg-secondary)', opacity: 0.7 }}>
|
||||||
|
<Clock size={12} style={{ color: 'var(--text-faint)' }} />
|
||||||
|
<span className="text-xs flex-1 truncate" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{inv.username} <span className="text-[10px]">({t('vacay.pending')})</span>
|
||||||
|
</span>
|
||||||
|
<button onClick={() => cancelInvite(inv.user_id)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-[10px] px-1.5 py-0.5 rounded transition-all"
|
||||||
|
style={{ color: 'var(--text-faint)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite Modal — Portal to body to avoid z-index issues */}
|
||||||
|
{showInvite && ReactDOM.createPortal(
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center px-4" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
|
||||||
|
onClick={() => setShowInvite(false)}>
|
||||||
|
<div className="rounded-2xl shadow-2xl w-full max-w-sm" style={{ background: 'var(--bg-card)', animation: 'modalIn 0.2s ease-out' }}
|
||||||
|
onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||||
|
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.inviteUser')}</h2>
|
||||||
|
<button onClick={() => setShowInvite(false)} className="p-1.5 rounded-lg transition-colors" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{t('vacay.inviteHint')}</p>
|
||||||
|
{availableUsers.length === 0 ? (
|
||||||
|
<p className="text-xs text-center py-4" style={{ color: 'var(--text-faint)' }}>{t('vacay.noUsersAvailable')}</p>
|
||||||
|
) : (
|
||||||
|
<CustomSelect
|
||||||
|
value={selectedInviteUser}
|
||||||
|
onChange={setSelectedInviteUser}
|
||||||
|
options={availableUsers.map(u => ({ value: u.id, label: `${u.username} (${u.email})` }))}
|
||||||
|
placeholder={t('vacay.selectUser')}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3 justify-end pt-2">
|
||||||
|
<button onClick={() => setShowInvite(false)} className="px-4 py-2 text-sm rounded-lg"
|
||||||
|
style={{ color: 'var(--text-muted)', border: '1px solid var(--border-primary)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={handleInvite} disabled={!selectedInviteUser || inviting}
|
||||||
|
className="px-4 py-2 text-sm rounded-lg transition-colors flex items-center gap-1.5 disabled:opacity-40"
|
||||||
|
style={{ background: 'var(--text-primary)', color: 'var(--bg-card)' }}>
|
||||||
|
{inviting && <Loader2 size={13} className="animate-spin" />}
|
||||||
|
{t('vacay.sendInvite')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Color Picker Modal — Portal to body */}
|
||||||
|
{showColorPicker && ReactDOM.createPortal(
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center px-4" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
|
||||||
|
onClick={() => { setShowColorPicker(false); setColorEditUserId(null) }}>
|
||||||
|
<div className="rounded-2xl shadow-2xl w-full max-w-xs" style={{ background: 'var(--bg-card)', animation: 'modalIn 0.2s ease-out' }}
|
||||||
|
onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||||
|
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.changeColor')}</h2>
|
||||||
|
<button onClick={() => { setShowColorPicker(false); setColorEditUserId(null) }} className="p-1.5 rounded-lg transition-colors" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex flex-wrap gap-2 justify-center">
|
||||||
|
{PRESET_COLORS.map(c => (
|
||||||
|
<button key={c} onClick={() => handleColorChange(c)}
|
||||||
|
className={`w-8 h-8 rounded-full transition-all ${editingUserColor === c ? 'ring-2 ring-offset-2 scale-110' : 'hover:scale-110'}`}
|
||||||
|
style={{ backgroundColor: c }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { MapPin, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe } from 'lucide-react'
|
||||||
|
import { useVacayStore } from '../../store/vacayStore'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
import apiClient from '../../api/client'
|
||||||
|
|
||||||
|
export default function VacaySettings({ onClose }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
|
const { plan, updatePlan, isFused, dissolve, users } = useVacayStore()
|
||||||
|
const [countries, setCountries] = useState([])
|
||||||
|
const [regions, setRegions] = useState([])
|
||||||
|
const [loadingRegions, setLoadingRegions] = useState(false)
|
||||||
|
|
||||||
|
const { language } = useTranslation()
|
||||||
|
|
||||||
|
// Load available countries with localized names
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.get('/addons/vacay/holidays/countries').then(r => {
|
||||||
|
let displayNames
|
||||||
|
try { displayNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) } catch { /* */ }
|
||||||
|
const list = r.data.map(c => ({
|
||||||
|
value: c.countryCode,
|
||||||
|
label: displayNames ? (displayNames.of(c.countryCode) || c.name) : c.name,
|
||||||
|
}))
|
||||||
|
list.sort((a, b) => a.label.localeCompare(b.label))
|
||||||
|
setCountries(list)
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [language])
|
||||||
|
|
||||||
|
// When country changes, check if it has regions
|
||||||
|
const selectedCountry = plan?.holidays_region?.split('-')[0] || ''
|
||||||
|
const selectedRegion = plan?.holidays_region?.includes('-') ? plan.holidays_region : ''
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCountry || !plan?.holidays_enabled) { setRegions([]); return }
|
||||||
|
setLoadingRegions(true)
|
||||||
|
const year = new Date().getFullYear()
|
||||||
|
apiClient.get(`/addons/vacay/holidays/${year}/${selectedCountry}`).then(r => {
|
||||||
|
const allCounties = new Set()
|
||||||
|
r.data.forEach(h => {
|
||||||
|
if (h.counties) h.counties.forEach(c => allCounties.add(c))
|
||||||
|
})
|
||||||
|
if (allCounties.size > 0) {
|
||||||
|
let subdivisionNames
|
||||||
|
try { subdivisionNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) } catch { /* */ }
|
||||||
|
const regionList = [...allCounties].sort().map(c => {
|
||||||
|
let label = c.split('-')[1] || c
|
||||||
|
// Try Intl for full subdivision name (not all browsers support subdivision codes)
|
||||||
|
// Fallback: use known mappings for DE
|
||||||
|
if (c.startsWith('DE-')) {
|
||||||
|
const deRegions = { BW:'Baden-Württemberg',BY:'Bayern',BE:'Berlin',BB:'Brandenburg',HB:'Bremen',HH:'Hamburg',HE:'Hessen',MV:'Mecklenburg-Vorpommern',NI:'Niedersachsen',NW:'Nordrhein-Westfalen',RP:'Rheinland-Pfalz',SL:'Saarland',SN:'Sachsen',ST:'Sachsen-Anhalt',SH:'Schleswig-Holstein',TH:'Thüringen' }
|
||||||
|
label = deRegions[c.split('-')[1]] || label
|
||||||
|
} else if (c.startsWith('CH-')) {
|
||||||
|
const chRegions = { AG:'Aargau',AI:'Appenzell Innerrhoden',AR:'Appenzell Ausserrhoden',BE:'Bern',BL:'Basel-Landschaft',BS:'Basel-Stadt',FR:'Freiburg',GE:'Genf',GL:'Glarus',GR:'Graubünden',JU:'Jura',LU:'Luzern',NE:'Neuenburg',NW:'Nidwalden',OW:'Obwalden',SG:'St. Gallen',SH:'Schaffhausen',SO:'Solothurn',SZ:'Schwyz',TG:'Thurgau',TI:'Tessin',UR:'Uri',VD:'Waadt',VS:'Wallis',ZG:'Zug',ZH:'Zürich' }
|
||||||
|
label = chRegions[c.split('-')[1]] || label
|
||||||
|
}
|
||||||
|
return { value: c, label }
|
||||||
|
})
|
||||||
|
setRegions(regionList)
|
||||||
|
} else {
|
||||||
|
setRegions([])
|
||||||
|
// If no regions, just set country code as region
|
||||||
|
if (plan.holidays_region !== selectedCountry) {
|
||||||
|
updatePlan({ holidays_region: selectedCountry })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(() => setRegions([])).finally(() => setLoadingRegions(false))
|
||||||
|
}, [selectedCountry, plan?.holidays_enabled])
|
||||||
|
|
||||||
|
if (!plan) return null
|
||||||
|
|
||||||
|
const toggle = (key) => updatePlan({ [key]: !plan[key] })
|
||||||
|
|
||||||
|
const handleCountryChange = (countryCode) => {
|
||||||
|
updatePlan({ holidays_region: countryCode })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRegionChange = (regionCode) => {
|
||||||
|
updatePlan({ holidays_region: regionCode })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Block weekends */}
|
||||||
|
<SettingToggle
|
||||||
|
icon={CalendarOff}
|
||||||
|
label={t('vacay.blockWeekends')}
|
||||||
|
hint={t('vacay.blockWeekendsHint')}
|
||||||
|
value={plan.block_weekends}
|
||||||
|
onChange={() => toggle('block_weekends')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Carry-over */}
|
||||||
|
<SettingToggle
|
||||||
|
icon={ArrowRightLeft}
|
||||||
|
label={t('vacay.carryOver')}
|
||||||
|
hint={t('vacay.carryOverHint')}
|
||||||
|
value={plan.carry_over_enabled}
|
||||||
|
onChange={() => toggle('carry_over_enabled')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Company holidays */}
|
||||||
|
<div>
|
||||||
|
<SettingToggle
|
||||||
|
icon={Building2}
|
||||||
|
label={t('vacay.companyHolidays')}
|
||||||
|
hint={t('vacay.companyHolidaysHint')}
|
||||||
|
value={plan.company_holidays_enabled}
|
||||||
|
onChange={() => toggle('company_holidays_enabled')}
|
||||||
|
/>
|
||||||
|
{plan.company_holidays_enabled && (
|
||||||
|
<div className="ml-7 mt-2">
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<AlertCircle size={12} style={{ color: 'var(--text-faint)' }} />
|
||||||
|
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{t('vacay.companyHolidaysNoDeduct')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Public holidays */}
|
||||||
|
<div>
|
||||||
|
<SettingToggle
|
||||||
|
icon={Globe}
|
||||||
|
label={t('vacay.publicHolidays')}
|
||||||
|
hint={t('vacay.publicHolidaysHint')}
|
||||||
|
value={plan.holidays_enabled}
|
||||||
|
onChange={() => toggle('holidays_enabled')}
|
||||||
|
/>
|
||||||
|
{plan.holidays_enabled && (
|
||||||
|
<div className="ml-7 mt-2 space-y-2">
|
||||||
|
<CustomSelect
|
||||||
|
value={selectedCountry}
|
||||||
|
onChange={handleCountryChange}
|
||||||
|
options={countries}
|
||||||
|
placeholder={t('vacay.selectCountry')}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
{regions.length > 0 && (
|
||||||
|
<CustomSelect
|
||||||
|
value={selectedRegion}
|
||||||
|
onChange={handleRegionChange}
|
||||||
|
options={regions}
|
||||||
|
placeholder={t('vacay.selectRegion')}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dissolve fusion */}
|
||||||
|
{isFused && (
|
||||||
|
<div className="pt-4 mt-2 border-t" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
|
<div className="rounded-xl overflow-hidden" style={{ border: '1px solid rgba(239,68,68,0.2)' }}>
|
||||||
|
<div className="px-4 py-3 flex items-center gap-3" style={{ background: 'rgba(239,68,68,0.06)' }}>
|
||||||
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ background: 'rgba(239,68,68,0.1)' }}>
|
||||||
|
<Unlink size={16} className="text-red-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.dissolve')}</p>
|
||||||
|
<p className="text-[11px]" style={{ color: 'var(--text-faint)' }}>{t('vacay.dissolveHint')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 flex items-center gap-2 flex-wrap" style={{ borderTop: '1px solid rgba(239,68,68,0.1)' }}>
|
||||||
|
{users.map(u => (
|
||||||
|
<div key={u.id} className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: u.color || '#6366f1' }} />
|
||||||
|
<span className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>{u.username}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3" style={{ borderTop: '1px solid rgba(239,68,68,0.1)' }}>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await dissolve()
|
||||||
|
toast.success(t('vacay.dissolved'))
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 text-xs font-medium bg-red-500 hover:bg-red-600 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{t('vacay.dissolveAction')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingToggle({ icon: Icon, label, hint, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Icon size={15} className="shrink-0" style={{ color: 'var(--text-muted)' }} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
|
||||||
|
<p className="text-[11px]" style={{ color: 'var(--text-faint)' }}>{hint}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onChange}
|
||||||
|
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
style={{ background: value ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||||
|
<span className="absolute left-1 h-4 w-4 rounded-full transition-transform duration-200"
|
||||||
|
style={{ background: 'var(--bg-card)', transform: value ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Briefcase, Pencil } from 'lucide-react'
|
||||||
|
import { useVacayStore } from '../../store/vacayStore'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
|
export default function VacayStats() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { stats, selectedYear, loadStats, updateVacationDays, isFused } = useVacayStore()
|
||||||
|
const { user: currentUser } = useAuthStore()
|
||||||
|
|
||||||
|
useEffect(() => { loadStats(selectedYear) }, [selectedYear])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
<div className="flex items-center gap-1.5 mb-3">
|
||||||
|
<Briefcase size={13} style={{ color: 'var(--text-faint)' }} />
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
{t('vacay.entitlement')} {selectedYear}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-center py-3" style={{ color: 'var(--text-faint)' }}>{t('vacay.noData')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stats.map(s => (
|
||||||
|
<StatCard
|
||||||
|
key={s.user_id}
|
||||||
|
stat={s}
|
||||||
|
isMe={s.user_id === currentUser?.id}
|
||||||
|
canEdit={s.user_id === currentUser?.id || isFused}
|
||||||
|
selectedYear={selectedYear}
|
||||||
|
onSave={updateVacationDays}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [localDays, setLocalDays] = useState(s.vacation_days)
|
||||||
|
const pct = s.total_available > 0 ? Math.min(100, (s.used / s.total_available) * 100) : 0
|
||||||
|
|
||||||
|
// Sync local state when stats reload from server
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) setLocalDays(s.vacation_days)
|
||||||
|
}, [s.vacation_days, editing])
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
setEditing(false)
|
||||||
|
const days = parseInt(localDays)
|
||||||
|
if (!isNaN(days) && days >= 0 && days <= 365 && days !== s.vacation_days) {
|
||||||
|
onSave(selectedYear, days, s.user_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg p-2.5 space-y-2" style={{ border: '1px solid var(--border-secondary)' }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: s.person_color }} />
|
||||||
|
<span className="text-xs font-semibold flex-1 truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{s.person_name}
|
||||||
|
{isMe && <span style={{ color: 'var(--text-faint)' }}> ({t('vacay.you')})</span>}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] tabular-nums" style={{ color: 'var(--text-faint)' }}>{s.used}/{s.total_available}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${pct}%`, backgroundColor: s.person_color }} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{/* Days — editable */}
|
||||||
|
<div
|
||||||
|
className="rounded-md px-2 py-2 group/days"
|
||||||
|
style={{
|
||||||
|
background: canEdit ? 'var(--bg-card)' : 'var(--bg-secondary)',
|
||||||
|
border: canEdit ? '1px solid var(--border-primary)' : '1px solid transparent',
|
||||||
|
cursor: canEdit ? 'pointer' : 'default',
|
||||||
|
}}
|
||||||
|
onClick={() => { if (canEdit && !editing) setEditing(true) }}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] mb-1" style={{ color: 'var(--text-faint)', height: 14, lineHeight: '14px' }}>
|
||||||
|
{t('vacay.entitlementDays')} {canEdit && !editing && <Pencil size={9} className="inline opacity-0 group-hover/days:opacity-100 transition-opacity" style={{ color: 'var(--text-faint)', verticalAlign: 'middle' }} />}
|
||||||
|
</div>
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={localDays}
|
||||||
|
onChange={e => setLocalDays(e.target.value)}
|
||||||
|
onBlur={handleSave}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') { setEditing(false); setLocalDays(s.vacation_days) } }}
|
||||||
|
autoFocus
|
||||||
|
className="w-full bg-transparent text-sm font-bold outline-none p-0 m-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
style={{ color: 'var(--text-primary)', height: 18, lineHeight: '18px' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm font-bold" style={{ color: 'var(--text-primary)', height: 18, lineHeight: '18px' }}>{s.vacation_days}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Used */}
|
||||||
|
<div className="rounded-md px-2 py-2" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<div className="text-[10px] mb-1" style={{ color: 'var(--text-faint)', height: 14, lineHeight: '14px' }}>{t('vacay.used')}</div>
|
||||||
|
<div className="text-sm font-bold" style={{ color: 'var(--text-primary)', height: 18, lineHeight: '18px' }}>{s.used}</div>
|
||||||
|
</div>
|
||||||
|
{/* Remaining */}
|
||||||
|
<div className="rounded-md px-2 py-2" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<div className="text-[10px] mb-1" style={{ color: 'var(--text-faint)', height: 14, lineHeight: '14px' }}>{t('vacay.remaining')}</div>
|
||||||
|
<div className="text-sm font-bold" style={{ color: s.remaining < 0 ? '#ef4444' : s.remaining <= 3 ? '#f59e0b' : '#22c55e', height: 18, lineHeight: '18px' }}>
|
||||||
|
{s.remaining}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{s.carried_over > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.15)' }}>
|
||||||
|
<span className="text-[10px]" style={{ color: '#d97706' }}>+{s.carried_over} {t('vacay.carriedOver', { year: selectedYear - 1 })}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// German public holidays (Feiertage) calculation per Bundesland
|
||||||
|
// Includes fixed and Easter-dependent movable holidays
|
||||||
|
|
||||||
|
const BUNDESLAENDER = {
|
||||||
|
BW: 'Baden-Württemberg',
|
||||||
|
BY: 'Bayern',
|
||||||
|
BE: 'Berlin',
|
||||||
|
BB: 'Brandenburg',
|
||||||
|
HB: 'Bremen',
|
||||||
|
HH: 'Hamburg',
|
||||||
|
HE: 'Hessen',
|
||||||
|
MV: 'Mecklenburg-Vorpommern',
|
||||||
|
NI: 'Niedersachsen',
|
||||||
|
NW: 'Nordrhein-Westfalen',
|
||||||
|
RP: 'Rheinland-Pfalz',
|
||||||
|
SL: 'Saarland',
|
||||||
|
SN: 'Sachsen',
|
||||||
|
ST: 'Sachsen-Anhalt',
|
||||||
|
SH: 'Schleswig-Holstein',
|
||||||
|
TH: 'Thüringen',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gauss Easter algorithm
|
||||||
|
function easterSunday(year) {
|
||||||
|
const a = year % 19;
|
||||||
|
const b = Math.floor(year / 100);
|
||||||
|
const c = year % 100;
|
||||||
|
const d = Math.floor(b / 4);
|
||||||
|
const e = b % 4;
|
||||||
|
const f = Math.floor((b + 8) / 25);
|
||||||
|
const g = Math.floor((b - f + 1) / 3);
|
||||||
|
const h = (19 * a + b - d - g + 15) % 30;
|
||||||
|
const i = Math.floor(c / 4);
|
||||||
|
const k = c % 4;
|
||||||
|
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||||
|
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||||
|
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
||||||
|
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||||
|
return new Date(year, month - 1, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date, days) {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setDate(d.getDate() + days);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(date) {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHolidays(year, bundesland = 'NW') {
|
||||||
|
const easter = easterSunday(year);
|
||||||
|
const holidays = {};
|
||||||
|
|
||||||
|
// Fixed holidays (nationwide)
|
||||||
|
holidays[`${year}-01-01`] = 'Neujahr';
|
||||||
|
holidays[`${year}-05-01`] = 'Tag der Arbeit';
|
||||||
|
holidays[`${year}-10-03`] = 'Tag der Deutschen Einheit';
|
||||||
|
holidays[`${year}-12-25`] = '1. Weihnachtsfeiertag';
|
||||||
|
holidays[`${year}-12-26`] = '2. Weihnachtsfeiertag';
|
||||||
|
|
||||||
|
// Easter-dependent (nationwide)
|
||||||
|
holidays[fmt(addDays(easter, -2))] = 'Karfreitag';
|
||||||
|
holidays[fmt(addDays(easter, 1))] = 'Ostermontag';
|
||||||
|
holidays[fmt(addDays(easter, 39))] = 'Christi Himmelfahrt';
|
||||||
|
holidays[fmt(addDays(easter, 50))] = 'Pfingstmontag';
|
||||||
|
|
||||||
|
// State-specific
|
||||||
|
const bl = bundesland.toUpperCase();
|
||||||
|
|
||||||
|
// Heilige Drei Könige (6. Jan) — BW, BY, ST
|
||||||
|
if (['BW', 'BY', 'ST'].includes(bl)) {
|
||||||
|
holidays[`${year}-01-06`] = 'Heilige Drei Könige';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internationaler Frauentag (8. März) — BE, MV
|
||||||
|
if (['BE', 'MV'].includes(bl)) {
|
||||||
|
holidays[`${year}-03-08`] = 'Internationaler Frauentag';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fronleichnam — BW, BY, HE, NW, RP, SL, SN (teilweise), TH (teilweise)
|
||||||
|
if (['BW', 'BY', 'HE', 'NW', 'RP', 'SL'].includes(bl)) {
|
||||||
|
holidays[fmt(addDays(easter, 60))] = 'Fronleichnam';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mariä Himmelfahrt (15. Aug) — SL, BY (teilweise)
|
||||||
|
if (['SL'].includes(bl)) {
|
||||||
|
holidays[`${year}-08-15`] = 'Mariä Himmelfahrt';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weltkindertag (20. Sep) — TH
|
||||||
|
if (bl === 'TH') {
|
||||||
|
holidays[`${year}-09-20`] = 'Weltkindertag';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reformationstag (31. Okt) — BB, HB, HH, MV, NI, SN, ST, SH, TH
|
||||||
|
if (['BB', 'HB', 'HH', 'MV', 'NI', 'SN', 'ST', 'SH', 'TH'].includes(bl)) {
|
||||||
|
holidays[`${year}-10-31`] = 'Reformationstag';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allerheiligen (1. Nov) — BW, BY, NW, RP, SL
|
||||||
|
if (['BW', 'BY', 'NW', 'RP', 'SL'].includes(bl)) {
|
||||||
|
holidays[`${year}-11-01`] = 'Allerheiligen';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buß- und Bettag — SN (Mittwoch vor dem 23. November)
|
||||||
|
if (bl === 'SN') {
|
||||||
|
const nov23 = new Date(year, 10, 23);
|
||||||
|
let bbt = new Date(nov23);
|
||||||
|
while (bbt.getDay() !== 3) bbt.setDate(bbt.getDate() - 1);
|
||||||
|
holidays[fmt(bbt)] = 'Buß- und Bettag';
|
||||||
|
}
|
||||||
|
|
||||||
|
return holidays;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWeekend(dateStr) {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
|
const day = d.getDay();
|
||||||
|
return day === 0 || day === 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWeekday(dateStr) {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
|
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getDay()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWeekdayFull(dateStr) {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
|
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getDay()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysInMonth(year, month) {
|
||||||
|
return new Date(year, month, 0).getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(dateStr) {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
|
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BUNDESLAENDER };
|
||||||
@@ -39,8 +39,8 @@ export default function Modal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center px-4 modal-backdrop"
|
className="fixed inset-0 z-50 flex items-start sm:items-center justify-center px-4 modal-backdrop"
|
||||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20 }}
|
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20, overflow: 'hidden' }}
|
||||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
|
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
|
||||||
@@ -50,7 +50,7 @@ export default function Modal({
|
|||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||||
flex flex-col max-h-[90vh]
|
flex flex-col max-h-[calc(100vh-90px)]
|
||||||
animate-in fade-in zoom-in-95 duration-200
|
animate-in fade-in zoom-in-95 duration-200
|
||||||
`}
|
`}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ const de = {
|
|||||||
'dashboard.subtitle.activeMany': '{count} aktive Reisen',
|
'dashboard.subtitle.activeMany': '{count} aktive Reisen',
|
||||||
'dashboard.subtitle.archivedSuffix': ' · {count} archiviert',
|
'dashboard.subtitle.archivedSuffix': ' · {count} archiviert',
|
||||||
'dashboard.newTrip': 'Neue Reise',
|
'dashboard.newTrip': 'Neue Reise',
|
||||||
|
'dashboard.currency': 'Währung',
|
||||||
|
'dashboard.timezone': 'Zeitzonen',
|
||||||
|
'dashboard.localTime': 'Lokal',
|
||||||
'dashboard.emptyTitle': 'Noch keine Reisen',
|
'dashboard.emptyTitle': 'Noch keine Reisen',
|
||||||
'dashboard.emptyText': 'Erstelle deine erste Reise und beginne mit der Planung von Orten, Tagesabläufen und Packlisten.',
|
'dashboard.emptyText': 'Erstelle deine erste Reise und beginne mit der Planung von Orten, Tagesabläufen und Packlisten.',
|
||||||
'dashboard.emptyButton': 'Erste Reise erstellen',
|
'dashboard.emptyButton': 'Erste Reise erstellen',
|
||||||
@@ -125,8 +128,24 @@ const de = {
|
|||||||
'settings.email': 'E-Mail',
|
'settings.email': 'E-Mail',
|
||||||
'settings.role': 'Rolle',
|
'settings.role': 'Rolle',
|
||||||
'settings.roleAdmin': 'Administrator',
|
'settings.roleAdmin': 'Administrator',
|
||||||
|
'settings.oidcLinked': 'Verknüpft mit',
|
||||||
|
'settings.changePassword': 'Passwort ändern',
|
||||||
|
'settings.currentPassword': 'Aktuelles Passwort',
|
||||||
|
'settings.newPassword': 'Neues Passwort',
|
||||||
|
'settings.confirmPassword': 'Neues Passwort bestätigen',
|
||||||
|
'settings.updatePassword': 'Passwort aktualisieren',
|
||||||
|
'settings.passwordRequired': 'Bitte aktuelles und neues Passwort eingeben',
|
||||||
|
'settings.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein',
|
||||||
|
'settings.passwordMismatch': 'Passwörter stimmen nicht überein',
|
||||||
|
'settings.passwordChanged': 'Passwort erfolgreich geändert',
|
||||||
|
'settings.deleteAccount': '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.deleteAccountConfirm': 'Endgültig löschen',
|
||||||
|
'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.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',
|
||||||
@@ -188,6 +207,7 @@ const de = {
|
|||||||
'admin.table.email': 'E-Mail',
|
'admin.table.email': 'E-Mail',
|
||||||
'admin.table.role': 'Rolle',
|
'admin.table.role': 'Rolle',
|
||||||
'admin.table.created': 'Erstellt',
|
'admin.table.created': 'Erstellt',
|
||||||
|
'admin.table.lastLogin': 'Letzter Login',
|
||||||
'admin.table.actions': 'Aktionen',
|
'admin.table.actions': 'Aktionen',
|
||||||
'admin.you': '(Du)',
|
'admin.you': '(Du)',
|
||||||
'admin.editUser': 'Benutzer bearbeiten',
|
'admin.editUser': 'Benutzer bearbeiten',
|
||||||
@@ -210,20 +230,157 @@ const de = {
|
|||||||
'admin.allowRegistration': 'Registrierung erlauben',
|
'admin.allowRegistration': 'Registrierung erlauben',
|
||||||
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
|
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
|
||||||
'admin.apiKeys': 'API-Schlüssel',
|
'admin.apiKeys': 'API-Schlüssel',
|
||||||
|
'admin.apiKeysHint': 'Optional. Aktiviert erweiterte Ortsdaten wie Fotos und Wetter.',
|
||||||
'admin.mapsKey': 'Google Maps API Key',
|
'admin.mapsKey': 'Google Maps API Key',
|
||||||
'admin.mapsKeyHint': 'Für Ortsuche benötigt. Erstellen unter console.cloud.google.com',
|
'admin.mapsKeyHint': 'Für Ortsuche benötigt. Erstellen unter console.cloud.google.com',
|
||||||
|
'admin.mapsKeyHintLong': 'Ohne API Key wird OpenStreetMap für die Ortssuche genutzt. Mit Google API Key können zusätzlich Bilder, Bewertungen und Öffnungszeiten geladen werden. Erstellen unter console.cloud.google.com.',
|
||||||
|
'admin.recommended': 'Empfohlen',
|
||||||
'admin.weatherKey': 'OpenWeatherMap API Key',
|
'admin.weatherKey': 'OpenWeatherMap API Key',
|
||||||
'admin.weatherKeyHint': 'Für Wetterdaten. Kostenlos unter openweathermap.org',
|
'admin.weatherKeyHint': 'Für Wetterdaten. Kostenlos unter openweathermap.org',
|
||||||
'admin.validateKey': 'Test',
|
'admin.validateKey': 'Test',
|
||||||
'admin.keyValid': 'Verbunden',
|
'admin.keyValid': 'Verbunden',
|
||||||
'admin.keyInvalid': 'Ungültig',
|
'admin.keyInvalid': 'Ungültig',
|
||||||
'admin.keySaved': 'API-Schlüssel gespeichert',
|
'admin.keySaved': 'API-Schlüssel gespeichert',
|
||||||
|
'admin.oidcTitle': 'Single Sign-On (OIDC)',
|
||||||
|
'admin.oidcSubtitle': 'Anmeldung über externe Anbieter wie Google, Apple, Authentik oder Keycloak.',
|
||||||
|
'admin.oidcDisplayName': 'Anzeigename',
|
||||||
|
'admin.oidcIssuer': 'Issuer URL',
|
||||||
|
'admin.oidcIssuerHint': 'Die OpenID Connect Issuer URL des Anbieters. z.B. https://accounts.google.com',
|
||||||
|
'admin.oidcSaved': 'OIDC-Konfiguration gespeichert',
|
||||||
|
|
||||||
|
// Addons
|
||||||
|
'admin.tabs.addons': 'Addons',
|
||||||
|
'admin.addons.title': 'Addons',
|
||||||
|
'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.disabled': 'Deaktiviert',
|
||||||
|
'admin.addons.type.trip': 'Trip',
|
||||||
|
'admin.addons.type.global': 'Global',
|
||||||
|
'admin.addons.tripHint': 'Verfügbar als Tab innerhalb jedes Trips',
|
||||||
|
'admin.addons.globalHint': 'Verfügbar als eigenständiger Bereich in der Navigation',
|
||||||
|
'admin.addons.toast.updated': 'Addon aktualisiert',
|
||||||
|
'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden',
|
||||||
|
'admin.addons.noAddons': 'Keine Addons verfügbar',
|
||||||
|
|
||||||
|
// Vacay addon
|
||||||
|
'vacay.subtitle': 'Urlaubstage planen und verwalten',
|
||||||
|
'vacay.settings': 'Einstellungen',
|
||||||
|
'vacay.year': 'Jahr',
|
||||||
|
'vacay.addYear': 'Jahr hinzufügen',
|
||||||
|
'vacay.removeYear': 'Jahr entfernen',
|
||||||
|
'vacay.removeYearConfirm': '{year} entfernen?',
|
||||||
|
'vacay.removeYearHint': 'Alle Urlaubseinträge und Betriebsferien für dieses Jahr werden unwiderruflich gelöscht.',
|
||||||
|
'vacay.remove': 'Entfernen',
|
||||||
|
'vacay.persons': 'Personen',
|
||||||
|
'vacay.noPersons': 'Keine Personen angelegt',
|
||||||
|
'vacay.addPerson': 'Person hinzufügen',
|
||||||
|
'vacay.editPerson': 'Person bearbeiten',
|
||||||
|
'vacay.removePerson': 'Person entfernen',
|
||||||
|
'vacay.removePersonConfirm': '{name} wirklich entfernen?',
|
||||||
|
'vacay.removePersonHint': 'Alle Urlaubseinträge dieser Person werden unwiderruflich gelöscht.',
|
||||||
|
'vacay.personName': 'Name',
|
||||||
|
'vacay.personNamePlaceholder': 'Name eingeben',
|
||||||
|
'vacay.color': 'Farbe',
|
||||||
|
'vacay.add': 'Hinzufügen',
|
||||||
|
'vacay.legend': 'Legende',
|
||||||
|
'vacay.publicHoliday': 'Feiertag',
|
||||||
|
'vacay.companyHoliday': 'Betriebsferien',
|
||||||
|
'vacay.weekend': 'Wochenende',
|
||||||
|
'vacay.modeVacation': 'Urlaub',
|
||||||
|
'vacay.modeCompany': 'Betriebsferien',
|
||||||
|
'vacay.entitlement': 'Urlaubsanspruch',
|
||||||
|
'vacay.entitlementDays': 'Tage',
|
||||||
|
'vacay.used': 'Weg',
|
||||||
|
'vacay.remaining': 'Rest',
|
||||||
|
'vacay.carriedOver': 'aus {year}',
|
||||||
|
'vacay.blockWeekends': 'Wochenenden sperren',
|
||||||
|
'vacay.blockWeekendsHint': 'Verhindert Urlaubseinträge an Samstagen und Sonntagen',
|
||||||
|
'vacay.publicHolidays': 'Feiertage',
|
||||||
|
'vacay.publicHolidaysHint': 'Feiertage im Kalender markieren',
|
||||||
|
'vacay.selectCountry': 'Land wählen',
|
||||||
|
'vacay.selectRegion': 'Region wählen (optional)',
|
||||||
|
'vacay.companyHolidays': 'Betriebsferien',
|
||||||
|
'vacay.companyHolidaysHint': 'Erlaubt das Markieren von unternehmensweiten Feiertagen',
|
||||||
|
'vacay.companyHolidaysNoDeduct': 'Betriebsferien werden nicht vom Urlaubskontingent abgezogen.',
|
||||||
|
'vacay.carryOver': 'Urlaubsmitnahme',
|
||||||
|
'vacay.carryOverHint': 'Resturlaub automatisch ins Folgejahr übertragen',
|
||||||
|
'vacay.sharing': 'Teilen',
|
||||||
|
'vacay.sharingHint': 'Teile deinen Urlaubsplan mit anderen NOMAD-Benutzern',
|
||||||
|
'vacay.owner': 'Besitzer',
|
||||||
|
'vacay.shareEmailPlaceholder': 'E-Mail des NOMAD-Benutzers',
|
||||||
|
'vacay.shareSuccess': 'Plan erfolgreich geteilt',
|
||||||
|
'vacay.shareError': 'Plan konnte nicht geteilt werden',
|
||||||
|
'vacay.dissolve': 'Fusion auflösen',
|
||||||
|
'vacay.dissolveHint': 'Kalender wieder trennen. Deine Einträge bleiben erhalten.',
|
||||||
|
'vacay.dissolveAction': 'Auflösen',
|
||||||
|
'vacay.dissolved': 'Kalender getrennt',
|
||||||
|
'vacay.fusedWith': 'Fusioniert mit',
|
||||||
|
'vacay.you': 'du',
|
||||||
|
'vacay.noData': 'Keine Daten',
|
||||||
|
'vacay.changeColor': 'Farbe ändern',
|
||||||
|
'vacay.inviteUser': 'Benutzer einladen',
|
||||||
|
'vacay.inviteHint': 'Lade einen anderen NOMAD-Benutzer ein, um einen gemeinsamen Urlaubskalender zu teilen.',
|
||||||
|
'vacay.selectUser': 'Benutzer wählen',
|
||||||
|
'vacay.sendInvite': 'Einladung senden',
|
||||||
|
'vacay.inviteSent': 'Einladung gesendet',
|
||||||
|
'vacay.inviteError': 'Einladung konnte nicht gesendet werden',
|
||||||
|
'vacay.pending': 'ausstehend',
|
||||||
|
'vacay.noUsersAvailable': 'Keine Benutzer verfügbar',
|
||||||
|
'vacay.accept': 'Annehmen',
|
||||||
|
'vacay.decline': 'Ablehnen',
|
||||||
|
'vacay.acceptFusion': 'Annehmen & Fusionieren',
|
||||||
|
'vacay.inviteTitle': 'Fusionsanfrage',
|
||||||
|
'vacay.inviteWantsToFuse': 'möchte einen Urlaubskalender mit dir teilen.',
|
||||||
|
'vacay.fuseInfo1': 'Beide sehen alle Urlaubseinträge in einem gemeinsamen Kalender.',
|
||||||
|
'vacay.fuseInfo2': 'Beide können Einträge für den jeweils anderen erstellen und bearbeiten.',
|
||||||
|
'vacay.fuseInfo3': 'Beide können Einträge löschen und den Urlaubsanspruch ändern.',
|
||||||
|
'vacay.fuseInfo4': 'Einstellungen wie Feiertage und Betriebsferien werden geteilt.',
|
||||||
|
'vacay.fuseInfo5': 'Die Fusion kann jederzeit von beiden Seiten aufgelöst werden. Einträge bleiben erhalten.',
|
||||||
|
'nav.myTrips': 'Meine Trips',
|
||||||
|
|
||||||
|
// Atlas addon
|
||||||
|
'atlas.subtitle': 'Dein Reise-Fußabdruck auf der Welt',
|
||||||
|
'atlas.countries': 'Länder',
|
||||||
|
'atlas.trips': 'Reisen',
|
||||||
|
'atlas.places': 'Orte',
|
||||||
|
'atlas.days': 'Tage',
|
||||||
|
'atlas.visitedCountries': 'Besuchte Länder',
|
||||||
|
'atlas.cities': 'Städte',
|
||||||
|
'atlas.noData': 'Noch keine Reisedaten',
|
||||||
|
'atlas.noDataHint': 'Erstelle einen Trip und füge Orte hinzu',
|
||||||
|
'atlas.lastTrip': 'Letzter Trip',
|
||||||
|
'atlas.nextTrip': 'Nächster Trip',
|
||||||
|
'atlas.daysLeft': 'Tage',
|
||||||
|
'atlas.streak': 'Streak',
|
||||||
|
'atlas.year': 'Jahr',
|
||||||
|
'atlas.years': 'Jahre',
|
||||||
|
'atlas.yearInRow': 'Jahr in Folge',
|
||||||
|
'atlas.yearsInRow': 'Jahre in Folge',
|
||||||
|
'atlas.tripIn': 'Reise in',
|
||||||
|
'atlas.tripsIn': 'Reisen in',
|
||||||
|
'atlas.since': 'seit',
|
||||||
|
'atlas.europe': 'Europa',
|
||||||
|
'atlas.asia': 'Asien',
|
||||||
|
'atlas.northAmerica': 'N-Amerika',
|
||||||
|
'atlas.southAmerica': 'S-Amerika',
|
||||||
|
'atlas.africa': 'Afrika',
|
||||||
|
'atlas.oceania': 'Ozeanien',
|
||||||
|
'atlas.other': 'Andere',
|
||||||
|
'atlas.firstVisit': 'Erste Reise',
|
||||||
|
'atlas.lastVisitLabel': 'Letzte Reise',
|
||||||
|
'atlas.tripSingular': 'Reise',
|
||||||
|
'atlas.tripPlural': 'Reisen',
|
||||||
|
'atlas.placeVisited': 'Ort besucht',
|
||||||
|
'atlas.placesVisited': 'Orte besucht',
|
||||||
|
|
||||||
// Trip Planner
|
// Trip Planner
|
||||||
'trip.tabs.plan': 'Planung',
|
'trip.tabs.plan': 'Karte',
|
||||||
'trip.tabs.reservations': 'Buchungen',
|
'trip.tabs.reservations': 'Buchungen',
|
||||||
'trip.tabs.packing': 'Packliste',
|
'trip.tabs.reservationsShort': 'Buchung',
|
||||||
'trip.tabs.packingShort': 'Packliste',
|
'trip.tabs.packing': 'Liste',
|
||||||
|
'trip.tabs.packingShort': 'Liste',
|
||||||
'trip.tabs.budget': 'Budget',
|
'trip.tabs.budget': 'Budget',
|
||||||
'trip.tabs.files': 'Dateien',
|
'trip.tabs.files': 'Dateien',
|
||||||
'trip.loading': 'Reise wird geladen...',
|
'trip.loading': 'Reise wird geladen...',
|
||||||
@@ -259,6 +416,9 @@ const de = {
|
|||||||
'dayplan.optimize': 'Optimieren',
|
'dayplan.optimize': 'Optimieren',
|
||||||
'dayplan.optimized': 'Route optimiert',
|
'dayplan.optimized': 'Route optimiert',
|
||||||
'dayplan.routeError': 'Fehler bei der Routenberechnung',
|
'dayplan.routeError': 'Fehler bei der Routenberechnung',
|
||||||
|
'dayplan.toast.needTwoPlaces': 'Mindestens zwei Orte für Routenoptimierung nötig',
|
||||||
|
'dayplan.toast.routeOptimized': 'Route optimiert',
|
||||||
|
'dayplan.toast.noGeoPlaces': 'Keine Orte mit Koordinaten für Routenberechnung gefunden',
|
||||||
'dayplan.confirmed': 'Bestätigt',
|
'dayplan.confirmed': 'Bestätigt',
|
||||||
'dayplan.pendingRes': 'Ausstehend',
|
'dayplan.pendingRes': 'Ausstehend',
|
||||||
'dayplan.pdf': 'PDF',
|
'dayplan.pdf': 'PDF',
|
||||||
@@ -293,8 +453,10 @@ const de = {
|
|||||||
'places.formNotesPlaceholder': 'Persönliche Notizen...',
|
'places.formNotesPlaceholder': 'Persönliche Notizen...',
|
||||||
'places.formReservation': 'Reservierung',
|
'places.formReservation': 'Reservierung',
|
||||||
'places.reservationNotesPlaceholder': 'Reservierungsnotizen, Bestätigungsnummer...',
|
'places.reservationNotesPlaceholder': 'Reservierungsnotizen, Bestätigungsnummer...',
|
||||||
'places.mapsSearchPlaceholder': 'Google Maps suchen...',
|
'places.mapsSearchPlaceholder': 'Ortssuche...',
|
||||||
'places.mapsSearchError': 'Google Maps Suche fehlgeschlagen. Bitte API-Schlüssel in den Einstellungen hinterlegen.',
|
'places.mapsSearchError': 'Ortssuche fehlgeschlagen.',
|
||||||
|
'places.osmHint': 'OpenStreetMap-Suche aktiv (ohne Bilder, Öffnungszeiten, Bewertungen). Für erweiterte Daten Google API Key in den Einstellungen hinterlegen.',
|
||||||
|
'places.osmActive': 'Suche via OpenStreetMap (ohne Bilder, Bewertungen & Öffnungszeiten). Google API Key in den Einstellungen hinterlegen für erweiterte Daten.',
|
||||||
'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',
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ const en = {
|
|||||||
'dashboard.subtitle.activeMany': '{count} active trips',
|
'dashboard.subtitle.activeMany': '{count} active trips',
|
||||||
'dashboard.subtitle.archivedSuffix': ' · {count} archived',
|
'dashboard.subtitle.archivedSuffix': ' · {count} archived',
|
||||||
'dashboard.newTrip': 'New Trip',
|
'dashboard.newTrip': 'New Trip',
|
||||||
|
'dashboard.currency': 'Currency',
|
||||||
|
'dashboard.timezone': 'Timezones',
|
||||||
|
'dashboard.localTime': 'Local',
|
||||||
'dashboard.emptyTitle': 'No trips yet',
|
'dashboard.emptyTitle': 'No trips yet',
|
||||||
'dashboard.emptyText': 'Create your first trip and start planning!',
|
'dashboard.emptyText': 'Create your first trip and start planning!',
|
||||||
'dashboard.emptyButton': 'Create First Trip',
|
'dashboard.emptyButton': 'Create First Trip',
|
||||||
@@ -125,6 +128,22 @@ const en = {
|
|||||||
'settings.email': 'Email',
|
'settings.email': 'Email',
|
||||||
'settings.role': 'Role',
|
'settings.role': 'Role',
|
||||||
'settings.roleAdmin': 'Administrator',
|
'settings.roleAdmin': 'Administrator',
|
||||||
|
'settings.oidcLinked': 'Linked with',
|
||||||
|
'settings.changePassword': 'Change Password',
|
||||||
|
'settings.currentPassword': 'Current password',
|
||||||
|
'settings.newPassword': 'New password',
|
||||||
|
'settings.confirmPassword': 'Confirm new password',
|
||||||
|
'settings.updatePassword': 'Update password',
|
||||||
|
'settings.passwordRequired': 'Please enter current and new password',
|
||||||
|
'settings.passwordTooShort': 'Password must be at least 8 characters',
|
||||||
|
'settings.passwordMismatch': 'Passwords do not match',
|
||||||
|
'settings.passwordChanged': 'Password changed successfully',
|
||||||
|
'settings.deleteAccount': 'Delete account',
|
||||||
|
'settings.deleteAccountTitle': 'Delete your account?',
|
||||||
|
'settings.deleteAccountWarning': 'Your account and all your trips, places, and files will be permanently deleted. This action cannot be undone.',
|
||||||
|
'settings.deleteAccountConfirm': 'Delete permanently',
|
||||||
|
'settings.deleteBlockedTitle': 'Deletion not possible',
|
||||||
|
'settings.deleteBlockedMessage': 'You are the only administrator. Promote another user to admin before deleting your account.',
|
||||||
'settings.roleUser': 'User',
|
'settings.roleUser': 'User',
|
||||||
'settings.saveProfile': 'Save Profile',
|
'settings.saveProfile': 'Save Profile',
|
||||||
'settings.toast.mapSaved': 'Map settings saved',
|
'settings.toast.mapSaved': 'Map settings saved',
|
||||||
@@ -188,6 +207,7 @@ const en = {
|
|||||||
'admin.table.email': 'Email',
|
'admin.table.email': 'Email',
|
||||||
'admin.table.role': 'Role',
|
'admin.table.role': 'Role',
|
||||||
'admin.table.created': 'Created',
|
'admin.table.created': 'Created',
|
||||||
|
'admin.table.lastLogin': 'Last Login',
|
||||||
'admin.table.actions': 'Actions',
|
'admin.table.actions': 'Actions',
|
||||||
'admin.you': '(You)',
|
'admin.you': '(You)',
|
||||||
'admin.editUser': 'Edit User',
|
'admin.editUser': 'Edit User',
|
||||||
@@ -210,18 +230,155 @@ const en = {
|
|||||||
'admin.allowRegistration': 'Allow Registration',
|
'admin.allowRegistration': 'Allow Registration',
|
||||||
'admin.allowRegistrationHint': 'New users can register themselves',
|
'admin.allowRegistrationHint': 'New users can register themselves',
|
||||||
'admin.apiKeys': 'API Keys',
|
'admin.apiKeys': 'API Keys',
|
||||||
|
'admin.apiKeysHint': 'Optional. Enables extended place data like photos and weather.',
|
||||||
'admin.mapsKey': 'Google Maps API Key',
|
'admin.mapsKey': 'Google Maps API Key',
|
||||||
'admin.mapsKeyHint': 'Required for place search. Get at console.cloud.google.com',
|
'admin.mapsKeyHint': 'Required for place search. Get at console.cloud.google.com',
|
||||||
|
'admin.mapsKeyHintLong': 'Without an API key, OpenStreetMap is used for place search. With a Google API key, photos, ratings, and opening hours can be loaded as well. Get one at console.cloud.google.com.',
|
||||||
|
'admin.recommended': 'Recommended',
|
||||||
'admin.weatherKey': 'OpenWeatherMap API Key',
|
'admin.weatherKey': 'OpenWeatherMap API Key',
|
||||||
'admin.weatherKeyHint': 'For weather data. Free at openweathermap.org',
|
'admin.weatherKeyHint': 'For weather data. Free at openweathermap.org',
|
||||||
'admin.validateKey': 'Test',
|
'admin.validateKey': 'Test',
|
||||||
'admin.keyValid': 'Connected',
|
'admin.keyValid': 'Connected',
|
||||||
'admin.keyInvalid': 'Invalid',
|
'admin.keyInvalid': 'Invalid',
|
||||||
'admin.keySaved': 'API keys saved',
|
'admin.keySaved': 'API keys saved',
|
||||||
|
'admin.oidcTitle': 'Single Sign-On (OIDC)',
|
||||||
|
'admin.oidcSubtitle': 'Allow login via external providers like Google, Apple, Authentik or Keycloak.',
|
||||||
|
'admin.oidcDisplayName': 'Display Name',
|
||||||
|
'admin.oidcIssuer': 'Issuer URL',
|
||||||
|
'admin.oidcIssuerHint': 'The OpenID Connect Issuer URL of the provider. e.g. https://accounts.google.com',
|
||||||
|
'admin.oidcSaved': 'OIDC configuration saved',
|
||||||
|
|
||||||
|
// Addons
|
||||||
|
'admin.tabs.addons': 'Addons',
|
||||||
|
'admin.addons.title': 'Addons',
|
||||||
|
'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.disabled': 'Disabled',
|
||||||
|
'admin.addons.type.trip': 'Trip',
|
||||||
|
'admin.addons.type.global': 'Global',
|
||||||
|
'admin.addons.tripHint': 'Available as a tab within each trip',
|
||||||
|
'admin.addons.globalHint': 'Available as a standalone section in the main navigation',
|
||||||
|
'admin.addons.toast.updated': 'Addon updated',
|
||||||
|
'admin.addons.toast.error': 'Failed to update addon',
|
||||||
|
'admin.addons.noAddons': 'No addons available',
|
||||||
|
|
||||||
|
// Vacay addon
|
||||||
|
'vacay.subtitle': 'Plan and manage vacation days',
|
||||||
|
'vacay.settings': 'Settings',
|
||||||
|
'vacay.year': 'Year',
|
||||||
|
'vacay.addYear': 'Add year',
|
||||||
|
'vacay.removeYear': 'Remove year',
|
||||||
|
'vacay.removeYearConfirm': 'Remove {year}?',
|
||||||
|
'vacay.removeYearHint': 'All vacation entries and company holidays for this year will be permanently deleted.',
|
||||||
|
'vacay.remove': 'Remove',
|
||||||
|
'vacay.persons': 'Persons',
|
||||||
|
'vacay.noPersons': 'No persons added',
|
||||||
|
'vacay.addPerson': 'Add Person',
|
||||||
|
'vacay.editPerson': 'Edit Person',
|
||||||
|
'vacay.removePerson': 'Remove Person',
|
||||||
|
'vacay.removePersonConfirm': 'Remove {name}?',
|
||||||
|
'vacay.removePersonHint': 'All vacation entries for this person will be permanently deleted.',
|
||||||
|
'vacay.personName': 'Name',
|
||||||
|
'vacay.personNamePlaceholder': 'Enter name',
|
||||||
|
'vacay.color': 'Color',
|
||||||
|
'vacay.add': 'Add',
|
||||||
|
'vacay.legend': 'Legend',
|
||||||
|
'vacay.publicHoliday': 'Public Holiday',
|
||||||
|
'vacay.companyHoliday': 'Company Holiday',
|
||||||
|
'vacay.weekend': 'Weekend',
|
||||||
|
'vacay.modeVacation': 'Vacation',
|
||||||
|
'vacay.modeCompany': 'Company Holiday',
|
||||||
|
'vacay.entitlement': 'Entitlement',
|
||||||
|
'vacay.entitlementDays': 'Days',
|
||||||
|
'vacay.used': 'Used',
|
||||||
|
'vacay.remaining': 'Left',
|
||||||
|
'vacay.carriedOver': 'from {year}',
|
||||||
|
'vacay.blockWeekends': 'Block Weekends',
|
||||||
|
'vacay.blockWeekendsHint': 'Prevent vacation entries on Saturdays and Sundays',
|
||||||
|
'vacay.publicHolidays': 'Public Holidays',
|
||||||
|
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
|
||||||
|
'vacay.selectCountry': 'Select country',
|
||||||
|
'vacay.selectRegion': 'Select region (optional)',
|
||||||
|
'vacay.companyHolidays': 'Company Holidays',
|
||||||
|
'vacay.companyHolidaysHint': 'Allow marking company-wide holiday days',
|
||||||
|
'vacay.companyHolidaysNoDeduct': 'Company holidays do not count towards vacation days.',
|
||||||
|
'vacay.carryOver': 'Carry Over',
|
||||||
|
'vacay.carryOverHint': 'Automatically carry remaining vacation days into the next year',
|
||||||
|
'vacay.sharing': 'Sharing',
|
||||||
|
'vacay.sharingHint': 'Share your vacation plan with other NOMAD users',
|
||||||
|
'vacay.owner': 'Owner',
|
||||||
|
'vacay.shareEmailPlaceholder': 'Email of NOMAD user',
|
||||||
|
'vacay.shareSuccess': 'Plan shared successfully',
|
||||||
|
'vacay.shareError': 'Could not share plan',
|
||||||
|
'vacay.dissolve': 'Dissolve Fusion',
|
||||||
|
'vacay.dissolveHint': 'Separate calendars again. Your entries will be kept.',
|
||||||
|
'vacay.dissolveAction': 'Dissolve',
|
||||||
|
'vacay.dissolved': 'Calendar separated',
|
||||||
|
'vacay.fusedWith': 'Fused with',
|
||||||
|
'vacay.you': 'you',
|
||||||
|
'vacay.noData': 'No data',
|
||||||
|
'vacay.changeColor': 'Change color',
|
||||||
|
'vacay.inviteUser': 'Invite User',
|
||||||
|
'vacay.inviteHint': 'Invite another NOMAD user to share a combined vacation calendar.',
|
||||||
|
'vacay.selectUser': 'Select user',
|
||||||
|
'vacay.sendInvite': 'Send Invite',
|
||||||
|
'vacay.inviteSent': 'Invite sent',
|
||||||
|
'vacay.inviteError': 'Could not send invite',
|
||||||
|
'vacay.pending': 'pending',
|
||||||
|
'vacay.noUsersAvailable': 'No users available',
|
||||||
|
'vacay.accept': 'Accept',
|
||||||
|
'vacay.decline': 'Decline',
|
||||||
|
'vacay.acceptFusion': 'Accept & Fuse',
|
||||||
|
'vacay.inviteTitle': 'Fusion Request',
|
||||||
|
'vacay.inviteWantsToFuse': 'wants to share a vacation calendar with you.',
|
||||||
|
'vacay.fuseInfo1': 'Both of you will see all vacation entries in one shared calendar.',
|
||||||
|
'vacay.fuseInfo2': 'Both parties can create and edit entries for each other.',
|
||||||
|
'vacay.fuseInfo3': 'Both parties can delete entries and change vacation entitlements.',
|
||||||
|
'vacay.fuseInfo4': 'Settings like public holidays and company holidays are shared.',
|
||||||
|
'vacay.fuseInfo5': 'The fusion can be dissolved at any time by either party. Your entries will be preserved.',
|
||||||
|
'nav.myTrips': 'My Trips',
|
||||||
|
|
||||||
|
// Atlas addon
|
||||||
|
'atlas.subtitle': 'Your travel footprint around the world',
|
||||||
|
'atlas.countries': 'Countries',
|
||||||
|
'atlas.trips': 'Trips',
|
||||||
|
'atlas.places': 'Places',
|
||||||
|
'atlas.days': 'Days',
|
||||||
|
'atlas.visitedCountries': 'Visited Countries',
|
||||||
|
'atlas.cities': 'Cities',
|
||||||
|
'atlas.noData': 'No travel data yet',
|
||||||
|
'atlas.noDataHint': 'Create a trip and add places to see your world map',
|
||||||
|
'atlas.lastTrip': 'Last trip',
|
||||||
|
'atlas.nextTrip': 'Next trip',
|
||||||
|
'atlas.daysLeft': 'days left',
|
||||||
|
'atlas.streak': 'Streak',
|
||||||
|
'atlas.year': 'year',
|
||||||
|
'atlas.years': 'years',
|
||||||
|
'atlas.yearInRow': 'year in a row',
|
||||||
|
'atlas.yearsInRow': 'years in a row',
|
||||||
|
'atlas.tripIn': 'trip in',
|
||||||
|
'atlas.tripsIn': 'trips in',
|
||||||
|
'atlas.since': 'since',
|
||||||
|
'atlas.europe': 'Europe',
|
||||||
|
'atlas.asia': 'Asia',
|
||||||
|
'atlas.northAmerica': 'N. America',
|
||||||
|
'atlas.southAmerica': 'S. America',
|
||||||
|
'atlas.africa': 'Africa',
|
||||||
|
'atlas.oceania': 'Oceania',
|
||||||
|
'atlas.other': 'Other',
|
||||||
|
'atlas.firstVisit': 'First trip',
|
||||||
|
'atlas.lastVisitLabel': 'Last trip',
|
||||||
|
'atlas.tripSingular': 'Trip',
|
||||||
|
'atlas.tripPlural': 'Trips',
|
||||||
|
'atlas.placeVisited': 'Place visited',
|
||||||
|
'atlas.placesVisited': 'Places visited',
|
||||||
|
|
||||||
// Trip Planner
|
// Trip Planner
|
||||||
'trip.tabs.plan': 'Plan',
|
'trip.tabs.plan': 'Plan',
|
||||||
'trip.tabs.reservations': 'Bookings',
|
'trip.tabs.reservations': 'Bookings',
|
||||||
|
'trip.tabs.reservationsShort': 'Book',
|
||||||
'trip.tabs.packing': 'Packing List',
|
'trip.tabs.packing': 'Packing List',
|
||||||
'trip.tabs.packingShort': 'Packing',
|
'trip.tabs.packingShort': 'Packing',
|
||||||
'trip.tabs.budget': 'Budget',
|
'trip.tabs.budget': 'Budget',
|
||||||
@@ -259,6 +416,9 @@ const en = {
|
|||||||
'dayplan.optimize': 'Optimize',
|
'dayplan.optimize': 'Optimize',
|
||||||
'dayplan.optimized': 'Route optimized',
|
'dayplan.optimized': 'Route optimized',
|
||||||
'dayplan.routeError': 'Failed to calculate route',
|
'dayplan.routeError': 'Failed to calculate route',
|
||||||
|
'dayplan.toast.needTwoPlaces': 'At least two places needed for route optimization',
|
||||||
|
'dayplan.toast.routeOptimized': 'Route optimized',
|
||||||
|
'dayplan.toast.noGeoPlaces': 'No places with coordinates found for route calculation',
|
||||||
'dayplan.confirmed': 'Confirmed',
|
'dayplan.confirmed': 'Confirmed',
|
||||||
'dayplan.pendingRes': 'Pending',
|
'dayplan.pendingRes': 'Pending',
|
||||||
'dayplan.pdf': 'PDF',
|
'dayplan.pdf': 'PDF',
|
||||||
@@ -293,8 +453,10 @@ const en = {
|
|||||||
'places.formNotesPlaceholder': 'Personal notes...',
|
'places.formNotesPlaceholder': 'Personal notes...',
|
||||||
'places.formReservation': 'Reservation',
|
'places.formReservation': 'Reservation',
|
||||||
'places.reservationNotesPlaceholder': 'Reservation notes, confirmation number...',
|
'places.reservationNotesPlaceholder': 'Reservation notes, confirmation number...',
|
||||||
'places.mapsSearchPlaceholder': 'Search Google Maps...',
|
'places.mapsSearchPlaceholder': 'Search places...',
|
||||||
'places.mapsSearchError': 'Google Maps search failed. Please add an API key in settings.',
|
'places.mapsSearchError': 'Place search failed.',
|
||||||
|
'places.osmHint': 'Using OpenStreetMap search (no photos, opening hours, or ratings). Add a Google API key in settings for full details.',
|
||||||
|
'places.osmActive': 'Search via OpenStreetMap (no photos, ratings or opening hours). Add a Google API key in Settings for enhanced data.',
|
||||||
'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',
|
||||||
|
|||||||
@@ -2,6 +2,100 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html { height: 100%; overflow: hidden; background-color: var(--bg-primary); }
|
||||||
|
body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow-scrolling: touch; }
|
||||||
|
|
||||||
|
|
||||||
|
.atlas-tooltip {
|
||||||
|
background: rgba(10, 10, 20, 0.6) !important;
|
||||||
|
backdrop-filter: blur(20px) saturate(180%) !important;
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%) !important;
|
||||||
|
color: #f1f5f9 !important;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1) !important;
|
||||||
|
border-radius: 14px !important;
|
||||||
|
padding: 10px 14px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
font-family: inherit !important;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.25) !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
.atlas-tooltip::before { border-top-color: rgba(10, 10, 20, 0.6) !important; }
|
||||||
|
html:not(.dark) .atlas-tooltip {
|
||||||
|
background: rgba(255, 255, 255, 0.75) !important;
|
||||||
|
color: #0f172a !important;
|
||||||
|
border: 1px solid rgba(0,0,0,0.08) !important;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.1) !important;
|
||||||
|
}
|
||||||
|
html:not(.dark) .atlas-tooltip::before { border-top-color: rgba(255, 255, 255, 0.75) !important; }
|
||||||
|
.leaflet-tooltip.atlas-tooltip { opacity: 1 !important; }
|
||||||
|
.leaflet-tooltip-pane { transition: none !important; }
|
||||||
|
.leaflet-fade-anim .leaflet-tooltip { transition: none !important; opacity: 1 !important; }
|
||||||
|
|
||||||
|
.dark .leaflet-control-zoom a {
|
||||||
|
background: rgba(10, 10, 20, 0.7) !important;
|
||||||
|
color: #e2e8f0 !important;
|
||||||
|
border-color: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
.dark .leaflet-control-zoom a:hover {
|
||||||
|
background: rgba(30, 30, 40, 0.8) !important;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.leaflet-control-zoom { display: none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides for pages using hardcoded slate-* Tailwind classes */
|
||||||
|
html.dark .bg-slate-50 { background-color: var(--bg-secondary) !important; }
|
||||||
|
html.dark .bg-white { background-color: var(--bg-card) !important; }
|
||||||
|
html.dark .bg-slate-100 { background-color: var(--bg-secondary) !important; }
|
||||||
|
html.dark .bg-slate-900.text-white { background-color: #e2e8f0 !important; color: #0f172a !important; }
|
||||||
|
html.dark .border-slate-200, html.dark .border-slate-300 { border-color: var(--border-primary) !important; }
|
||||||
|
html.dark .border-slate-100, html.dark .border-b-slate-100 { border-color: var(--border-secondary) !important; }
|
||||||
|
html.dark .text-slate-900 { color: var(--text-primary) !important; }
|
||||||
|
html.dark .text-slate-700 { color: var(--text-secondary) !important; }
|
||||||
|
html.dark .text-slate-600 { color: var(--text-muted) !important; }
|
||||||
|
html.dark .text-slate-500 { color: var(--text-muted) !important; }
|
||||||
|
html.dark .text-slate-400 { color: var(--text-faint) !important; }
|
||||||
|
html.dark .hover\:bg-slate-50:hover, html.dark .hover\:bg-slate-100:hover { background-color: var(--bg-hover) !important; }
|
||||||
|
html.dark .hover\:text-slate-900:hover { color: var(--text-primary) !important; }
|
||||||
|
html.dark .hover\:bg-slate-700:hover { background-color: var(--bg-hover) !important; }
|
||||||
|
html.dark .divide-slate-100 > :not([hidden]) ~ :not([hidden]) { border-color: var(--border-secondary) !important; }
|
||||||
|
html.dark .focus\:ring-slate-400:focus { --tw-ring-color: var(--text-faint) !important; }
|
||||||
|
html.dark input[class*="border-slate"], html.dark input[class*="text-slate"] { background: var(--bg-secondary) !important; color: var(--text-primary) !important; border-color: var(--border-primary) !important; }
|
||||||
|
html.dark .text-amber-900 { color: #fbbf24 !important; }
|
||||||
|
html.dark .text-amber-700 { color: #f59e0b !important; }
|
||||||
|
html.dark .bg-amber-50 { background-color: rgba(245,158,11,0.1) !important; }
|
||||||
|
html.dark .border-amber-200 { border-color: rgba(245,158,11,0.2) !important; }
|
||||||
|
html.dark .disabled\:bg-slate-400:disabled { background-color: var(--text-faint) !important; }
|
||||||
|
html.dark button.bg-slate-900 { background-color: #e2e8f0 !important; color: #0f172a !important; opacity: 1 !important; }
|
||||||
|
html.dark button.bg-slate-900:hover { background-color: #cbd5e1 !important; }
|
||||||
|
html.dark button.bg-slate-900:disabled { background-color: #ffffff !important; color: #000000 !important; opacity: 0.4 !important; }
|
||||||
|
html.dark span.bg-slate-900 { background-color: #e2e8f0 !important; color: #0f172a !important; }
|
||||||
|
html.dark span.bg-slate-100 { background-color: var(--bg-secondary) !important; color: var(--text-muted) !important; }
|
||||||
|
html.dark .border-b { border-bottom-color: var(--border-secondary) !important; }
|
||||||
|
/* Gray variants (CategoryManager, BackupPanel) */
|
||||||
|
html.dark .bg-gray-50 { background-color: var(--bg-secondary) !important; }
|
||||||
|
html.dark .bg-gray-100 { background-color: var(--bg-secondary) !important; }
|
||||||
|
html.dark .border-gray-200, html.dark .border-gray-300 { border-color: var(--border-primary) !important; }
|
||||||
|
html.dark .border-gray-100 { border-color: var(--border-secondary) !important; }
|
||||||
|
html.dark .text-gray-900 { color: var(--text-primary) !important; }
|
||||||
|
html.dark .text-gray-700 { color: var(--text-secondary) !important; }
|
||||||
|
html.dark .text-gray-600 { color: var(--text-muted) !important; }
|
||||||
|
html.dark .text-gray-500 { color: var(--text-muted) !important; }
|
||||||
|
html.dark .text-gray-400 { color: var(--text-faint) !important; }
|
||||||
|
html.dark .text-gray-300 { color: var(--text-faint) !important; }
|
||||||
|
html.dark .hover\:bg-gray-50:hover, html.dark .hover\:bg-gray-200:hover { background-color: var(--bg-hover) !important; }
|
||||||
|
html.dark .hover\:border-gray-200:hover, html.dark .hover\:border-gray-400:hover { border-color: var(--border-primary) !important; }
|
||||||
|
html.dark input.bg-white, html.dark input[class*="bg-white"] { background: var(--bg-secondary) !important; color: var(--text-primary) !important; }
|
||||||
|
html.dark .bg-gray-200 { background-color: var(--border-primary) !important; }
|
||||||
|
html.dark .border-gray-300.border-t-slate-600 { border-color: var(--border-primary) !important; border-top-color: var(--text-primary) !important; }
|
||||||
|
/* Modal buttons */
|
||||||
|
html.dark button[class*="text-slate-600"][class*="border-slate-200"] { color: var(--text-muted) !important; border-color: var(--border-primary) !important; }
|
||||||
|
html.dark button[class*="text-slate-600"][class*="border-slate-200"]:hover { background: var(--bg-hover) !important; }
|
||||||
|
/* Dashed borders */
|
||||||
|
html.dark .border-dashed.border-gray-300 { border-color: var(--border-primary) !important; }
|
||||||
|
html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color: transparent !important; }
|
||||||
|
|
||||||
/* Reorder buttons: desktop = original style; mobile = always visible, larger touch targets */
|
/* Reorder buttons: desktop = original style; mobile = always visible, larger touch targets */
|
||||||
.reorder-buttons {
|
.reorder-buttons {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -45,6 +139,8 @@
|
|||||||
|
|
||||||
/* ── 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;
|
||||||
@@ -230,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;
|
||||||
|
|||||||
@@ -2,21 +2,25 @@ import React, { useEffect, useState } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { adminApi, authApi } from '../api/client'
|
import { adminApi, authApi } from '../api/client'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import { useTranslation } from '../i18n'
|
import { useTranslation } from '../i18n'
|
||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
import Modal from '../components/shared/Modal'
|
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 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 } from 'lucide-react'
|
||||||
import CustomSelect from '../components/shared/CustomSelect'
|
import CustomSelect from '../components/shared/CustomSelect'
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const { demoMode } = useAuthStore()
|
const { demoMode } = useAuthStore()
|
||||||
const { t } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
|
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'users', label: t('admin.tabs.users') },
|
{ id: 'users', label: t('admin.tabs.users') },
|
||||||
{ id: 'categories', label: t('admin.tabs.categories') },
|
{ id: 'categories', label: t('admin.tabs.categories') },
|
||||||
|
{ 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') },
|
||||||
]
|
]
|
||||||
@@ -30,6 +34,10 @@ export default function AdminPage() {
|
|||||||
const [showCreateUser, setShowCreateUser] = useState(false)
|
const [showCreateUser, setShowCreateUser] = useState(false)
|
||||||
const [createForm, setCreateForm] = useState({ username: '', email: '', password: '', role: 'user' })
|
const [createForm, setCreateForm] = useState({ username: '', email: '', password: '', role: 'user' })
|
||||||
|
|
||||||
|
// OIDC config
|
||||||
|
const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', display_name: '' })
|
||||||
|
const [savingOidc, setSavingOidc] = useState(false)
|
||||||
|
|
||||||
// Registration toggle
|
// Registration toggle
|
||||||
const [allowRegistration, setAllowRegistration] = useState(true)
|
const [allowRegistration, setAllowRegistration] = useState(true)
|
||||||
|
|
||||||
@@ -49,6 +57,7 @@ export default function AdminPage() {
|
|||||||
loadData()
|
loadData()
|
||||||
loadAppConfig()
|
loadAppConfig()
|
||||||
loadApiKeys()
|
loadApiKeys()
|
||||||
|
adminApi.getOidc().then(setOidcConfig).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -197,10 +206,10 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50">
|
<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">
|
||||||
@@ -238,12 +247,11 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4 mb-6">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
|
||||||
{[
|
{[
|
||||||
{ label: t('admin.stats.users'), value: stats.totalUsers, icon: Users },
|
{ label: t('admin.stats.users'), value: stats.totalUsers, icon: Users },
|
||||||
{ label: t('admin.stats.trips'), value: stats.totalTrips, icon: Briefcase },
|
{ label: t('admin.stats.trips'), value: stats.totalTrips, icon: Briefcase },
|
||||||
{ label: t('admin.stats.places'), value: stats.totalPlaces, icon: Map },
|
{ label: t('admin.stats.places'), value: stats.totalPlaces, icon: Map },
|
||||||
{ label: t('admin.stats.photos'), value: stats.totalPhotos || 0, icon: Camera },
|
|
||||||
{ label: t('admin.stats.files'), value: stats.totalFiles || 0, icon: FileText },
|
{ label: t('admin.stats.files'), value: stats.totalFiles || 0, icon: FileText },
|
||||||
].map(({ label, value, icon: Icon }) => (
|
].map(({ label, value, icon: Icon }) => (
|
||||||
<div key={label} className="rounded-xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
<div key={label} className="rounded-xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
@@ -260,12 +268,12 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 mb-6 bg-white border border-slate-200 rounded-xl p-1 w-fit">
|
<div className="grid grid-cols-3 sm:flex gap-1 mb-6 rounded-xl p-1" style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)' }}>
|
||||||
{TABS.map(tab => (
|
{TABS.map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
className={`px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium rounded-lg transition-colors ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'bg-slate-900 text-white'
|
? 'bg-slate-900 text-white'
|
||||||
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
|
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
|
||||||
@@ -280,7 +288,10 @@ export default function AdminPage() {
|
|||||||
{activeTab === 'users' && (
|
{activeTab === 'users' && (
|
||||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
|
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
|
||||||
<h2 className="font-semibold text-slate-900">{t('admin.tabs.users')} ({users.length})</h2>
|
<div>
|
||||||
|
<h2 className="font-semibold text-slate-900">{t('admin.tabs.users')}</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{users.length} {t('admin.stats.users')}</p>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateUser(true)}
|
onClick={() => setShowCreateUser(true)}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors"
|
||||||
@@ -303,6 +314,7 @@ export default function AdminPage() {
|
|||||||
<th className="px-5 py-3">{t('admin.table.email')}</th>
|
<th className="px-5 py-3">{t('admin.table.email')}</th>
|
||||||
<th className="px-5 py-3">{t('admin.table.role')}</th>
|
<th className="px-5 py-3">{t('admin.table.role')}</th>
|
||||||
<th className="px-5 py-3">{t('admin.table.created')}</th>
|
<th className="px-5 py-3">{t('admin.table.created')}</th>
|
||||||
|
<th className="px-5 py-3">{t('admin.table.lastLogin')}</th>
|
||||||
<th className="px-5 py-3 text-right">{t('admin.table.actions')}</th>
|
<th className="px-5 py-3 text-right">{t('admin.table.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -311,8 +323,11 @@ export default function AdminPage() {
|
|||||||
<tr key={u.id} className={`hover:bg-slate-50 transition-colors ${u.id === currentUser?.id ? 'bg-slate-50/60' : ''}`}>
|
<tr key={u.id} className={`hover:bg-slate-50 transition-colors ${u.id === currentUser?.id ? 'bg-slate-50/60' : ''}`}>
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-sm font-medium text-slate-700">
|
<div className="relative">
|
||||||
{u.username.charAt(0).toUpperCase()}
|
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-sm font-medium text-slate-700">
|
||||||
|
{u.username.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2" style={{ borderColor: 'var(--bg-card)', background: u.online ? '#22c55e' : '#94a3b8' }} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-slate-900">{u.username}</p>
|
<p className="text-sm font-medium text-slate-900">{u.username}</p>
|
||||||
@@ -334,7 +349,10 @@ export default function AdminPage() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-sm text-slate-500">
|
<td className="px-5 py-3 text-sm text-slate-500">
|
||||||
{new Date(u.created_at).toLocaleDateString('de-DE')}
|
{new Date(u.created_at).toLocaleDateString(locale)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-slate-500">
|
||||||
|
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
@@ -366,6 +384,8 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
{activeTab === 'categories' && <CategoryManager />}
|
{activeTab === 'categories' && <CategoryManager />}
|
||||||
|
|
||||||
|
{activeTab === 'addons' && <AddonManager />}
|
||||||
|
|
||||||
{activeTab === 'settings' && (
|
{activeTab === 'settings' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Registration Toggle */}
|
{/* Registration Toggle */}
|
||||||
@@ -399,11 +419,15 @@ export default function AdminPage() {
|
|||||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-slate-100">
|
<div className="px-6 py-4 border-b border-slate-100">
|
||||||
<h2 className="font-semibold text-slate-900">{t('admin.apiKeys')}</h2>
|
<h2 className="font-semibold text-slate-900">{t('admin.apiKeys')}</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{t('admin.apiKeysHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
{/* Google Maps Key */}
|
{/* Google Maps Key */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.mapsKey')}</label>
|
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-1.5">
|
||||||
|
{t('admin.mapsKey')}
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 500, padding: '1px 7px', borderRadius: 99, background: '#dbeafe', color: '#1d4ed8' }}>{t('admin.recommended')}</span>
|
||||||
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<input
|
<input
|
||||||
@@ -436,7 +460,7 @@ export default function AdminPage() {
|
|||||||
{t('admin.validateKey')}
|
{t('admin.validateKey')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-1">{t('admin.mapsKeyHint')}</p>
|
<p className="text-xs text-slate-400 mt-1">{t('admin.mapsKeyHintLong')}</p>
|
||||||
{validation.maps === true && (
|
{validation.maps === true && (
|
||||||
<p className="text-xs text-emerald-600 mt-1 flex items-center gap-1">
|
<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>
|
<span className="w-2 h-2 bg-emerald-500 rounded-full inline-block"></span>
|
||||||
@@ -511,6 +535,73 @@ export default function AdminPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* OIDC / SSO Configuration */}
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-slate-100">
|
||||||
|
<h2 className="font-semibold text-slate-900">{t('admin.oidcTitle')}</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{t('admin.oidcSubtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.oidcDisplayName')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={oidcConfig.display_name}
|
||||||
|
onChange={e => setOidcConfig(c => ({ ...c, display_name: e.target.value }))}
|
||||||
|
placeholder='z.B. Google, Authentik, Keycloak'
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.oidcIssuer')}</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={oidcConfig.issuer}
|
||||||
|
onChange={e => setOidcConfig(c => ({ ...c, issuer: e.target.value }))}
|
||||||
|
placeholder='https://accounts.google.com'
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{t('admin.oidcIssuerHint')}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">Client ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={oidcConfig.client_id}
|
||||||
|
onChange={e => setOidcConfig(c => ({ ...c, client_id: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">Client Secret</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={oidcConfig.client_secret}
|
||||||
|
onChange={e => setOidcConfig(c => ({ ...c, client_secret: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setSavingOidc(true)
|
||||||
|
try {
|
||||||
|
await adminApi.updateOidc(oidcConfig)
|
||||||
|
toast.success(t('admin.oidcSaved'))
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.error || t('common.error'))
|
||||||
|
} finally {
|
||||||
|
setSavingOidc(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={savingOidc}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||||
|
>
|
||||||
|
{savingOidc ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
import React, { useEffect, useState, useRef } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useTranslation } from '../i18n'
|
||||||
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
|
import Navbar from '../components/Layout/Navbar'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X } from 'lucide-react'
|
||||||
|
import L from 'leaflet'
|
||||||
|
|
||||||
|
// Convert country code to flag emoji
|
||||||
|
function MobileStats({ data, stats, countries, resolveName, t, dark }) {
|
||||||
|
const tp = dark ? '#f1f5f9' : '#0f172a'
|
||||||
|
const tf = dark ? '#475569' : '#94a3b8'
|
||||||
|
const { continents, lastTrip, nextTrip, streak, firstYear, tripsThisYear } = data || {}
|
||||||
|
const CL = { 'Europe': t('atlas.europe'), 'Asia': t('atlas.asia'), 'North America': t('atlas.northAmerica'), 'South America': t('atlas.southAmerica'), 'Africa': t('atlas.africa'), 'Oceania': t('atlas.oceania') }
|
||||||
|
const thisYear = new Date().getFullYear()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Stats grid */}
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{[[stats.totalCountries, t('atlas.countries')], [stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => (
|
||||||
|
<div key={i} className="text-center py-2">
|
||||||
|
<p className="text-xl font-black tabular-nums" style={{ color: tp }}>{v}</p>
|
||||||
|
<p className="text-[9px] font-semibold uppercase tracking-wide" style={{ color: tf }}>{l}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Continents */}
|
||||||
|
<div className="grid grid-cols-6 gap-1">
|
||||||
|
{['Europe', 'Asia', 'North America', 'South America', 'Africa', 'Oceania'].map(cont => {
|
||||||
|
const count = continents?.[cont] || 0
|
||||||
|
return (
|
||||||
|
<div key={cont} className="text-center py-1">
|
||||||
|
<p className="text-base font-bold tabular-nums" style={{ color: count > 0 ? tp : (dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.12)') }}>{count}</p>
|
||||||
|
<p className="text-[8px] font-semibold uppercase" style={{ color: count > 0 ? tf : (dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)') }}>{CL[cont]}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* Highlights */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{streak > 0 && (
|
||||||
|
<div className="text-center flex-1 py-2">
|
||||||
|
<p className="text-xl font-black tabular-nums" style={{ color: tp }}>{streak}</p>
|
||||||
|
<p className="text-[9px] font-semibold uppercase tracking-wide" style={{ color: tf }}>{streak === 1 ? t('atlas.yearInRow') : t('atlas.yearsInRow')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tripsThisYear > 0 && (
|
||||||
|
<div className="text-center flex-1 py-2">
|
||||||
|
<p className="text-xl font-black tabular-nums" style={{ color: tp }}>{tripsThisYear}</p>
|
||||||
|
<p className="text-[9px] font-semibold uppercase tracking-wide" style={{ color: tf }}>{tripsThisYear === 1 ? t('atlas.tripIn') : t('atlas.tripsIn')} {thisYear}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function countryCodeToFlag(code) {
|
||||||
|
if (!code || code.length !== 2) return ''
|
||||||
|
return String.fromCodePoint(...[...code.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65))
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCountryNames(language) {
|
||||||
|
const [resolver, setResolver] = useState(() => (code) => code)
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const dn = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' })
|
||||||
|
setResolver(() => (code) => { try { return dn.of(code) } catch { return code } })
|
||||||
|
} catch { /* */ }
|
||||||
|
}, [language])
|
||||||
|
return resolver
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map visited country codes to ISO-3166 alpha3 (GeoJSON uses alpha3)
|
||||||
|
const A2_TO_A3 = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BR":"BRA","BE":"BEL","BG":"BGR","CA":"CAN","CL":"CHL","CN":"CHN","CO":"COL","HR":"HRV","CZ":"CZE","DK":"DNK","EG":"EGY","EE":"EST","FI":"FIN","FR":"FRA","DE":"DEU","GR":"GRC","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JP":"JPN","KE":"KEN","KR":"KOR","LV":"LVA","LT":"LTU","LU":"LUX","MY":"MYS","MX":"MEX","MA":"MAR","NL":"NLD","NZ":"NZL","NO":"NOR","PK":"PAK","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","RO":"ROU","RU":"RUS","SA":"SAU","RS":"SRB","SK":"SVK","SI":"SVN","ZA":"ZAF","ES":"ESP","SE":"SWE","CH":"CHE","TH":"THA","TR":"TUR","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","VN":"VNM","NG":"NGA"}
|
||||||
|
|
||||||
|
export default function AtlasPage() {
|
||||||
|
const { t, language } = useTranslation()
|
||||||
|
const { settings } = useSettingsStore()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const resolveName = useCountryNames(language)
|
||||||
|
const dark = settings.dark_mode
|
||||||
|
const mapRef = useRef(null)
|
||||||
|
const mapInstance = useRef(null)
|
||||||
|
const geoLayerRef = useRef(null)
|
||||||
|
const glareRef = useRef(null)
|
||||||
|
const borderGlareRef = useRef(null)
|
||||||
|
const panelRef = useRef(null)
|
||||||
|
|
||||||
|
const handlePanelMouseMove = (e) => {
|
||||||
|
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
|
||||||
|
const rect = panelRef.current.getBoundingClientRect()
|
||||||
|
const x = e.clientX - rect.left
|
||||||
|
const y = e.clientY - rect.top
|
||||||
|
// Subtle inner glow
|
||||||
|
glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)`
|
||||||
|
glareRef.current.style.opacity = '1'
|
||||||
|
// Border glow that follows cursor
|
||||||
|
borderGlareRef.current.style.opacity = '1'
|
||||||
|
borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||||
|
borderGlareRef.current.style.WebkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||||
|
}
|
||||||
|
const handlePanelMouseLeave = () => {
|
||||||
|
if (glareRef.current) glareRef.current.style.opacity = '0'
|
||||||
|
if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, setData] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||||
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||||||
|
const [selectedCountry, setSelectedCountry] = useState(null)
|
||||||
|
const [countryDetail, setCountryDetail] = useState(null)
|
||||||
|
const [geoData, setGeoData] = useState(null)
|
||||||
|
|
||||||
|
// Load atlas data
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.get('/addons/atlas/stats').then(r => {
|
||||||
|
setData(r.data)
|
||||||
|
setLoading(false)
|
||||||
|
}).catch(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load GeoJSON world data (direct GeoJSON, no conversion needed)
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(geo => setGeoData(geo))
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Initialize map — runs after loading is done and mapRef is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || !mapRef.current) return
|
||||||
|
if (mapInstance.current) { mapInstance.current.remove(); mapInstance.current = null }
|
||||||
|
|
||||||
|
const map = L.map(mapRef.current, {
|
||||||
|
center: [25, 0],
|
||||||
|
zoom: 3,
|
||||||
|
minZoom: 3,
|
||||||
|
maxZoom: 7,
|
||||||
|
zoomControl: false,
|
||||||
|
attributionControl: false,
|
||||||
|
maxBounds: [[-90, -220], [90, 220]],
|
||||||
|
maxBoundsViscosity: 1.0,
|
||||||
|
fadeAnimation: false,
|
||||||
|
preferCanvas: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
L.control.zoom({ position: 'bottomright' }).addTo(map)
|
||||||
|
|
||||||
|
const tileUrl = dark
|
||||||
|
? 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png'
|
||||||
|
: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png'
|
||||||
|
|
||||||
|
L.tileLayer(tileUrl, {
|
||||||
|
maxZoom: 8,
|
||||||
|
keepBuffer: 25,
|
||||||
|
updateWhenZooming: true,
|
||||||
|
updateWhenIdle: false,
|
||||||
|
tileSize: 256,
|
||||||
|
zoomOffset: 0,
|
||||||
|
crossOrigin: true,
|
||||||
|
loading: true,
|
||||||
|
}).addTo(map)
|
||||||
|
|
||||||
|
// Preload adjacent zoom level tiles
|
||||||
|
L.tileLayer(tileUrl, {
|
||||||
|
maxZoom: 8,
|
||||||
|
keepBuffer: 10,
|
||||||
|
opacity: 0,
|
||||||
|
tileSize: 256,
|
||||||
|
crossOrigin: true,
|
||||||
|
}).addTo(map)
|
||||||
|
|
||||||
|
mapInstance.current = map
|
||||||
|
return () => { map.remove(); mapInstance.current = null }
|
||||||
|
}, [dark, loading])
|
||||||
|
|
||||||
|
// Render GeoJSON countries
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapInstance.current || !geoData || !data) return
|
||||||
|
|
||||||
|
const visitedA3 = new Set(data.countries.map(c => A2_TO_A3[c.code]).filter(Boolean))
|
||||||
|
const countryMap = {}
|
||||||
|
data.countries.forEach(c => { if (A2_TO_A3[c.code]) countryMap[A2_TO_A3[c.code]] = c })
|
||||||
|
|
||||||
|
if (geoLayerRef.current) {
|
||||||
|
mapInstance.current.removeLayer(geoLayerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate deterministic color per country code
|
||||||
|
const VISITED_COLORS = ['#6366f1','#ec4899','#14b8a6','#f97316','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#e11d48','#0d9488','#7c3aed','#2563eb','#dc2626','#059669','#d946ef']
|
||||||
|
// Assign colors in order of visit (by index in countries array) so no two neighbors share a color easily
|
||||||
|
const visitedA3List = [...visitedA3]
|
||||||
|
const colorMap = {}
|
||||||
|
visitedA3List.forEach((a3, i) => { colorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] })
|
||||||
|
const colorForCode = (a3) => colorMap[a3] || VISITED_COLORS[0]
|
||||||
|
|
||||||
|
const canvasRenderer = L.canvas({ padding: 0.5, tolerance: 5 })
|
||||||
|
|
||||||
|
geoLayerRef.current = L.geoJSON(geoData, {
|
||||||
|
renderer: canvasRenderer,
|
||||||
|
interactive: true,
|
||||||
|
bubblingMouseEvents: false,
|
||||||
|
style: (feature) => {
|
||||||
|
const a3 = feature.properties?.ISO_A3 || feature.properties?.ADM0_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
|
||||||
|
const visited = visitedA3.has(a3)
|
||||||
|
return {
|
||||||
|
fillColor: visited ? colorForCode(a3) : (dark ? '#1e1e2e' : '#e2e8f0'),
|
||||||
|
fillOpacity: visited ? 0.7 : 0.3,
|
||||||
|
color: dark ? '#333' : '#cbd5e1',
|
||||||
|
weight: 0.5,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onEachFeature: (feature, layer) => {
|
||||||
|
const a3 = feature.properties?.ISO_A3 || feature.properties?.ADM0_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
|
||||||
|
const c = countryMap[a3]
|
||||||
|
if (c) {
|
||||||
|
const name = resolveName(c.code)
|
||||||
|
const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { month: 'short', year: 'numeric' }) }
|
||||||
|
const tooltipHtml = `
|
||||||
|
<div style="display:flex;flex-direction:column;gap:8px;min-width:160px">
|
||||||
|
<div style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;padding-bottom:6px;border-bottom:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}">${name}</div>
|
||||||
|
<div style="display:flex;gap:14px">
|
||||||
|
<div><span style="font-size:16px;font-weight:800">${c.tripCount}</span> <span style="font-size:10px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">${c.tripCount === 1 ? t('atlas.tripSingular') : t('atlas.tripPlural')}</span></div>
|
||||||
|
<div><span style="font-size:16px;font-weight:800">${c.placeCount}</span> <span style="font-size:10px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">${c.placeCount === 1 ? t('atlas.placeVisited') : t('atlas.placesVisited')}</span></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:2px;border-top:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'};padding-top:8px">
|
||||||
|
<div style="flex:1;display:flex;flex-direction:column;gap:2px">
|
||||||
|
<span style="font-size:9px;text-transform:uppercase;letter-spacing:0.08em;opacity:0.4">${t('atlas.firstVisit')}</span>
|
||||||
|
<span style="font-size:12px;font-weight:700">${formatDate(c.firstVisit)}</span>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;display:flex;flex-direction:column;gap:2px">
|
||||||
|
<span style="font-size:9px;text-transform:uppercase;letter-spacing:0.08em;opacity:0.4">${t('atlas.lastVisitLabel')}</span>
|
||||||
|
<span style="font-size:12px;font-weight:700">${formatDate(c.lastVisit)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
layer.bindTooltip(tooltipHtml, {
|
||||||
|
sticky: false, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||||
|
})
|
||||||
|
layer.on('click', () => loadCountryDetail(c.code))
|
||||||
|
layer.on('mouseover', (e) => {
|
||||||
|
e.target.setStyle({ fillOpacity: 0.9, weight: 2, color: dark ? '#818cf8' : '#4f46e5' })
|
||||||
|
})
|
||||||
|
layer.on('mouseout', (e) => {
|
||||||
|
geoLayerRef.current.resetStyle(e.target)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).addTo(mapInstance.current)
|
||||||
|
}, [geoData, data, dark])
|
||||||
|
|
||||||
|
const loadCountryDetail = async (code) => {
|
||||||
|
setSelectedCountry(code)
|
||||||
|
try {
|
||||||
|
const r = await apiClient.get(`/addons/atlas/country/${code}`)
|
||||||
|
setCountryDetail(r.data)
|
||||||
|
} catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = data?.stats || { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 }
|
||||||
|
const countries = data?.countries || []
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
|
||||||
|
<Navbar />
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
|
||||||
|
<Navbar />
|
||||||
|
<div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0 }}>
|
||||||
|
{/* Map */}
|
||||||
|
<div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} />
|
||||||
|
|
||||||
|
{/* Mobile: Bottom bar */}
|
||||||
|
<div className="md:hidden absolute bottom-3 left-0 right-0 z-10 flex justify-center" style={{ touchAction: 'manipulation' }}>
|
||||||
|
<div className="flex items-center gap-4 px-5 py-4 rounded-2xl"
|
||||||
|
style={{ background: dark ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.5)', backdropFilter: 'blur(16px)' }}>
|
||||||
|
{/* Countries highlighted */}
|
||||||
|
<div className="text-center px-3 py-1.5 rounded-xl" style={{ background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' }}>
|
||||||
|
<p className="text-3xl font-black tabular-nums leading-none" style={{ color: 'var(--text-primary)' }}>{stats.totalCountries}</p>
|
||||||
|
<p className="text-[9px] font-semibold uppercase tracking-wide mt-1" style={{ color: 'var(--text-faint)' }}>{t('atlas.countries')}</p>
|
||||||
|
</div>
|
||||||
|
{[[stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => (
|
||||||
|
<div key={i} className="text-center px-1">
|
||||||
|
<p className="text-xl font-black tabular-nums leading-none" style={{ color: 'var(--text-primary)' }}>{v}</p>
|
||||||
|
<p className="text-[9px] font-semibold uppercase tracking-wide mt-1" style={{ color: 'var(--text-faint)' }}>{l}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Panel — bottom center, glass effect */}
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
onMouseMove={handlePanelMouseMove}
|
||||||
|
onMouseLeave={handlePanelMouseLeave}
|
||||||
|
className="hidden md:flex flex-col absolute z-10 overflow-hidden transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
bottom: 16,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
width: 'fit-content',
|
||||||
|
background: dark ? 'rgba(10,10,15,0.55)' : 'rgba(255,255,255,0.2)',
|
||||||
|
backdropFilter: 'blur(24px) saturate(180%)',
|
||||||
|
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||||
|
border: '1px solid ' + (dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'),
|
||||||
|
borderRadius: 20,
|
||||||
|
boxShadow: dark
|
||||||
|
? '0 8px 32px rgba(0,0,0,0.3)'
|
||||||
|
: '0 8px 32px rgba(0,0,0,0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Liquid glass glare effect */}
|
||||||
|
<div ref={glareRef} className="absolute inset-0 pointer-events-none" style={{ opacity: 0, transition: 'opacity 0.3s ease', borderRadius: 20 }} />
|
||||||
|
{/* Border glow that follows cursor */}
|
||||||
|
<div ref={borderGlareRef} className="absolute inset-0 pointer-events-none" style={{
|
||||||
|
opacity: 0, transition: 'opacity 0.3s ease', borderRadius: 20,
|
||||||
|
border: dark ? '1.5px solid rgba(255,255,255,0.5)' : '2px solid rgba(0,0,0,0.15)',
|
||||||
|
}} />
|
||||||
|
<SidebarContent
|
||||||
|
data={data} stats={stats} countries={countries} selectedCountry={selectedCountry}
|
||||||
|
countryDetail={countryDetail} resolveName={resolveName}
|
||||||
|
onCountryClick={loadCountryDetail} onTripClick={(id) => navigate(`/trips/${id}`)}
|
||||||
|
t={t} dark={dark}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, t, dark }) {
|
||||||
|
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
|
||||||
|
const tp = dark ? '#f1f5f9' : '#0f172a'
|
||||||
|
const tm = dark ? '#94a3b8' : '#64748b'
|
||||||
|
const tf = dark ? '#475569' : '#94a3b8'
|
||||||
|
const accent = '#818cf8'
|
||||||
|
|
||||||
|
const { mostVisited, continents, lastTrip, nextTrip, streak, firstYear, tripsThisYear } = data || {}
|
||||||
|
const contEntries = continents ? Object.entries(continents).sort((a, b) => b[1] - a[1]) : []
|
||||||
|
const maxCont = contEntries.length > 0 ? contEntries[0][1] : 1
|
||||||
|
const CL = { 'Europe': t('atlas.europe'), 'Asia': t('atlas.asia'), 'North America': t('atlas.northAmerica'), 'South America': t('atlas.southAmerica'), 'Africa': t('atlas.africa'), 'Oceania': t('atlas.oceania') }
|
||||||
|
const contColors = ['#818cf8', '#f472b6', '#34d399', '#fbbf24', '#fb923c', '#22d3ee']
|
||||||
|
|
||||||
|
if (countries.length === 0 && !lastTrip) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<Globe size={28} className="mx-auto mb-2" style={{ color: tf, opacity: 0.4 }} />
|
||||||
|
<p className="text-sm font-medium" style={{ color: tm }}>{t('atlas.noData')}</p>
|
||||||
|
<p className="text-xs mt-1" style={{ color: tf }}>{t('atlas.noDataHint')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const thisYear = new Date().getFullYear()
|
||||||
|
const divider = `2px solid ${bg(0.08)}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-stretch justify-center">
|
||||||
|
|
||||||
|
{/* ═══ SECTION 1: Numbers ═══ */}
|
||||||
|
{/* Countries hero */}
|
||||||
|
<div className="flex items-baseline gap-1.5 px-5 py-4 mx-2 my-2 rounded-xl" style={{ background: bg(0.08) }}>
|
||||||
|
<span className="text-5xl font-black tabular-nums leading-none" style={{ color: tp }}>{stats.totalCountries}</span>
|
||||||
|
<span className="text-sm font-medium" style={{ color: tm }}>{t('atlas.countries')}</span>
|
||||||
|
</div>
|
||||||
|
{/* Other stats */}
|
||||||
|
{[[stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => (
|
||||||
|
<div key={i} className="flex flex-col items-center justify-center px-3 py-5 shrink-0">
|
||||||
|
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{v}</span>
|
||||||
|
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide whitespace-nowrap" style={{ color: tf }}>{l}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* ═══ DIVIDER ═══ */}
|
||||||
|
<div style={{ width: 2, background: bg(0.08), margin: '12px 14px' }} />
|
||||||
|
|
||||||
|
{/* ═══ SECTION 2: Continents ═══ */}
|
||||||
|
<div className="flex items-center gap-4 px-3 py-4 shrink-0">
|
||||||
|
{['Europe', 'Asia', 'North America', 'South America', 'Africa', 'Oceania'].map((cont) => {
|
||||||
|
const count = continents?.[cont] || 0
|
||||||
|
const active = count > 0
|
||||||
|
return (
|
||||||
|
<div key={cont} className="flex flex-col items-center shrink-0">
|
||||||
|
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: active ? tp : bg(0.15) }}>{count}</span>
|
||||||
|
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide whitespace-nowrap" style={{ color: active ? tf : bg(0.1) }}>{CL[cont]}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══ DIVIDER ═══ */}
|
||||||
|
<div style={{ width: 2, background: bg(0.08), margin: '12px 14px' }} />
|
||||||
|
|
||||||
|
{/* ═══ SECTION 3: Highlights & Streaks ═══ */}
|
||||||
|
<div className="flex items-center gap-5 px-3 py-4">
|
||||||
|
{/* Last trip */}
|
||||||
|
{lastTrip && (
|
||||||
|
<button onClick={() => onTripClick(lastTrip.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 text-lg shrink-0" style={{ background: bg(0.06) }}>
|
||||||
|
{lastTrip.countryCode ? countryCodeToFlag(lastTrip.countryCode) : <MapPin size={16} style={{ color: tm }} />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: tf }}>{t('atlas.lastTrip')}</p>
|
||||||
|
<p className="text-[13px] font-bold truncate" style={{ color: tp }}>{lastTrip.title}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Streak */}
|
||||||
|
{streak > 0 && (
|
||||||
|
<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-[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')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* This year */}
|
||||||
|
{tripsThisYear > 0 && (
|
||||||
|
<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-[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}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══ Country detail overlay ═══ */}
|
||||||
|
{selectedCountry && countryDetail && (
|
||||||
|
<>
|
||||||
|
<div style={{ width: 2, background: bg(0.08), margin: '12px 0' }} />
|
||||||
|
<div className="flex items-center gap-3 px-6 py-4">
|
||||||
|
<span className="text-3xl">{countryCodeToFlag(selectedCountry)}</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold" style={{ color: tp }}>{resolveName(selectedCountry)}</p>
|
||||||
|
<p className="text-[10px] mb-1" style={{ color: tf }}>{countryDetail.places.length} {t('atlas.places')} · {countryDetail.trips.length} Trips</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{countryDetail.trips.slice(0, 3).map(trip => (
|
||||||
|
<button key={trip.id} onClick={() => onTripClick(trip.id)}
|
||||||
|
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-semibold transition-opacity hover:opacity-75"
|
||||||
|
style={{ background: bg(0.08), color: tp }}>
|
||||||
|
<Briefcase size={9} style={{ color: tm }} />
|
||||||
|
{trip.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,15 +2,17 @@ import React, { useEffect, useState, useRef } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { tripsApi } from '../api/client'
|
import { tripsApi } from '../api/client'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import { useTranslation } from '../i18n'
|
import { useTranslation } from '../i18n'
|
||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
import DemoBanner from '../components/Layout/DemoBanner'
|
import DemoBanner from '../components/Layout/DemoBanner'
|
||||||
import TravelStats from '../components/Dashboard/TravelStats'
|
import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
|
||||||
|
import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
|
||||||
import TripFormModal from '../components/Trips/TripFormModal'
|
import TripFormModal from '../components/Trips/TripFormModal'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
import {
|
import {
|
||||||
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
|
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
|
||||||
Archive, ArchiveRestore, Clock, MapPin,
|
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||||
@@ -72,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
|
||||||
@@ -81,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' }}>
|
||||||
@@ -149,7 +185,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</LiquidGlass>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,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',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -345,11 +381,26 @@ export default function DashboardPage() {
|
|||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
const [editingTrip, setEditingTrip] = useState(null)
|
const [editingTrip, setEditingTrip] = useState(null)
|
||||||
const [showArchived, setShowArchived] = useState(false)
|
const [showArchived, setShowArchived] = useState(false)
|
||||||
|
const [showWidgetSettings, setShowWidgetSettings] = useState(false)
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const { demoMode } = useAuthStore()
|
const { demoMode } = useAuthStore()
|
||||||
|
const { settings, updateSetting } = useSettingsStore()
|
||||||
|
const dark = settings.dark_mode
|
||||||
|
const showCurrency = settings.dashboard_currency !== 'off'
|
||||||
|
const showTimezone = settings.dashboard_timezone !== 'off'
|
||||||
|
const showSidebar = showCurrency || showTimezone
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showWidgetSettings === 'mobile') {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
return () => { document.body.style.overflow = '' }
|
||||||
|
}, [showWidgetSettings])
|
||||||
|
|
||||||
useEffect(() => { loadTrips() }, [])
|
useEffect(() => { loadTrips() }, [])
|
||||||
|
|
||||||
@@ -437,10 +488,10 @@ export default function DashboardPage() {
|
|||||||
const rest = spotlight ? trips.filter(t => t.id !== spotlight.id) : trips
|
const rest = spotlight ? trips.filter(t => t.id !== spotlight.id) : trips
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', 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={{ paddingTop: 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 */}
|
||||||
@@ -453,21 +504,75 @@ export default function DashboardPage() {
|
|||||||
: t('dashboard.subtitle.empty')}
|
: t('dashboard.subtitle.empty')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
|
||||||
onClick={() => { setEditingTrip(null); setShowForm(true) }}
|
{/* Widget settings */}
|
||||||
style={{
|
<button
|
||||||
display: 'flex', alignItems: 'center', gap: 7, padding: '9px 18px',
|
onClick={() => setShowWidgetSettings(s => s ? false : true)}
|
||||||
background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 12,
|
style={{
|
||||||
fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
padding: '0 14px',
|
||||||
}}
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||||
|
transition: 'background 0.15s, border-color 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.borderColor = 'var(--text-faint)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
||||||
|
>
|
||||||
|
<Settings size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingTrip(null); setShowForm(true) }}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 7, padding: '9px 18px',
|
||||||
|
background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 12,
|
||||||
|
fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
||||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||||
>
|
>
|
||||||
<Plus size={15} /> {t('dashboard.newTrip')}
|
<Plus size={15} /> {t('dashboard.newTrip')}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Widget settings dropdown */}
|
||||||
|
{showWidgetSettings && (
|
||||||
|
<div className="rounded-xl border p-3 mb-4 flex items-center gap-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
<span className="text-xs font-semibold" style={{ color: 'var(--text-muted)' }}>Widgets:</span>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<button onClick={() => updateSetting('dashboard_currency', showCurrency ? 'off' : 'on')}
|
||||||
|
className="relative inline-flex h-5 w-9 items-center rounded-full transition-colors"
|
||||||
|
style={{ background: showCurrency ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||||
|
<span className="absolute left-0.5 h-4 w-4 rounded-full transition-transform duration-200"
|
||||||
|
style={{ background: 'var(--bg-card)', transform: showCurrency ? 'translateX(16px)' : 'translateX(0)' }} />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-primary)' }}>{t('dashboard.currency')}</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<button onClick={() => updateSetting('dashboard_timezone', showTimezone ? 'off' : 'on')}
|
||||||
|
className="relative inline-flex h-5 w-9 items-center rounded-full transition-colors"
|
||||||
|
style={{ background: showTimezone ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||||
|
<span className="absolute left-0.5 h-4 w-4 rounded-full transition-transform duration-200"
|
||||||
|
style={{ background: 'var(--bg-card)', transform: showTimezone ? 'translateX(16px)' : 'translateX(0)' }} />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-primary)' }}>{t('dashboard.timezone')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile widgets button */}
|
||||||
|
{showSidebar && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowWidgetSettings('mobile')}
|
||||||
|
className="lg:hidden flex items-center justify-center gap-2 w-full py-2.5 rounded-xl text-xs font-semibold mb-4"
|
||||||
|
style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
<ArrowRightLeft size={13} style={{ color: 'var(--text-faint)' }} />
|
||||||
|
{showCurrency && showTimezone ? `${t('dashboard.currency')} & ${t('dashboard.timezone')}` : showCurrency ? t('dashboard.currency') : t('dashboard.timezone')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
|
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
@@ -505,7 +610,7 @@ export default function DashboardPage() {
|
|||||||
{!isLoading && spotlight && (
|
{!isLoading && spotlight && (
|
||||||
<SpotlightCard
|
<SpotlightCard
|
||||||
trip={spotlight}
|
trip={spotlight}
|
||||||
t={t} locale={locale}
|
t={t} locale={locale} dark={dark}
|
||||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onArchive={handleArchive}
|
onArchive={handleArchive}
|
||||||
@@ -562,14 +667,37 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats sidebar */}
|
{/* Widgets sidebar */}
|
||||||
<div className="hidden lg:block" style={{ position: 'sticky', top: 80, flexShrink: 0 }}>
|
{showSidebar && (
|
||||||
<TravelStats />
|
<div className="hidden lg:flex flex-col gap-4" style={{ position: 'sticky', top: 80, flexShrink: 0, width: 280 }}>
|
||||||
</div>
|
{showCurrency && <LiquidGlass dark={dark} style={{ borderRadius: 16 }}><CurrencyWidget /></LiquidGlass>}
|
||||||
|
{showTimezone && <LiquidGlass dark={dark} style={{ borderRadius: 16 }}><TimezoneWidget /></LiquidGlass>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile widgets bottom sheet */}
|
||||||
|
{showWidgetSettings === 'mobile' && (
|
||||||
|
<div className="lg:hidden fixed inset-0 z-50" style={{ background: 'rgba(0,0,0,0.3)', touchAction: 'none' }} onClick={() => setShowWidgetSettings(false)}>
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 flex flex-col overflow-hidden"
|
||||||
|
style={{ maxHeight: '80vh', background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', overscrollBehavior: 'contain' }}
|
||||||
|
onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||||
|
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Widgets</span>
|
||||||
|
<button onClick={() => setShowWidgetSettings(false)} className="w-7 h-7 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<X size={14} style={{ color: 'var(--text-primary)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto p-4 space-y-4">
|
||||||
|
{showCurrency && <CurrencyWidget />}
|
||||||
|
{showTimezone && <TimezoneWidget />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<TripFormModal
|
<TripFormModal
|
||||||
isOpen={showForm}
|
isOpen={showForm}
|
||||||
onClose={() => { setShowForm(false); setEditingTrip(null) }}
|
onClose={() => { setShowForm(false); setEditingTrip(null) }}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export default function FilesPage() {
|
|||||||
<div className="min-h-screen bg-slate-50">
|
<div className="min-h-screen bg-slate-50">
|
||||||
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
|
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
|
||||||
|
|
||||||
<div className="pt-14">
|
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||||
<div className="max-w-5xl mx-auto px-4 py-6">
|
<div className="max-w-5xl mx-auto px-4 py-6">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useAuthStore } from '../store/authStore'
|
|||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import { useTranslation } from '../i18n'
|
import { useTranslation } from '../i18n'
|
||||||
import { authApi } from '../api/client'
|
import { authApi } from '../api/client'
|
||||||
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route } from 'lucide-react'
|
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield } from 'lucide-react'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { t, language } = useTranslation()
|
const { t, language } = useTranslation()
|
||||||
@@ -28,6 +28,30 @@ export default function LoginPage() {
|
|||||||
if (!config.has_users) setMode('register')
|
if (!config.has_users) setMode('register')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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 oidcError = params.get('oidc_error')
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem('auth_token', token)
|
||||||
|
window.history.replaceState({}, '', '/login')
|
||||||
|
login.__fromOidc = true
|
||||||
|
navigate('/dashboard')
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
if (oidcError) {
|
||||||
|
const errorMessages = {
|
||||||
|
registration_disabled: language === 'de' ? 'Registrierung ist deaktiviert. Kontaktiere den Administrator.' : 'Registration is disabled. Contact your administrator.',
|
||||||
|
no_email: language === 'de' ? 'Keine E-Mail vom Provider erhalten.' : 'No email received from provider.',
|
||||||
|
token_failed: language === 'de' ? 'Authentifizierung fehlgeschlagen.' : 'Authentication failed.',
|
||||||
|
invalid_state: language === 'de' ? 'Ungueltige Sitzung. Bitte erneut versuchen.' : 'Invalid session. Please try again.',
|
||||||
|
}
|
||||||
|
setError(errorMessages[oidcError] || oidcError)
|
||||||
|
window.history.replaceState({}, '', '/login')
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleDemoLogin = async () => {
|
const handleDemoLogin = async () => {
|
||||||
@@ -43,6 +67,8 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [showTakeoff, setShowTakeoff] = useState(false)
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
@@ -55,10 +81,10 @@ export default function LoginPage() {
|
|||||||
} else {
|
} else {
|
||||||
await login(email, password)
|
await login(email, password)
|
||||||
}
|
}
|
||||||
navigate('/dashboard')
|
setShowTakeoff(true)
|
||||||
|
setTimeout(() => navigate('/dashboard'), 2600)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || t('login.error'))
|
setError(err.message || t('login.error'))
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +97,157 @@ export default function LoginPage() {
|
|||||||
color: '#111827', background: 'white', boxSizing: 'border-box', transition: 'border-color 0.15s',
|
color: '#111827', background: 'white', boxSizing: 'border-box', transition: 'border-color 0.15s',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showTakeoff) {
|
||||||
|
return (
|
||||||
|
<div className="takeoff-overlay" style={{ position: 'fixed', inset: 0, zIndex: 99999, overflow: 'hidden' }}>
|
||||||
|
{/* Sky gradient */}
|
||||||
|
<div className="takeoff-sky" style={{ position: 'absolute', inset: 0 }} />
|
||||||
|
|
||||||
|
{/* Stars */}
|
||||||
|
{Array.from({ length: 60 }, (_, i) => (
|
||||||
|
<div key={i} className="takeoff-star" style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: Math.random() > 0.7 ? 3 : 1.5,
|
||||||
|
height: Math.random() > 0.7 ? 3 : 1.5,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'white',
|
||||||
|
top: `${Math.random() * 100}%`,
|
||||||
|
left: `${Math.random() * 100}%`,
|
||||||
|
animationDelay: `${0.3 + Math.random() * 0.5}s, ${Math.random() * 1}s`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Clouds rushing past */}
|
||||||
|
{[0, 1, 2, 3, 4].map(i => (
|
||||||
|
<div key={i} className="takeoff-cloud" style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: 120 + i * 40,
|
||||||
|
height: 40 + i * 10,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.15)',
|
||||||
|
filter: 'blur(8px)',
|
||||||
|
right: -200,
|
||||||
|
top: `${25 + i * 12}%`,
|
||||||
|
animationDelay: `${0.3 + i * 0.25}s`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Speed lines */}
|
||||||
|
{Array.from({ length: 12 }, (_, i) => (
|
||||||
|
<div key={i} className="takeoff-speedline" style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: 80 + Math.random() * 120,
|
||||||
|
height: 1.5,
|
||||||
|
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent)',
|
||||||
|
top: `${10 + Math.random() * 80}%`,
|
||||||
|
right: -200,
|
||||||
|
animationDelay: `${0.5 + i * 0.12}s`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Plane */}
|
||||||
|
<div className="takeoff-plane" style={{ position: 'absolute', left: '50%', bottom: '10%', transform: 'translate(-50%, 0)' }}>
|
||||||
|
<svg viewBox="0 0 480 120" style={{ width: 200, filter: 'drop-shadow(0 0 20px rgba(255,255,255,0.3))' }}>
|
||||||
|
<g fill="white" transform="translate(240,60) rotate(-12)">
|
||||||
|
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
||||||
|
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
||||||
|
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
||||||
|
<path d="M-100,-5 L-120,-30 L-108,-30 L-90,-8 Z" />
|
||||||
|
<path d="M-100,5 L-120,30 L-108,30 L-90,8 Z" />
|
||||||
|
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contrail */}
|
||||||
|
<div className="takeoff-trail" style={{
|
||||||
|
position: 'absolute', left: '50%', bottom: '8%',
|
||||||
|
width: 3, height: 0, background: 'linear-gradient(to top, transparent, rgba(255,255,255,0.5))',
|
||||||
|
transformOrigin: 'bottom center',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Logo fade in + burst */}
|
||||||
|
<div className="takeoff-logo" style={{
|
||||||
|
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
|
||||||
|
}}>
|
||||||
|
<img src="/logo-light.svg" alt="NOMAD" style={{ height: 72 }} />
|
||||||
|
<p style={{ margin: 0, fontSize: 20, color: 'rgba(255,255,255,0.6)', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase' }}>{t('login.tagline')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.takeoff-sky {
|
||||||
|
background: linear-gradient(to top, #1a1a2e 0%, #16213e 30%, #0f3460 60%, #0a0a23 100%);
|
||||||
|
animation: skyShift 2.6s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
@keyframes skyShift {
|
||||||
|
0% { background: linear-gradient(to top, #0a0a23 0%, #0f172a 40%, #111827 100%); }
|
||||||
|
100% { background: linear-gradient(to top, #000011 0%, #000016 50%, #000011 100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.takeoff-star {
|
||||||
|
opacity: 0;
|
||||||
|
animation: starAppear 0.5s ease-out forwards, starTwinkle 2s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes starAppear {
|
||||||
|
0% { opacity: 0; transform: scale(0); }
|
||||||
|
100% { opacity: 0.7; transform: scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes starTwinkle {
|
||||||
|
0% { opacity: 0.3; }
|
||||||
|
100% { opacity: 0.9; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.takeoff-cloud {
|
||||||
|
animation: cloudRush 0.6s ease-in forwards;
|
||||||
|
}
|
||||||
|
@keyframes cloudRush {
|
||||||
|
0% { right: -200px; opacity: 0; }
|
||||||
|
20% { opacity: 0.4; }
|
||||||
|
100% { right: 120%; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.takeoff-speedline {
|
||||||
|
animation: speedRush 0.4s ease-in forwards;
|
||||||
|
}
|
||||||
|
@keyframes speedRush {
|
||||||
|
0% { right: -200px; opacity: 0; }
|
||||||
|
30% { opacity: 0.6; }
|
||||||
|
100% { right: 120%; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.takeoff-plane {
|
||||||
|
animation: planeUp 1s ease-in forwards;
|
||||||
|
}
|
||||||
|
@keyframes planeUp {
|
||||||
|
0% { transform: translate(-50%, 0) rotate(0deg) scale(1); bottom: 8%; left: 50%; opacity: 1; }
|
||||||
|
100% { transform: translate(-50%, 0) rotate(-22deg) scale(0.15); bottom: 120%; left: 58%; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.takeoff-trail {
|
||||||
|
animation: trailGrow 0.9s ease-out 0.15s forwards;
|
||||||
|
}
|
||||||
|
@keyframes trailGrow {
|
||||||
|
0% { height: 0; opacity: 0; transform: translateX(-50%) rotate(-5deg); }
|
||||||
|
30% { height: 150px; opacity: 0.6; }
|
||||||
|
60% { height: 350px; opacity: 0.4; }
|
||||||
|
100% { height: 600px; opacity: 0; transform: translateX(-50%) rotate(-8deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.takeoff-logo {
|
||||||
|
opacity: 0;
|
||||||
|
animation: logoReveal 0.5s ease-out 0.9s forwards;
|
||||||
|
}
|
||||||
|
@keyframes logoReveal {
|
||||||
|
0% { opacity: 0; transform: translate(-50%, -40%) scale(0.9); }
|
||||||
|
100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}>
|
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}>
|
||||||
|
|
||||||
@@ -191,14 +368,11 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<div style={{ position: 'relative', zIndex: 1, maxWidth: 560, textAlign: 'center' }}>
|
<div style={{ position: 'relative', zIndex: 1, maxWidth: 560, textAlign: 'center' }}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 48, justifyContent: 'center' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 48 }}>
|
||||||
<div style={{ width: 48, height: 48, background: 'white', borderRadius: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 0 30px rgba(255,255,255,0.1)' }}>
|
<img src="/logo-light.svg" alt="NOMAD" style={{ height: 64 }} />
|
||||||
<Plane size={24} style={{ color: '#0f172a' }} />
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: 28, fontWeight: 800, color: 'white', letterSpacing: '-0.02em' }}>NOMAD</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 style={{ margin: '0 0 12px', fontSize: 36, fontWeight: 800, color: 'white', lineHeight: 1.15, letterSpacing: '-0.02em' }}>
|
<h2 style={{ margin: '0 0 12px', fontSize: 36, fontWeight: 700, color: 'white', lineHeight: 1.15, letterSpacing: '-0.02em', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase' }}>
|
||||||
{t('login.tagline')}
|
{t('login.tagline')}
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ margin: '0 0 44px', fontSize: 15, color: 'rgba(255,255,255,0.5)', lineHeight: 1.7 }}>
|
<p style={{ margin: '0 0 44px', fontSize: 15, color: 'rgba(255,255,255,0.5)', lineHeight: 1.7 }}>
|
||||||
@@ -237,13 +411,11 @@ export default function LoginPage() {
|
|||||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||||
|
|
||||||
{/* Mobile logo */}
|
{/* Mobile logo */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 36, justifyContent: 'center' }}
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, marginBottom: 36 }}
|
||||||
className="mobile-logo">
|
className="mobile-logo">
|
||||||
<style>{`@media(min-width:1024px){.mobile-logo{display:none!important}}`}</style>
|
<style>{`@media(min-width:1024px){.mobile-logo{display:none!important}}`}</style>
|
||||||
<div style={{ width: 36, height: 36, background: '#111827', borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<img src="/logo-dark.svg" alt="NOMAD" style={{ height: 48 }} />
|
||||||
<Plane size={18} style={{ color: 'white' }} />
|
<p style={{ margin: 0, fontSize: 18, color: '#9ca3af', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase' }}>{t('login.tagline')}</p>
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: 22, fontWeight: 800, color: '#111827', letterSpacing: '-0.02em' }}>NOMAD</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ background: 'white', borderRadius: 20, border: '1px solid #e5e7eb', padding: '36px 32px', boxShadow: '0 2px 16px rgba(0,0,0,0.06)' }}>
|
<div style={{ background: 'white', borderRadius: 20, border: '1px solid #e5e7eb', padding: '36px 32px', boxShadow: '0 2px 16px rgba(0,0,0,0.06)' }}>
|
||||||
@@ -322,7 +494,7 @@ export default function LoginPage() {
|
|||||||
>
|
>
|
||||||
{isLoading
|
{isLoading
|
||||||
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : t('login.signingIn')}</>
|
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : t('login.signingIn')}</>
|
||||||
: mode === 'register' ? t('login.createAccount') : t('login.signIn')
|
: <><Plane size={16} />{mode === 'register' ? t('login.createAccount') : t('login.signIn')}</>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -339,6 +511,33 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* OIDC / SSO login button */}
|
||||||
|
{appConfig?.oidc_configured && (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}>
|
||||||
|
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
||||||
|
<span style={{ fontSize: 12, color: '#9ca3af' }}>{language === 'de' ? 'oder' : 'or'}</span>
|
||||||
|
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
||||||
|
</div>
|
||||||
|
<a href="/api/auth/oidc/login"
|
||||||
|
style={{
|
||||||
|
marginTop: 12, width: '100%', padding: '12px',
|
||||||
|
background: 'white', color: '#374151',
|
||||||
|
border: '1px solid #d1d5db', borderRadius: 12,
|
||||||
|
fontSize: 14, fontWeight: 600, cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
|
textDecoration: 'none', transition: 'all 0.15s',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = '#f9fafb'; e.currentTarget.style.borderColor = '#9ca3af' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'white'; e.currentTarget.style.borderColor = '#d1d5db' }}
|
||||||
|
>
|
||||||
|
<Shield size={16} />
|
||||||
|
{language === 'de' ? `Anmelden mit ${appConfig.oidc_display_name}` : `Sign in with ${appConfig.oidc_display_name}`}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Demo login button */}
|
{/* Demo login button */}
|
||||||
{appConfig?.demo_mode && (
|
{appConfig?.demo_mode && (
|
||||||
<button onClick={handleDemoLogin} disabled={isLoading}
|
<button onClick={handleDemoLogin} disabled={isLoading}
|
||||||
@@ -355,7 +554,7 @@ export default function LoginPage() {
|
|||||||
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 12px rgba(245, 158, 11, 0.3)' }}
|
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 12px rgba(245, 158, 11, 0.3)' }}
|
||||||
>
|
>
|
||||||
<Plane size={18} />
|
<Plane size={18} />
|
||||||
Demo ausprobieren — ohne Registrierung
|
{language === 'de' ? 'Demo ausprobieren — ohne Registrierung' : 'Try the demo — no registration needed'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function PhotosPage() {
|
|||||||
<div className="min-h-screen bg-slate-50">
|
<div className="min-h-screen bg-slate-50">
|
||||||
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
|
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
|
||||||
|
|
||||||
<div className="pt-14">
|
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ 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 } from 'lucide-react'
|
import { Save, Map, Palette, User, Moon, Sun, Shield, Camera, Trash2, Lock } from 'lucide-react'
|
||||||
|
import { authApi, adminApi } from '../api/client'
|
||||||
|
|
||||||
const MAP_PRESETS = [
|
const MAP_PRESETS = [
|
||||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||||
@@ -31,7 +32,8 @@ function Section({ title, icon: Icon, children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { user, updateProfile, uploadAvatar, deleteAvatar } = useAuthStore()
|
const { user, updateProfile, uploadAvatar, deleteAvatar, logout } = useAuthStore()
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const avatarInputRef = React.useRef(null)
|
const avatarInputRef = React.useRef(null)
|
||||||
const { settings, updateSetting, updateSettings } = useSettingsStore()
|
const { settings, updateSetting, updateSettings } = useSettingsStore()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
@@ -52,6 +54,8 @@ export default function SettingsPage() {
|
|||||||
// Account
|
// Account
|
||||||
const [username, setUsername] = useState(user?.username || '')
|
const [username, setUsername] = useState(user?.username || '')
|
||||||
const [email, setEmail] = useState(user?.email || '')
|
const [email, setEmail] = useState(user?.email || '')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMapTileUrl(settings.map_tile_url || '')
|
setMapTileUrl(settings.map_tile_url || '')
|
||||||
@@ -132,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>
|
||||||
@@ -344,76 +348,244 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Change Password */}
|
||||||
|
<div style={{ paddingTop: 8, marginTop: 8, borderTop: '1px solid var(--border-secondary)' }}>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-3">{t('settings.changePassword')}</label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
|
placeholder={t('settings.newPassword')}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder={t('settings.confirmPassword')}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!newPassword) return toast.error(t('settings.passwordRequired'))
|
||||||
|
if (newPassword.length < 8) return toast.error(t('settings.passwordTooShort'))
|
||||||
|
if (newPassword !== confirmPassword) return toast.error(t('settings.passwordMismatch'))
|
||||||
|
try {
|
||||||
|
await authApi.changePassword({ new_password: newPassword })
|
||||||
|
toast.success(t('settings.passwordChanged'))
|
||||||
|
setNewPassword(''); setConfirmPassword('')
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.error || t('common.error'))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||||
|
>
|
||||||
|
<Lock size={14} />
|
||||||
|
{t('settings.updatePassword')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{user?.avatar_url ? (
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
<img src={user.avatar_url} alt="" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
|
{user?.avatar_url ? (
|
||||||
) : (
|
<img src={user.avatar_url} alt="" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover' }} />
|
||||||
<div style={{
|
) : (
|
||||||
width: 64, height: 64, borderRadius: '50%', flexShrink: 0,
|
<div style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
width: 64, height: 64, borderRadius: '50%',
|
||||||
fontSize: 24, fontWeight: 700,
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
background: 'var(--bg-hover)', color: 'var(--text-secondary)',
|
fontSize: 24, fontWeight: 700,
|
||||||
}}>
|
background: 'var(--bg-hover)', color: 'var(--text-secondary)',
|
||||||
{user?.username?.charAt(0).toUpperCase()}
|
}}>
|
||||||
</div>
|
{user?.username?.charAt(0).toUpperCase()}
|
||||||
)}
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
)}
|
||||||
|
<input
|
||||||
|
ref={avatarInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleAvatarUpload}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => avatarInputRef.current?.click()}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', bottom: -3, right: -3,
|
||||||
|
width: 28, height: 28, borderRadius: '50%',
|
||||||
|
background: 'var(--text-primary)', color: 'var(--bg-card)',
|
||||||
|
border: '2px solid var(--bg-card)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: 'pointer', padding: 0, transition: 'transform 0.15s, opacity 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.15)'; e.currentTarget.style.opacity = '0.85' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.opacity = '1' }}
|
||||||
|
>
|
||||||
|
<Camera size={14} />
|
||||||
|
</button>
|
||||||
|
{user?.avatar_url && (
|
||||||
|
<button
|
||||||
|
onClick={handleAvatarRemove}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: -2, right: -2,
|
||||||
|
width: 20, height: 20, borderRadius: '50%',
|
||||||
|
background: '#ef4444', color: 'white',
|
||||||
|
border: '2px solid var(--bg-card)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: 'pointer', padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={10} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
<div className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
<div className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||||
<span className="font-medium" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-secondary)' }}>
|
<span className="font-medium" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-secondary)' }}>
|
||||||
{user?.role === 'admin' ? <><Shield size={13} /> {t('settings.roleAdmin')}</> : t('settings.roleUser')}
|
{user?.role === 'admin' ? <><Shield size={13} /> {t('settings.roleAdmin')}</> : t('settings.roleUser')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
{user?.oidc_issuer && (
|
||||||
<div className="flex items-center gap-2">
|
<span style={{
|
||||||
<input
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
ref={avatarInputRef}
|
fontSize: 10, fontWeight: 500, padding: '1px 8px', borderRadius: 99,
|
||||||
type="file"
|
background: '#dbeafe', color: '#1d4ed8', marginLeft: 6,
|
||||||
accept="image/*"
|
}}>
|
||||||
onChange={handleAvatarUpload}
|
SSO
|
||||||
style={{ display: 'none' }}
|
</span>
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => avatarInputRef.current?.click()}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--border-primary)',
|
|
||||||
background: 'var(--bg-card)',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
|
||||||
>
|
|
||||||
<Camera size={14} />
|
|
||||||
{t('settings.uploadAvatar')}
|
|
||||||
</button>
|
|
||||||
{user?.avatar_url && (
|
|
||||||
<button
|
|
||||||
onClick={handleAvatarRemove}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--border-primary)',
|
|
||||||
background: 'var(--bg-card)',
|
|
||||||
color: '#ef4444',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
{t('settings.removeAvatar')}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{user?.oidc_issuer && (
|
||||||
|
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -2 }}>
|
||||||
|
{t('settings.oidcLinked')} {user.oidc_issuer.replace('https://', '').replace(/\/+$/, '')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
|
||||||
onClick={saveProfile}
|
<button
|
||||||
disabled={saving.profile}
|
onClick={saveProfile}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
disabled={saving.profile}
|
||||||
>
|
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||||
{saving.profile ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
>
|
||||||
{t('settings.saveProfile')}
|
{saving.profile ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
</button>
|
{t('settings.saveProfile')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (user?.role === 'admin') {
|
||||||
|
try {
|
||||||
|
const data = await adminApi.stats()
|
||||||
|
const adminUsers = (await adminApi.users()).users.filter(u => u.role === 'admin')
|
||||||
|
if (adminUsers.length <= 1) {
|
||||||
|
setShowDeleteConfirm('blocked')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
setShowDeleteConfirm(true)
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors text-red-500 hover:bg-red-50"
|
||||||
|
style={{ border: '1px solid #fecaca' }}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
{t('settings.deleteAccount')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* Delete Account Confirmation */}
|
||||||
|
{showDeleteConfirm === 'blocked' && (
|
||||||
|
<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: 24,
|
||||||
|
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
|
||||||
|
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||||
|
}} onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||||
|
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef3c7', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Shield size={18} style={{ color: '#d97706' }} />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteBlockedTitle')}</h3>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}>
|
||||||
|
{t('settings.deleteBlockedMessage')}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||||
|
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.ok') || 'OK'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDeleteConfirm === true && (
|
||||||
|
<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: 24,
|
||||||
|
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
|
||||||
|
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||||
|
}} onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||||
|
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Trash2 size={18} style={{ color: '#ef4444' }} />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteAccountTitle')}</h3>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}>
|
||||||
|
{t('settings.deleteAccountWarning')}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||||
|
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await authApi.deleteOwnAccount()
|
||||||
|
logout()
|
||||||
|
navigate('/login')
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.error || t('common.error'))
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 600,
|
||||||
|
border: 'none', background: '#ef4444', color: 'white',
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.deleteAccountConfirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useTripStore } from '../store/tripStore'
|
import { useTripStore } from '../store/tripStore'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
@@ -19,6 +20,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'
|
||||||
|
|
||||||
const MIN_SIDEBAR = 200
|
const MIN_SIDEBAR = 200
|
||||||
const MAX_SIDEBAR = 520
|
const MAX_SIDEBAR = 520
|
||||||
@@ -32,12 +34,22 @@ export default function TripPlannerPage() {
|
|||||||
const tripStore = useTripStore()
|
const tripStore = useTripStore()
|
||||||
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 })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
addonsApi.enabled().then(data => {
|
||||||
|
const map = {}
|
||||||
|
data.addons.forEach(a => { map[a.id] = true })
|
||||||
|
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents })
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const TRIP_TABS = [
|
const TRIP_TABS = [
|
||||||
{ id: 'plan', label: t('trip.tabs.plan') },
|
{ id: 'plan', label: t('trip.tabs.plan') },
|
||||||
{ id: 'buchungen', label: t('trip.tabs.reservations') },
|
{ id: 'buchungen', label: t('trip.tabs.reservations'), shortLabel: t('trip.tabs.reservationsShort') },
|
||||||
{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') },
|
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
|
||||||
{ id: 'finanzplan', label: t('trip.tabs.budget') },
|
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
|
||||||
{ id: 'dateien', label: t('trip.tabs.files') },
|
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []),
|
||||||
]
|
]
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('plan')
|
const [activeTab, setActiveTab] = useState('plan')
|
||||||
@@ -121,13 +133,8 @@ export default function TripPlannerPage() {
|
|||||||
return places.filter(p => p.lat && p.lng)
|
return places.filter(p => p.lat && p.lng)
|
||||||
}, [places])
|
}, [places])
|
||||||
|
|
||||||
const handleSelectDay = useCallback((dayId) => {
|
const updateRouteForDay = useCallback((dayId) => {
|
||||||
tripStore.setSelectedDay(dayId)
|
if (!dayId) { setRoute(null); setRouteInfo(null); return }
|
||||||
setRouteInfo(null)
|
|
||||||
setFitKey(k => k + 1)
|
|
||||||
setMobileSidebarOpen(null)
|
|
||||||
|
|
||||||
// Auto-show Luftlinien for the selected day
|
|
||||||
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||||
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng)
|
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||||
if (waypoints.length >= 2) {
|
if (waypoints.length >= 2) {
|
||||||
@@ -135,12 +142,22 @@ export default function TripPlannerPage() {
|
|||||||
} else {
|
} else {
|
||||||
setRoute(null)
|
setRoute(null)
|
||||||
}
|
}
|
||||||
|
setRouteInfo(null)
|
||||||
}, [tripStore])
|
}, [tripStore])
|
||||||
|
|
||||||
|
const handleSelectDay = useCallback((dayId, skipFit) => {
|
||||||
|
const changed = dayId !== selectedDayId
|
||||||
|
tripStore.setSelectedDay(dayId)
|
||||||
|
if (changed && !skipFit) setFitKey(k => k + 1)
|
||||||
|
setMobileSidebarOpen(null)
|
||||||
|
updateRouteForDay(dayId)
|
||||||
|
}, [tripStore, updateRouteForDay, selectedDayId])
|
||||||
|
|
||||||
const handlePlaceClick = useCallback((placeId) => {
|
const handlePlaceClick = useCallback((placeId) => {
|
||||||
setSelectedPlaceId(placeId)
|
setSelectedPlaceId(placeId)
|
||||||
if (placeId) { setLeftCollapsed(false); setRightCollapsed(false) }
|
if (placeId) { setLeftCollapsed(false); setRightCollapsed(false) }
|
||||||
}, [])
|
updateRouteForDay(selectedDayId)
|
||||||
|
}, [selectedDayId, updateRouteForDay])
|
||||||
|
|
||||||
const handleMarkerClick = useCallback((placeId) => {
|
const handleMarkerClick = useCallback((placeId) => {
|
||||||
const opening = placeId !== undefined
|
const opening = placeId !== undefined
|
||||||
@@ -177,16 +194,29 @@ export default function TripPlannerPage() {
|
|||||||
try {
|
try {
|
||||||
await tripStore.assignPlaceToDay(tripId, target, placeId, position)
|
await tripStore.assignPlaceToDay(tripId, target, placeId, position)
|
||||||
toast.success(t('trip.toast.assignedToDay'))
|
toast.success(t('trip.toast.assignedToDay'))
|
||||||
|
updateRouteForDay(target)
|
||||||
} catch (err) { toast.error(err.message) }
|
} catch (err) { toast.error(err.message) }
|
||||||
}, [selectedDayId, tripId, tripStore, toast])
|
}, [selectedDayId, tripId, tripStore, toast, updateRouteForDay])
|
||||||
|
|
||||||
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
|
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
|
||||||
try { await tripStore.removeAssignment(tripId, dayId, assignmentId) }
|
try {
|
||||||
|
await tripStore.removeAssignment(tripId, dayId, assignmentId)
|
||||||
|
updateRouteForDay(dayId)
|
||||||
|
}
|
||||||
catch (err) { toast.error(err.message) }
|
catch (err) { toast.error(err.message) }
|
||||||
}, [tripId, tripStore, toast])
|
}, [tripId, tripStore, toast, updateRouteForDay])
|
||||||
|
|
||||||
const handleReorder = useCallback(async (dayId, orderedIds) => {
|
const handleReorder = useCallback(async (dayId, orderedIds) => {
|
||||||
try { await tripStore.reorderAssignments(tripId, dayId, orderedIds) }
|
try {
|
||||||
|
await tripStore.reorderAssignments(tripId, dayId, orderedIds)
|
||||||
|
// Build route directly from orderedIds to avoid stale closure
|
||||||
|
const dayItems = tripStore.assignments[String(dayId)] || []
|
||||||
|
const ordered = orderedIds.map(id => dayItems.find(a => a.id === id)).filter(Boolean)
|
||||||
|
const waypoints = ordered.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||||
|
if (waypoints.length >= 2) setRoute(waypoints.map(p => [p.lat, p.lng]))
|
||||||
|
else setRoute(null)
|
||||||
|
setRouteInfo(null)
|
||||||
|
}
|
||||||
catch { toast.error(t('trip.toast.reorderError')) }
|
catch { toast.error(t('trip.toast.reorderError')) }
|
||||||
}, [tripId, tripStore, toast])
|
}, [tripId, tripStore, toast])
|
||||||
|
|
||||||
@@ -247,11 +277,11 @@ export default function TripPlannerPage() {
|
|||||||
if (!trip) return null
|
if (!trip) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}>
|
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}>
|
||||||
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
|
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', top: 56, left: 0, right: 0, zIndex: 40,
|
position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, zIndex: 40,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
padding: '0 12px',
|
padding: '0 12px',
|
||||||
background: 'var(--bg-elevated)',
|
background: 'var(--bg-elevated)',
|
||||||
@@ -286,8 +316,8 @@ export default function TripPlannerPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Offset by navbar (56px) + tab bar (44px) */}
|
{/* Offset by navbar + tab bar (44px) */}
|
||||||
<div style={{ flex: 1, overflow: 'hidden', marginTop: 100, position: 'relative' }}>
|
<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 }}>
|
||||||
@@ -321,7 +351,7 @@ export default function TripPlannerPage() {
|
|||||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||||
<button onClick={() => setLeftCollapsed(c => !c)}
|
<button onClick={() => setLeftCollapsed(c => !c)}
|
||||||
style={{
|
style={{
|
||||||
position: leftCollapsed ? 'fixed' : 'absolute', top: leftCollapsed ? 'calc(56px + 44px + 14px)' : 14, left: leftCollapsed ? 10 : undefined, right: leftCollapsed ? undefined : -28, zIndex: 25,
|
position: leftCollapsed ? 'fixed' : 'absolute', top: leftCollapsed ? 'calc(var(--nav-h) + 44px + 14px)' : 14, left: leftCollapsed ? 10 : undefined, right: leftCollapsed ? undefined : -28, zIndex: 25,
|
||||||
width: 36, height: 36, borderRadius: leftCollapsed ? 10 : '0 10px 10px 0',
|
width: 36, height: 36, borderRadius: leftCollapsed ? 10 : '0 10px 10px 0',
|
||||||
background: leftCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
background: leftCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||||
boxShadow: leftCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
boxShadow: leftCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
||||||
@@ -376,7 +406,7 @@ export default function TripPlannerPage() {
|
|||||||
<div className="hidden md:block" style={{ position: 'absolute', right: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
<div className="hidden md:block" style={{ position: 'absolute', right: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||||
<button onClick={() => setRightCollapsed(c => !c)}
|
<button onClick={() => setRightCollapsed(c => !c)}
|
||||||
style={{
|
style={{
|
||||||
position: rightCollapsed ? 'fixed' : 'absolute', top: rightCollapsed ? 'calc(56px + 44px + 14px)' : 14, right: rightCollapsed ? 10 : undefined, left: rightCollapsed ? undefined : -28, zIndex: 25,
|
position: rightCollapsed ? 'fixed' : 'absolute', top: rightCollapsed ? 'calc(var(--nav-h) + 44px + 14px)' : 14, right: rightCollapsed ? 10 : undefined, left: rightCollapsed ? undefined : -28, zIndex: 25,
|
||||||
width: 36, height: 36, borderRadius: rightCollapsed ? 10 : '10px 0 0 10px',
|
width: 36, height: 36, borderRadius: rightCollapsed ? 10 : '10px 0 0 10px',
|
||||||
background: rightCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
background: rightCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||||
boxShadow: rightCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
boxShadow: rightCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
||||||
@@ -422,16 +452,20 @@ export default function TripPlannerPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex md:hidden" style={{ position: 'absolute', top: 12, left: 12, right: 12, justifyContent: 'space-between', zIndex: 30 }}>
|
{/* Mobile sidebar buttons — portal to body to escape Leaflet touch handling */}
|
||||||
<button onClick={() => setMobileSidebarOpen('left')}
|
{activeTab === 'plan' && !mobileSidebarOpen && !showPlaceForm && !showMembersModal && !showReservationModal && ReactDOM.createPortal(
|
||||||
style={{ 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' }}>
|
<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' }}>
|
||||||
{t('trip.mobilePlan')}
|
<button onClick={() => setMobileSidebarOpen('left')}
|
||||||
</button>
|
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' }}>
|
||||||
<button onClick={() => setMobileSidebarOpen('right')}
|
{t('trip.mobilePlan')}
|
||||||
style={{ 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' }}>
|
</button>
|
||||||
{t('trip.mobilePlaces')}
|
<button onClick={() => setMobileSidebarOpen('right')}
|
||||||
</button>
|
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' }}>
|
||||||
</div>
|
{t('trip.mobilePlaces')}
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedPlace && (
|
{selectedPlace && (
|
||||||
<PlaceInspector
|
<PlaceInspector
|
||||||
@@ -450,9 +484,9 @@ export default function TripPlannerPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mobileSidebarOpen && (
|
{mobileSidebarOpen && ReactDOM.createPortal(
|
||||||
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.3)', zIndex: 50 }} 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', bottom: 0, left: 0, right: 0, background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', maxHeight: '80vh', 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)' }}>
|
||||||
@@ -461,18 +495,19 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'buchungen' && (
|
{activeTab === 'buchungen' && (
|
||||||
<div style={{ height: '100%', maxWidth: 1200, margin: '0 auto', width: '100%', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ height: '100%', maxWidth: 1200, margin: '0 auto', width: '100%', display: 'flex', flexDirection: 'column', overflowY: 'auto', overscrollBehavior: 'contain' }}>
|
||||||
<ReservationsPanel
|
<ReservationsPanel
|
||||||
tripId={tripId}
|
tripId={tripId}
|
||||||
reservations={reservations}
|
reservations={reservations}
|
||||||
@@ -488,19 +523,19 @@ export default function TripPlannerPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'packliste' && (
|
{activeTab === 'packliste' && (
|
||||||
<div style={{ height: '100%', overflowY: 'auto', maxWidth: 1200, margin: '0 auto', width: '100%', padding: '8px 0' }}>
|
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1200, margin: '0 auto', width: '100%', padding: '8px 0' }}>
|
||||||
<PackingListPanel tripId={tripId} items={packingItems} />
|
<PackingListPanel tripId={tripId} items={packingItems} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'finanzplan' && (
|
{activeTab === 'finanzplan' && (
|
||||||
<div style={{ height: '100%', overflowY: 'auto', maxWidth: 1400, margin: '0 auto', width: '100%', padding: '8px 0' }}>
|
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1400, margin: '0 auto', width: '100%', padding: '8px 0' }}>
|
||||||
<BudgetPanel tripId={tripId} />
|
<BudgetPanel tripId={tripId} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'dateien' && (
|
{activeTab === 'dateien' && (
|
||||||
<div style={{ height: '100%', overflow: 'hidden' }}>
|
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain' }}>
|
||||||
<FileManager
|
<FileManager
|
||||||
files={files || []}
|
files={files || []}
|
||||||
onUpload={(fd) => tripStore.addFile(tripId, fd)}
|
onUpload={(fd) => tripStore.addFile(tripId, fd)}
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import { useTranslation } from '../i18n'
|
||||||
|
import { useVacayStore } from '../store/vacayStore'
|
||||||
|
import { addListener, removeListener } from '../api/websocket'
|
||||||
|
import Navbar from '../components/Layout/Navbar'
|
||||||
|
import VacayCalendar from '../components/Vacay/VacayCalendar'
|
||||||
|
import VacayPersons from '../components/Vacay/VacayPersons'
|
||||||
|
import VacayStats from '../components/Vacay/VacayStats'
|
||||||
|
import VacaySettings from '../components/Vacay/VacaySettings'
|
||||||
|
import { Plus, Minus, ChevronLeft, ChevronRight, Settings, CalendarDays, AlertTriangle, Users, Eye, Pencil, Trash2, Unlink, ShieldCheck, SlidersHorizontal } from 'lucide-react'
|
||||||
|
import Modal from '../components/shared/Modal'
|
||||||
|
|
||||||
|
export default function VacayPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { years, selectedYear, setSelectedYear, addYear, removeYear, loadAll, loadPlan, loadEntries, loadStats, loadHolidays, loading, incomingInvites, acceptInvite, declineInvite, plan } = useVacayStore()
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
const [deleteYear, setDeleteYear] = useState(null)
|
||||||
|
const [showMobileSidebar, setShowMobileSidebar] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => { loadAll() }, [])
|
||||||
|
|
||||||
|
// Live sync via WebSocket
|
||||||
|
const handleWsMessage = useCallback((msg) => {
|
||||||
|
if (msg.type === 'vacay:update' || msg.type === 'vacay:settings') {
|
||||||
|
loadPlan()
|
||||||
|
loadEntries(selectedYear)
|
||||||
|
loadStats(selectedYear)
|
||||||
|
if (msg.type === 'vacay:settings') loadAll()
|
||||||
|
}
|
||||||
|
if (msg.type === 'vacay:invite' || msg.type === 'vacay:accepted' || msg.type === 'vacay:declined' || msg.type === 'vacay:cancelled' || msg.type === 'vacay:dissolved') {
|
||||||
|
loadAll()
|
||||||
|
}
|
||||||
|
}, [selectedYear])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
addListener(handleWsMessage)
|
||||||
|
return () => removeListener(handleWsMessage)
|
||||||
|
}, [handleWsMessage])
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedYear) { loadEntries(selectedYear); loadStats(selectedYear); loadHolidays(selectedYear) }
|
||||||
|
}, [selectedYear])
|
||||||
|
|
||||||
|
const handleAddYear = () => {
|
||||||
|
const nextYear = years.length > 0 ? Math.max(...years) + 1 : new Date().getFullYear()
|
||||||
|
addYear(nextYear)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
|
||||||
|
<Navbar />
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidebar content (shared between desktop sidebar and mobile drawer)
|
||||||
|
const sidebarContent = (
|
||||||
|
<>
|
||||||
|
{/* Year Selector */}
|
||||||
|
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>{t('vacay.year')}</span>
|
||||||
|
<button onClick={handleAddYear} className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }} title={t('vacay.addYear')}>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx > 0) setSelectedYear(years[idx - 1]) }} disabled={years.indexOf(selectedYear) <= 0} className="p-1 lg:p-1 p-2 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<ChevronLeft size={16} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
</button>
|
||||||
|
<span className="text-xl font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{selectedYear}</span>
|
||||||
|
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx < years.length - 1) setSelectedYear(years[idx + 1]) }} disabled={years.indexOf(selectedYear) >= years.length - 1} className="p-1 lg:p-1 p-2 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<ChevronRight size={16} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-1">
|
||||||
|
{years.map(y => (
|
||||||
|
<div key={y} onClick={() => setSelectedYear(y)}
|
||||||
|
className="group relative py-1.5 rounded-lg text-xs font-medium transition-all text-center cursor-pointer"
|
||||||
|
style={{
|
||||||
|
background: y === selectedYear ? 'var(--text-primary)' : 'var(--bg-secondary)',
|
||||||
|
color: y === selectedYear ? 'var(--bg-card)' : 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
{y}
|
||||||
|
{years.length > 1 && (
|
||||||
|
<span onClick={e => { e.stopPropagation(); setDeleteYear(y); setShowMobileSidebar(false) }}
|
||||||
|
className="absolute -top-1 -right-1 w-3.5 h-3.5 rounded-full bg-red-500 text-white text-[7px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer">
|
||||||
|
<Minus size={7} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VacayPersons />
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
{(plan?.holidays_enabled || plan?.company_holidays_enabled || plan?.block_weekends) && (
|
||||||
|
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>{t('vacay.legend')}</span>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1.5">
|
||||||
|
{plan?.holidays_enabled && <LegendItem color="#fecaca" label={t('vacay.publicHoliday')} />}
|
||||||
|
{plan?.company_holidays_enabled && <LegendItem color="#fde68a" label={t('vacay.companyHoliday')} />}
|
||||||
|
{plan?.block_weekends && <LegendItem color="#e5e7eb" label={t('vacay.weekend')} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VacayStats />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||||
|
<div className="max-w-[1800px] mx-auto px-3 sm:px-4 py-4 sm:py-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4 sm:mb-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-xl flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<CalendarDays size={18} style={{ color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg sm:text-xl font-bold" style={{ color: 'var(--text-primary)' }}>Vacay</h1>
|
||||||
|
<p className="text-xs hidden sm:block" style={{ color: 'var(--text-muted)' }}>{t('vacay.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Mobile sidebar toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMobileSidebar(true)}
|
||||||
|
className="lg:hidden flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors"
|
||||||
|
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSettings(true)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors"
|
||||||
|
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
<Settings size={14} />
|
||||||
|
<span className="hidden sm:inline">{t('vacay.settings')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main layout */}
|
||||||
|
<div className="flex gap-4 items-start">
|
||||||
|
{/* Desktop Sidebar */}
|
||||||
|
<div className="hidden lg:flex w-[240px] shrink-0 flex-col gap-3 sticky top-[70px]">
|
||||||
|
{sidebarContent}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<VacayCalendar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Sidebar Drawer */}
|
||||||
|
{showMobileSidebar && ReactDOM.createPortal(
|
||||||
|
<div className="fixed inset-0 lg:hidden" style={{ zIndex: 99980 }}>
|
||||||
|
<div className="absolute inset-0" style={{ background: 'rgba(0,0,0,0.4)' }} onClick={() => setShowMobileSidebar(false)} />
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-[280px] overflow-y-auto p-3 flex flex-col gap-3"
|
||||||
|
style={{ background: 'var(--bg-primary)', boxShadow: '4px 0 24px rgba(0,0,0,0.15)', animation: 'slideInLeft 0.2s ease-out' }}>
|
||||||
|
{sidebarContent}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settings Modal */}
|
||||||
|
<Modal isOpen={showSettings} onClose={() => setShowSettings(false)} title={t('vacay.settings')} size="md">
|
||||||
|
<VacaySettings onClose={() => setShowSettings(false)} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete Year Modal */}
|
||||||
|
<Modal isOpen={deleteYear !== null} onClose={() => setDeleteYear(null)} title={t('vacay.removeYear')} size="sm">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-3 p-3 rounded-lg" style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.15)' }}>
|
||||||
|
<AlertTriangle size={18} className="text-red-500 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{t('vacay.removeYearConfirm', { year: deleteYear })}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{t('vacay.removeYearHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button onClick={() => setDeleteYear(null)} className="px-4 py-2 text-sm rounded-lg transition-colors" style={{ color: 'var(--text-muted)', border: '1px solid var(--border-primary)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={async () => { await removeYear(deleteYear); setDeleteYear(null) }} className="px-4 py-2 text-sm bg-red-500 hover:bg-red-600 text-white rounded-lg transition-colors">
|
||||||
|
{t('vacay.remove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Incoming invite — forced fullscreen modal */}
|
||||||
|
{incomingInvites.length > 0 && ReactDOM.createPortal(
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center px-4"
|
||||||
|
style={{ zIndex: 99995, backgroundColor: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(8px)' }}>
|
||||||
|
{incomingInvites.map(inv => (
|
||||||
|
<div key={inv.id} className="w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
|
||||||
|
style={{ background: 'var(--bg-card)', animation: 'modalIn 0.25s ease-out' }}>
|
||||||
|
<div className="px-6 pt-6 pb-4 text-center">
|
||||||
|
<div className="w-14 h-14 rounded-full mx-auto mb-4 flex items-center justify-center text-lg font-bold"
|
||||||
|
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
|
||||||
|
{inv.username?.[0]?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{t('vacay.inviteTitle')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
<span className="font-semibold" style={{ color: 'var(--text-primary)' }}>{inv.username}</span> {t('vacay.inviteWantsToFuse')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-4 space-y-2">
|
||||||
|
<InfoItem icon={Eye} text={t('vacay.fuseInfo1')} />
|
||||||
|
<InfoItem icon={Pencil} text={t('vacay.fuseInfo2')} />
|
||||||
|
<InfoItem icon={Trash2} text={t('vacay.fuseInfo3')} />
|
||||||
|
<InfoItem icon={ShieldCheck} text={t('vacay.fuseInfo4')} />
|
||||||
|
<InfoItem icon={Unlink} text={t('vacay.fuseInfo5')} />
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-6 flex gap-3">
|
||||||
|
<button onClick={() => declineInvite(inv.plan_id)}
|
||||||
|
className="flex-1 px-4 py-2.5 text-sm font-medium rounded-xl transition-colors"
|
||||||
|
style={{ color: 'var(--text-muted)', border: '1px solid var(--border-primary)' }}>
|
||||||
|
{t('vacay.decline')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => acceptInvite(inv.plan_id)}
|
||||||
|
className="flex-1 px-4 py-2.5 text-sm font-medium rounded-xl transition-colors"
|
||||||
|
style={{ background: 'var(--text-primary)', color: 'var(--bg-card)' }}>
|
||||||
|
{t('vacay.acceptFusion')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes slideInLeft {
|
||||||
|
from { transform: translateX(-100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoItem({ icon: Icon, text }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<Icon size={15} className="shrink-0 mt-0.5" style={{ color: 'var(--text-muted)' }} />
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-primary)' }}>{text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegendItem({ color, label }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-4 h-3 rounded" style={{ background: color, border: `1px solid ${color}` }} />
|
||||||
|
<span className="text-[11px]" style={{ color: 'var(--text-muted)' }}>{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export const useAuthStore = create((set, get) => ({
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
demoMode: localStorage.getItem('demo_mode') === 'true',
|
demoMode: localStorage.getItem('demo_mode') === 'true',
|
||||||
|
hasMapsKey: false,
|
||||||
|
|
||||||
login: async (email, password) => {
|
login: async (email, password) => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
@@ -137,6 +138,8 @@ export const useAuthStore = create((set, get) => ({
|
|||||||
set({ demoMode: val })
|
set({ demoMode: val })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setHasMapsKey: (val) => set({ hasMapsKey: val }),
|
||||||
|
|
||||||
demoLogin: async () => {
|
demoLogin: async () => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
|
||||||
|
const ax = apiClient
|
||||||
|
const api = {
|
||||||
|
getPlan: () => ax.get('/addons/vacay/plan').then(r => r.data),
|
||||||
|
updatePlan: (data) => ax.put('/addons/vacay/plan', data).then(r => r.data),
|
||||||
|
updateColor: (color, targetUserId) => ax.put('/addons/vacay/color', { color, target_user_id: targetUserId }).then(r => r.data),
|
||||||
|
invite: (userId) => ax.post('/addons/vacay/invite', { user_id: userId }).then(r => r.data),
|
||||||
|
acceptInvite: (planId) => ax.post('/addons/vacay/invite/accept', { plan_id: planId }).then(r => r.data),
|
||||||
|
declineInvite: (planId) => ax.post('/addons/vacay/invite/decline', { plan_id: planId }).then(r => r.data),
|
||||||
|
cancelInvite: (userId) => ax.post('/addons/vacay/invite/cancel', { user_id: userId }).then(r => r.data),
|
||||||
|
dissolve: () => ax.post('/addons/vacay/dissolve').then(r => r.data),
|
||||||
|
availableUsers: () => ax.get('/addons/vacay/available-users').then(r => r.data),
|
||||||
|
getYears: () => ax.get('/addons/vacay/years').then(r => r.data),
|
||||||
|
addYear: (year) => ax.post('/addons/vacay/years', { year }).then(r => r.data),
|
||||||
|
removeYear: (year) => ax.delete(`/addons/vacay/years/${year}`).then(r => r.data),
|
||||||
|
getEntries: (year) => ax.get(`/addons/vacay/entries/${year}`).then(r => r.data),
|
||||||
|
toggleEntry: (date, targetUserId) => ax.post('/addons/vacay/entries/toggle', { date, target_user_id: targetUserId }).then(r => r.data),
|
||||||
|
toggleCompanyHoliday: (date) => ax.post('/addons/vacay/entries/company-holiday', { date }).then(r => r.data),
|
||||||
|
getStats: (year) => ax.get(`/addons/vacay/stats/${year}`).then(r => r.data),
|
||||||
|
updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId }).then(r => r.data),
|
||||||
|
getCountries: () => ax.get('/addons/vacay/holidays/countries').then(r => r.data),
|
||||||
|
getHolidays: (year, country) => ax.get(`/addons/vacay/holidays/${year}/${country}`).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useVacayStore = create((set, get) => ({
|
||||||
|
plan: null,
|
||||||
|
users: [],
|
||||||
|
pendingInvites: [],
|
||||||
|
incomingInvites: [],
|
||||||
|
isOwner: true,
|
||||||
|
isFused: false,
|
||||||
|
years: [],
|
||||||
|
entries: [],
|
||||||
|
companyHolidays: [],
|
||||||
|
stats: [],
|
||||||
|
selectedYear: new Date().getFullYear(),
|
||||||
|
selectedUserId: null,
|
||||||
|
holidays: {}, // date -> { name, localName }
|
||||||
|
loading: false,
|
||||||
|
|
||||||
|
setSelectedYear: (year) => set({ selectedYear: year }),
|
||||||
|
setSelectedUserId: (id) => set({ selectedUserId: id }),
|
||||||
|
|
||||||
|
loadPlan: async () => {
|
||||||
|
const data = await api.getPlan()
|
||||||
|
set({
|
||||||
|
plan: data.plan,
|
||||||
|
users: data.users,
|
||||||
|
pendingInvites: data.pendingInvites,
|
||||||
|
incomingInvites: data.incomingInvites,
|
||||||
|
isOwner: data.isOwner,
|
||||||
|
isFused: data.isFused,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePlan: async (updates) => {
|
||||||
|
const data = await api.updatePlan(updates)
|
||||||
|
set({ plan: data.plan })
|
||||||
|
await get().loadEntries()
|
||||||
|
await get().loadStats()
|
||||||
|
await get().loadHolidays()
|
||||||
|
},
|
||||||
|
|
||||||
|
updateColor: async (color, targetUserId) => {
|
||||||
|
await api.updateColor(color, targetUserId)
|
||||||
|
await get().loadPlan()
|
||||||
|
await get().loadEntries()
|
||||||
|
},
|
||||||
|
|
||||||
|
invite: async (userId) => {
|
||||||
|
await api.invite(userId)
|
||||||
|
await get().loadPlan()
|
||||||
|
},
|
||||||
|
|
||||||
|
acceptInvite: async (planId) => {
|
||||||
|
await api.acceptInvite(planId)
|
||||||
|
await get().loadAll()
|
||||||
|
},
|
||||||
|
|
||||||
|
declineInvite: async (planId) => {
|
||||||
|
await api.declineInvite(planId)
|
||||||
|
await get().loadPlan()
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelInvite: async (userId) => {
|
||||||
|
await api.cancelInvite(userId)
|
||||||
|
await get().loadPlan()
|
||||||
|
},
|
||||||
|
|
||||||
|
dissolve: async () => {
|
||||||
|
await api.dissolve()
|
||||||
|
await get().loadAll()
|
||||||
|
},
|
||||||
|
|
||||||
|
loadYears: async () => {
|
||||||
|
const data = await api.getYears()
|
||||||
|
set({ years: data.years })
|
||||||
|
if (data.years.length > 0) {
|
||||||
|
set({ selectedYear: data.years[data.years.length - 1] })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addYear: async (year) => {
|
||||||
|
const data = await api.addYear(year)
|
||||||
|
set({ years: data.years })
|
||||||
|
await get().loadStats(year)
|
||||||
|
},
|
||||||
|
|
||||||
|
removeYear: async (year) => {
|
||||||
|
const data = await api.removeYear(year)
|
||||||
|
set({ years: data.years })
|
||||||
|
},
|
||||||
|
|
||||||
|
loadEntries: async (year) => {
|
||||||
|
const y = year || get().selectedYear
|
||||||
|
const data = await api.getEntries(y)
|
||||||
|
set({ entries: data.entries, companyHolidays: data.companyHolidays })
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleEntry: async (date, targetUserId) => {
|
||||||
|
await api.toggleEntry(date, targetUserId)
|
||||||
|
await get().loadEntries()
|
||||||
|
await get().loadStats()
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleCompanyHoliday: async (date) => {
|
||||||
|
await api.toggleCompanyHoliday(date)
|
||||||
|
await get().loadEntries()
|
||||||
|
},
|
||||||
|
|
||||||
|
loadStats: async (year) => {
|
||||||
|
const y = year || get().selectedYear
|
||||||
|
const data = await api.getStats(y)
|
||||||
|
set({ stats: data.stats })
|
||||||
|
},
|
||||||
|
|
||||||
|
updateVacationDays: async (year, days, targetUserId) => {
|
||||||
|
await api.updateStats(year, days, targetUserId)
|
||||||
|
await get().loadStats(year)
|
||||||
|
},
|
||||||
|
|
||||||
|
loadHolidays: async (year) => {
|
||||||
|
const y = year || get().selectedYear
|
||||||
|
const plan = get().plan
|
||||||
|
if (!plan?.holidays_enabled || !plan?.holidays_region) {
|
||||||
|
set({ holidays: {} })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const country = plan.holidays_region.split('-')[0]
|
||||||
|
const region = plan.holidays_region.includes('-') ? plan.holidays_region : null
|
||||||
|
try {
|
||||||
|
const data = await api.getHolidays(y, country)
|
||||||
|
// Check if this country HAS regional holidays
|
||||||
|
const hasRegions = data.some(h => h.counties && h.counties.length > 0)
|
||||||
|
// If country has regions but no region selected yet, only show global ones
|
||||||
|
// Actually: don't show ANY holidays until region is selected
|
||||||
|
if (hasRegions && !region) {
|
||||||
|
set({ holidays: {} })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const map = {}
|
||||||
|
data.forEach(h => {
|
||||||
|
if (h.global || !h.counties || (region && h.counties.includes(region))) {
|
||||||
|
map[h.date] = { name: h.name, localName: h.localName }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
set({ holidays: map })
|
||||||
|
} catch {
|
||||||
|
set({ holidays: {} })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadAll: async () => {
|
||||||
|
set({ loading: true })
|
||||||
|
try {
|
||||||
|
await get().loadPlan()
|
||||||
|
await get().loadYears()
|
||||||
|
const year = get().selectedYear
|
||||||
|
await get().loadEntries(year)
|
||||||
|
await get().loadStats(year)
|
||||||
|
await get().loadHolidays(year)
|
||||||
|
} finally {
|
||||||
|
set({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -1,8 +1,91 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
||||||
|
navigateFallback: 'index.html',
|
||||||
|
navigateFallbackDenylist: [/^\/api/, /^\/uploads/],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
// Carto map tiles (default provider)
|
||||||
|
urlPattern: /^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'map-tiles',
|
||||||
|
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// OpenStreetMap tiles (fallback / alternative)
|
||||||
|
urlPattern: /^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'map-tiles',
|
||||||
|
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Leaflet CSS/JS from unpkg CDN
|
||||||
|
urlPattern: /^https:\/\/unpkg\.com\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'cdn-libs',
|
||||||
|
expiration: { maxEntries: 30, maxAgeSeconds: 365 * 24 * 60 * 60 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// API calls — prefer network, fall back to cache
|
||||||
|
urlPattern: /\/api\/.*/i,
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'api-data',
|
||||||
|
expiration: { maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 },
|
||||||
|
networkTimeoutSeconds: 5,
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Uploaded files (photos, covers, documents)
|
||||||
|
urlPattern: /\/uploads\/.*/i,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'user-uploads',
|
||||||
|
expiration: { maxEntries: 300, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
name: 'NOMAD \u2014 Travel Planner',
|
||||||
|
short_name: 'NOMAD',
|
||||||
|
description: 'Navigation Organizer for Maps, Activities & Destinations',
|
||||||
|
theme_color: '#111827',
|
||||||
|
background_color: '#0f172a',
|
||||||
|
display: 'standalone',
|
||||||
|
scope: '/',
|
||||||
|
start_url: '/',
|
||||||
|
orientation: 'any',
|
||||||
|
categories: ['travel', 'navigation'],
|
||||||
|
icons: [
|
||||||
|
{ src: 'icons/apple-touch-icon-180x180.png', sizes: '180x180', type: 'image/png' },
|
||||||
|
{ src: 'icons/icon-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: 'icons/icon-512x512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
{ src: 'icons/icon-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||||
|
{ src: 'icons/icon.svg', sizes: 'any', type: 'image/svg+xml' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- JWT_SECRET=${JWT_SECRET:-change-me-to-a-long-random-string}
|
- JWT_SECRET=${JWT_SECRET:-}
|
||||||
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
|
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "nomad-server",
|
"name": "nomad-server",
|
||||||
"version": "2.0.0",
|
"version": "2.5.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nomad-server",
|
"name": "nomad-server",
|
||||||
"version": "2.0.0",
|
"version": "2.5.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.1",
|
"dotenv": "^16.4.1",
|
||||||
"express": "^4.18.3",
|
"express": "^4.18.3",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
@@ -995,6 +996,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/helmet": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nomad-server",
|
"name": "nomad-server",
|
||||||
"version": "2.3.5",
|
"version": "2.5.2",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --experimental-sqlite src/index.js",
|
"start": "node --experimental-sqlite src/index.js",
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.1",
|
"dotenv": "^16.4.1",
|
||||||
"express": "^4.18.3",
|
"express": "^4.18.3",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
|||||||
@@ -211,6 +211,82 @@ function initDb() {
|
|||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Addon system
|
||||||
|
CREATE TABLE IF NOT EXISTS addons (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
type TEXT NOT NULL DEFAULT 'global',
|
||||||
|
icon TEXT DEFAULT 'Puzzle',
|
||||||
|
enabled INTEGER DEFAULT 0,
|
||||||
|
config TEXT DEFAULT '{}',
|
||||||
|
sort_order INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Vacay addon tables
|
||||||
|
CREATE TABLE IF NOT EXISTS vacay_plans (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
block_weekends INTEGER DEFAULT 1,
|
||||||
|
holidays_enabled INTEGER DEFAULT 0,
|
||||||
|
holidays_region TEXT DEFAULT '',
|
||||||
|
company_holidays_enabled INTEGER DEFAULT 1,
|
||||||
|
carry_over_enabled INTEGER DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(owner_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vacay_plan_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(plan_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vacay_user_colors (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
|
||||||
|
color TEXT DEFAULT '#6366f1',
|
||||||
|
UNIQUE(user_id, plan_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vacay_years (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
|
||||||
|
year INTEGER NOT NULL,
|
||||||
|
UNIQUE(plan_id, year)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vacay_user_years (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
|
||||||
|
year INTEGER NOT NULL,
|
||||||
|
vacation_days INTEGER DEFAULT 30,
|
||||||
|
carried_over INTEGER DEFAULT 0,
|
||||||
|
UNIQUE(user_id, plan_id, year)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vacay_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
note TEXT DEFAULT '',
|
||||||
|
UNIQUE(user_id, plan_id, date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vacay_company_holidays (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
note TEXT DEFAULT '',
|
||||||
|
UNIQUE(plan_id, date)
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Create indexes for performance
|
// Create indexes for performance
|
||||||
@@ -250,6 +326,9 @@ function initDb() {
|
|||||||
`ALTER TABLE trips ADD COLUMN is_archived INTEGER DEFAULT 0`,
|
`ALTER TABLE trips ADD COLUMN is_archived INTEGER DEFAULT 0`,
|
||||||
`ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL`,
|
`ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL`,
|
||||||
`ALTER TABLE users ADD COLUMN avatar TEXT`,
|
`ALTER TABLE users ADD COLUMN avatar TEXT`,
|
||||||
|
`ALTER TABLE users ADD COLUMN oidc_sub TEXT`,
|
||||||
|
`ALTER TABLE users ADD COLUMN oidc_issuer TEXT`,
|
||||||
|
`ALTER TABLE users ADD COLUMN last_login DATETIME`,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Recreate budget_items to allow NULL persons (SQLite can't ALTER NOT NULL)
|
// Recreate budget_items to allow NULL persons (SQLite can't ALTER NOT NULL)
|
||||||
@@ -304,6 +383,25 @@ function initDb() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error seeding categories:', err.message);
|
console.error('Error seeding categories:', err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed: default addons
|
||||||
|
try {
|
||||||
|
const existingAddons = _db.prepare('SELECT COUNT(*) as count FROM addons').get();
|
||||||
|
if (existingAddons.count === 0) {
|
||||||
|
const defaultAddons = [
|
||||||
|
{ id: 'packing', name: 'Packing List', description: 'Pack your bags with checklists per trip', type: 'trip', icon: 'ListChecks', sort_order: 0 },
|
||||||
|
{ id: 'budget', name: 'Budget Planner', description: 'Track expenses and plan your travel budget', type: 'trip', icon: 'Wallet', sort_order: 1 },
|
||||||
|
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', sort_order: 2 },
|
||||||
|
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', sort_order: 10 },
|
||||||
|
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', sort_order: 11 },
|
||||||
|
];
|
||||||
|
const insertAddon = _db.prepare('INSERT INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, 1, ?)');
|
||||||
|
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.sort_order);
|
||||||
|
console.log('Default addons seeded');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error seeding addons:', err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize on module load
|
// Initialize on module load
|
||||||
@@ -323,6 +421,7 @@ if (process.env.DEMO_MODE === 'true') {
|
|||||||
// without needing a server restart after reinitialize()
|
// without needing a server restart after reinitialize()
|
||||||
const db = new Proxy({}, {
|
const db = new Proxy({}, {
|
||||||
get(_, prop) {
|
get(_, prop) {
|
||||||
|
if (!_db) throw new Error('Database connection is not available (restore in progress?)');
|
||||||
const val = _db[prop];
|
const val = _db[prop];
|
||||||
return typeof val === 'function' ? val.bind(_db) : val;
|
return typeof val === 'function' ? val.bind(_db) : val;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
|
const helmet = require('helmet');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
@@ -42,16 +43,11 @@ app.use(cors({
|
|||||||
origin: corsOrigin,
|
origin: corsOrigin,
|
||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
app.use(express.json());
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: false, // managed by frontend meta tag or reverse proxy
|
||||||
// Security headers
|
crossOriginEmbedderPolicy: false, // allows loading external images (maps, etc.)
|
||||||
app.use((req, res, next) => {
|
}));
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
app.use(express.json({ limit: '100kb' }));
|
||||||
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
||||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
||||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
// Serve uploaded files
|
// Serve uploaded files
|
||||||
@@ -76,7 +72,9 @@ const settingsRoutes = require('./routes/settings');
|
|||||||
const budgetRoutes = require('./routes/budget');
|
const budgetRoutes = require('./routes/budget');
|
||||||
const backupRoutes = require('./routes/backup');
|
const backupRoutes = require('./routes/backup');
|
||||||
|
|
||||||
|
const oidcRoutes = require('./routes/oidc');
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
|
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/places', placesRoutes);
|
app.use('/api/trips/:tripId/places', placesRoutes);
|
||||||
@@ -89,6 +87,21 @@ app.use('/api', assignmentsRoutes);
|
|||||||
app.use('/api/tags', tagsRoutes);
|
app.use('/api/tags', tagsRoutes);
|
||||||
app.use('/api/categories', categoriesRoutes);
|
app.use('/api/categories', categoriesRoutes);
|
||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
|
|
||||||
|
// Public addons endpoint (authenticated but not admin-only)
|
||||||
|
const { authenticate: addonAuth } = require('./middleware/auth');
|
||||||
|
const { db: addonDb } = require('./db/database');
|
||||||
|
app.get('/api/addons', addonAuth, (req, res) => {
|
||||||
|
const addons = addonDb.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all();
|
||||||
|
res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled })) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Addon routes
|
||||||
|
const vacayRoutes = require('./routes/vacay');
|
||||||
|
app.use('/api/addons/vacay', vacayRoutes);
|
||||||
|
const atlasRoutes = require('./routes/atlas');
|
||||||
|
app.use('/api/addons/atlas', atlasRoutes);
|
||||||
|
|
||||||
app.use('/api/maps', mapsRoutes);
|
app.use('/api/maps', mapsRoutes);
|
||||||
app.use('/api/weather', weatherRoutes);
|
app.use('/api/weather', weatherRoutes);
|
||||||
app.use('/api/settings', settingsRoutes);
|
app.use('/api/settings', settingsRoutes);
|
||||||
|
|||||||
@@ -11,9 +11,16 @@ router.use(authenticate, adminOnly);
|
|||||||
// GET /api/admin/users
|
// GET /api/admin/users
|
||||||
router.get('/users', (req, res) => {
|
router.get('/users', (req, res) => {
|
||||||
const users = db.prepare(
|
const users = db.prepare(
|
||||||
'SELECT id, username, email, role, created_at, updated_at FROM users ORDER BY created_at DESC'
|
'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
|
||||||
).all();
|
).all();
|
||||||
res.json({ users });
|
// Add online status from WebSocket connections
|
||||||
|
let onlineUserIds = new Set();
|
||||||
|
try {
|
||||||
|
const { getOnlineUserIds } = require('../websocket');
|
||||||
|
onlineUserIds = getOnlineUserIds();
|
||||||
|
} catch { /* */ }
|
||||||
|
const usersWithStatus = users.map(u => ({ ...u, online: onlineUserIds.has(u.id) }));
|
||||||
|
res.json({ users: usersWithStatus });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/admin/users
|
// POST /api/admin/users
|
||||||
@@ -104,10 +111,31 @@ router.get('/stats', (req, res) => {
|
|||||||
const totalUsers = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
const totalUsers = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
||||||
const totalTrips = db.prepare('SELECT COUNT(*) as count FROM trips').get().count;
|
const totalTrips = db.prepare('SELECT COUNT(*) as count FROM trips').get().count;
|
||||||
const totalPlaces = db.prepare('SELECT COUNT(*) as count FROM places').get().count;
|
const totalPlaces = db.prepare('SELECT COUNT(*) as count FROM places').get().count;
|
||||||
const totalPhotos = db.prepare('SELECT COUNT(*) as count FROM photos').get().count;
|
|
||||||
const totalFiles = db.prepare('SELECT COUNT(*) as count FROM trip_files').get().count;
|
const totalFiles = db.prepare('SELECT COUNT(*) as count FROM trip_files').get().count;
|
||||||
|
|
||||||
res.json({ totalUsers, totalTrips, totalPlaces, totalPhotos, totalFiles });
|
res.json({ totalUsers, totalTrips, totalPlaces, totalFiles });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/admin/oidc — get OIDC config
|
||||||
|
router.get('/oidc', (req, res) => {
|
||||||
|
const get = (key) => db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key)?.value || '';
|
||||||
|
res.json({
|
||||||
|
issuer: get('oidc_issuer'),
|
||||||
|
client_id: get('oidc_client_id'),
|
||||||
|
client_secret: get('oidc_client_secret'),
|
||||||
|
display_name: get('oidc_display_name'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/admin/oidc — update OIDC config
|
||||||
|
router.put('/oidc', (req, res) => {
|
||||||
|
const { issuer, client_id, client_secret, display_name } = req.body;
|
||||||
|
const set = (key, val) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || '');
|
||||||
|
set('oidc_issuer', issuer);
|
||||||
|
set('oidc_client_id', client_id);
|
||||||
|
set('oidc_client_secret', client_secret);
|
||||||
|
set('oidc_display_name', display_name);
|
||||||
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/admin/save-demo-baseline (demo mode only)
|
// POST /api/admin/save-demo-baseline (demo mode only)
|
||||||
@@ -124,4 +152,21 @@ router.post('/save-demo-baseline', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Addons ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/addons', (req, res) => {
|
||||||
|
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all();
|
||||||
|
res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/addons/:id', (req, res) => {
|
||||||
|
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id);
|
||||||
|
if (!addon) return res.status(404).json({ error: 'Addon not found' });
|
||||||
|
const { enabled, config } = req.body;
|
||||||
|
if (enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id);
|
||||||
|
if (config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id);
|
||||||
|
const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id);
|
||||||
|
res.json({ addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') } });
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { db } = require('../db/database');
|
||||||
|
const { authenticate } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// Country code lookup from coordinates (bounding box approach)
|
||||||
|
// Covers most countries — not pixel-perfect but good enough for visited-country tracking
|
||||||
|
const COUNTRY_BOXES = {
|
||||||
|
AF:[60.5,29.4,75,38.5],AL:[19,39.6,21.1,42.7],DZ:[-8.7,19,12,37.1],AD:[1.4,42.4,1.8,42.7],AO:[11.7,-18.1,24.1,-4.4],
|
||||||
|
AR:[-73.6,-55.1,-53.6,-21.8],AM:[43.4,38.8,46.6,41.3],AU:[112.9,-43.6,153.6,-10.7],AT:[9.5,46.4,17.2,49],AZ:[44.8,38.4,50.4,41.9],
|
||||||
|
BR:[-73.9,-33.8,-34.8,5.3],BE:[2.5,49.5,6.4,51.5],BG:[22.4,41.2,28.6,44.2],CA:[-141,41.7,-52.6,83.1],CL:[-75.6,-55.9,-66.9,-17.5],
|
||||||
|
CN:[73.6,18.2,134.8,53.6],CO:[-79.1,-4.3,-66.9,12.5],HR:[13.5,42.4,19.5,46.6],CZ:[12.1,48.6,18.9,51.1],DK:[8,54.6,15.2,57.8],
|
||||||
|
EG:[24.7,22,37,31.7],EE:[21.8,57.5,28.2,59.7],FI:[20.6,59.8,31.6,70.1],FR:[-5.1,41.3,9.6,51.1],DE:[5.9,47.3,15.1,55.1],
|
||||||
|
GR:[19.4,34.8,29.7,41.8],HU:[16,45.7,22.9,48.6],IS:[-24.5,63.4,-13.5,66.6],IN:[68.2,6.7,97.4,35.5],ID:[95.3,-11,141,5.9],
|
||||||
|
IR:[44.1,25.1,63.3,39.8],IQ:[38.8,29.1,48.6,37.4],IE:[-10.5,51.4,-6,55.4],IL:[34.3,29.5,35.9,33.3],IT:[6.6,36.6,18.5,47.1],
|
||||||
|
JP:[129.4,31.1,145.5,45.5],KE:[33.9,-4.7,41.9,5.5],KR:[126,33.2,129.6,38.6],LV:[21,55.7,28.2,58.1],LT:[21,53.9,26.8,56.5],
|
||||||
|
LU:[5.7,49.4,6.5,50.2],MY:[99.6,0.9,119.3,7.4],MX:[-118.4,14.5,-86.7,32.7],MA:[-13.2,27.7,-1,35.9],NL:[3.4,50.8,7.2,53.5],
|
||||||
|
NZ:[166.4,-47.3,178.5,-34.4],NO:[4.6,58,31.1,71.2],PK:[60.9,23.7,77.1,37.1],PE:[-81.3,-18.4,-68.7,-0.1],PH:[117,5,126.6,18.5],
|
||||||
|
PL:[14.1,49,24.1,54.9],PT:[-9.5,36.8,-6.2,42.2],RO:[20.2,43.6,29.7,48.3],RU:[19.6,41.2,180,81.9],SA:[34.6,16.4,55.7,32.2],
|
||||||
|
RS:[18.8,42.2,23,46.2],SK:[16.8,47.7,22.6,49.6],SI:[13.4,45.4,16.6,46.9],ZA:[16.5,-34.8,32.9,-22.1],ES:[-9.4,36,-0.2,43.8],
|
||||||
|
SE:[11.1,55.3,24.2,69.1],CH:[6,45.8,10.5,47.8],TH:[97.3,5.6,105.6,20.5],TR:[26,36,44.8,42.1],UA:[22.1,44.4,40.2,52.4],
|
||||||
|
AE:[51.6,22.6,56.4,26.1],GB:[-8,49.9,2,60.9],US:[-125,24.5,-66.9,49.4],VN:[102.1,8.6,109.5,23.4],
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCountryFromCoords(lat, lng) {
|
||||||
|
for (const [code, [minLng, minLat, maxLng, maxLat]] of Object.entries(COUNTRY_BOXES)) {
|
||||||
|
if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCountryFromAddress(address) {
|
||||||
|
if (!address) return null;
|
||||||
|
// Take last segment after comma, trim
|
||||||
|
const parts = address.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
const last = parts[parts.length - 1];
|
||||||
|
// Try to match known country names to codes
|
||||||
|
const NAME_TO_CODE = {
|
||||||
|
'germany':'DE','deutschland':'DE','france':'FR','frankreich':'FR','spain':'ES','spanien':'ES',
|
||||||
|
'italy':'IT','italien':'IT','united kingdom':'GB','uk':'GB','england':'GB','united states':'US',
|
||||||
|
'usa':'US','netherlands':'NL','niederlande':'NL','austria':'AT','österreich':'AT','switzerland':'CH',
|
||||||
|
'schweiz':'CH','portugal':'PT','greece':'GR','griechenland':'GR','turkey':'TR','türkei':'TR',
|
||||||
|
'croatia':'HR','kroatien':'HR','czech republic':'CZ','tschechien':'CZ','czechia':'CZ',
|
||||||
|
'poland':'PL','polen':'PL','sweden':'SE','schweden':'SE','norway':'NO','norwegen':'NO',
|
||||||
|
'denmark':'DK','dänemark':'DK','finland':'FI','finnland':'FI','belgium':'BE','belgien':'BE',
|
||||||
|
'ireland':'IE','irland':'IE','hungary':'HU','ungarn':'HU','romania':'RO','rumänien':'RO',
|
||||||
|
'bulgaria':'BG','bulgarien':'BG','japan':'JP','china':'CN','australia':'AU','australien':'AU',
|
||||||
|
'canada':'CA','kanada':'CA','mexico':'MX','mexiko':'MX','brazil':'BR','brasilien':'BR',
|
||||||
|
'argentina':'AR','argentinien':'AR','thailand':'TH','indonesia':'ID','indonesien':'ID',
|
||||||
|
'india':'IN','indien':'IN','egypt':'EG','ägypten':'EG','morocco':'MA','marokko':'MA',
|
||||||
|
'south africa':'ZA','südafrika':'ZA','new zealand':'NZ','neuseeland':'NZ','iceland':'IS','island':'IS',
|
||||||
|
'luxembourg':'LU','luxemburg':'LU','slovenia':'SI','slowenien':'SI','slovakia':'SK','slowakei':'SK',
|
||||||
|
'estonia':'EE','estland':'EE','latvia':'LV','lettland':'LV','lithuania':'LT','litauen':'LT',
|
||||||
|
'serbia':'RS','serbien':'RS','israel':'IL','russia':'RU','russland':'RU','ukraine':'UA',
|
||||||
|
'vietnam':'VN','south korea':'KR','südkorea':'KR','philippines':'PH','philippinen':'PH',
|
||||||
|
'malaysia':'MY','colombia':'CO','kolumbien':'CO','peru':'PE','chile':'CL','iran':'IR',
|
||||||
|
'iraq':'IQ','irak':'IQ','pakistan':'PK','kenya':'KE','kenia':'KE','nigeria':'NG',
|
||||||
|
'saudi arabia':'SA','saudi-arabien':'SA','albania':'AL','albanien':'AL',
|
||||||
|
'日本':'JP','中国':'CN','한국':'KR','대한민국':'KR','ไทย':'TH',
|
||||||
|
};
|
||||||
|
const normalized = last.toLowerCase();
|
||||||
|
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
|
||||||
|
if (last.length === 2 && last === last.toUpperCase()) return last;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/addons/atlas/stats
|
||||||
|
router.get('/stats', (req, res) => {
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
// Get all trips (own + shared)
|
||||||
|
const trips = db.prepare(`
|
||||||
|
SELECT DISTINCT t.* FROM trips t
|
||||||
|
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
||||||
|
WHERE t.user_id = ? OR m.user_id = ?
|
||||||
|
ORDER BY t.start_date DESC
|
||||||
|
`).all(userId, userId, userId);
|
||||||
|
|
||||||
|
// Get all places from those trips
|
||||||
|
const tripIds = trips.map(t => t.id);
|
||||||
|
if (tripIds.length === 0) {
|
||||||
|
return res.json({ countries: [], trips: [], stats: { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholders = tripIds.map(() => '?').join(',');
|
||||||
|
const places = db.prepare(`SELECT * FROM places WHERE trip_id IN (${placeholders})`).all(...tripIds);
|
||||||
|
|
||||||
|
// Extract countries
|
||||||
|
const countrySet = new Map(); // code -> { code, places: [], trips: Set }
|
||||||
|
for (const place of places) {
|
||||||
|
let code = getCountryFromAddress(place.address);
|
||||||
|
if (!code && place.lat && place.lng) {
|
||||||
|
code = getCountryFromCoords(place.lat, place.lng);
|
||||||
|
}
|
||||||
|
if (code) {
|
||||||
|
if (!countrySet.has(code)) {
|
||||||
|
countrySet.set(code, { code, places: [], tripIds: new Set() });
|
||||||
|
}
|
||||||
|
countrySet.get(code).places.push({ id: place.id, name: place.name, lat: place.lat, lng: place.lng });
|
||||||
|
countrySet.get(code).tripIds.add(place.trip_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total days across all trips
|
||||||
|
let totalDays = 0;
|
||||||
|
for (const trip of trips) {
|
||||||
|
if (trip.start_date && trip.end_date) {
|
||||||
|
const start = new Date(trip.start_date);
|
||||||
|
const end = new Date(trip.end_date);
|
||||||
|
const diff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1;
|
||||||
|
if (diff > 0) totalDays += diff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const countries = [...countrySet.values()].map(c => {
|
||||||
|
const countryTrips = trips.filter(t => c.tripIds.has(t.id));
|
||||||
|
const dates = countryTrips.map(t => t.start_date).filter(Boolean).sort();
|
||||||
|
return {
|
||||||
|
code: c.code,
|
||||||
|
placeCount: c.places.length,
|
||||||
|
tripCount: c.tripIds.size,
|
||||||
|
firstVisit: dates[0] || null,
|
||||||
|
lastVisit: dates[dates.length - 1] || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
for (const place of places) {
|
||||||
|
if (place.address) {
|
||||||
|
const parts = place.address.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
let raw = parts.length >= 2 ? parts[parts.length - 2] : parts[0];
|
||||||
|
if (raw) {
|
||||||
|
const city = raw.replace(/[\d\-−〒]+/g, '').trim().toLowerCase();
|
||||||
|
if (city) citySet.add(city);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const totalCities = citySet.size;
|
||||||
|
|
||||||
|
// Most visited country
|
||||||
|
const mostVisited = countries.length > 0 ? countries.reduce((a, b) => a.placeCount > b.placeCount ? a : b) : null;
|
||||||
|
|
||||||
|
// Continent breakdown
|
||||||
|
const CONTINENT_MAP = {
|
||||||
|
AF:'Africa',AL:'Europe',DZ:'Africa',AD:'Europe',AO:'Africa',AR:'South America',AM:'Asia',AU:'Oceania',AT:'Europe',AZ:'Asia',
|
||||||
|
BR:'South America',BE:'Europe',BG:'Europe',CA:'North America',CL:'South America',CN:'Asia',CO:'South America',HR:'Europe',CZ:'Europe',DK:'Europe',
|
||||||
|
EG:'Africa',EE:'Europe',FI:'Europe',FR:'Europe',DE:'Europe',GR:'Europe',HU:'Europe',IS:'Europe',IN:'Asia',ID:'Asia',
|
||||||
|
IR:'Asia',IQ:'Asia',IE:'Europe',IL:'Asia',IT:'Europe',JP:'Asia',KE:'Africa',KR:'Asia',LV:'Europe',LT:'Europe',
|
||||||
|
LU:'Europe',MY:'Asia',MX:'North America',MA:'Africa',NL:'Europe',NZ:'Oceania',NO:'Europe',PK:'Asia',PE:'South America',PH:'Asia',
|
||||||
|
PL:'Europe',PT:'Europe',RO:'Europe',RU:'Europe',SA:'Asia',RS:'Europe',SK:'Europe',SI:'Europe',ZA:'Africa',ES:'Europe',
|
||||||
|
SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',NG:'Africa',
|
||||||
|
};
|
||||||
|
const continents = {};
|
||||||
|
countries.forEach(c => {
|
||||||
|
const cont = CONTINENT_MAP[c.code] || 'Other';
|
||||||
|
continents[cont] = (continents[cont] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Last trip (most recent past trip)
|
||||||
|
const now = new Date().toISOString().split('T')[0];
|
||||||
|
const pastTrips = trips.filter(t => t.end_date && t.end_date <= now).sort((a, b) => b.end_date.localeCompare(a.end_date));
|
||||||
|
const lastTrip = pastTrips[0] ? { id: pastTrips[0].id, title: pastTrips[0].title, start_date: pastTrips[0].start_date, end_date: pastTrips[0].end_date } : null;
|
||||||
|
// Find country for last trip
|
||||||
|
if (lastTrip) {
|
||||||
|
const lastTripPlaces = places.filter(p => p.trip_id === lastTrip.id);
|
||||||
|
for (const p of lastTripPlaces) {
|
||||||
|
let code = getCountryFromAddress(p.address);
|
||||||
|
if (!code && p.lat && p.lng) code = getCountryFromCoords(p.lat, p.lng);
|
||||||
|
if (code) { lastTrip.countryCode = code; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next trip (earliest future trip)
|
||||||
|
const futureTrips = trips.filter(t => t.start_date && t.start_date > now).sort((a, b) => a.start_date.localeCompare(b.start_date));
|
||||||
|
const nextTrip = futureTrips[0] ? { id: futureTrips[0].id, title: futureTrips[0].title, start_date: futureTrips[0].start_date } : null;
|
||||||
|
if (nextTrip) {
|
||||||
|
const diff = Math.ceil((new Date(nextTrip.start_date) - new Date()) / (1000 * 60 * 60 * 24));
|
||||||
|
nextTrip.daysUntil = Math.max(0, diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Travel streak (consecutive years with at least one trip)
|
||||||
|
const tripYears = new Set(trips.filter(t => t.start_date).map(t => parseInt(t.start_date.split('-')[0])));
|
||||||
|
let streak = 0;
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
for (let y = currentYear; y >= 2000; y--) {
|
||||||
|
if (tripYears.has(y)) streak++;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
const firstYear = tripYears.size > 0 ? Math.min(...tripYears) : null;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
countries,
|
||||||
|
stats: {
|
||||||
|
totalTrips: trips.length,
|
||||||
|
totalPlaces: places.length,
|
||||||
|
totalCountries: countries.length,
|
||||||
|
totalDays,
|
||||||
|
totalCities,
|
||||||
|
},
|
||||||
|
mostVisited,
|
||||||
|
continents,
|
||||||
|
lastTrip,
|
||||||
|
nextTrip,
|
||||||
|
streak,
|
||||||
|
firstYear,
|
||||||
|
tripsThisYear: trips.filter(t => t.start_date && t.start_date.startsWith(String(currentYear))).length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/addons/atlas/country/:code — details for a country
|
||||||
|
router.get('/country/:code', (req, res) => {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const code = req.params.code.toUpperCase();
|
||||||
|
|
||||||
|
const trips = db.prepare(`
|
||||||
|
SELECT DISTINCT t.* FROM trips t
|
||||||
|
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
||||||
|
WHERE t.user_id = ? OR m.user_id = ?
|
||||||
|
`).all(userId, userId, userId);
|
||||||
|
|
||||||
|
const tripIds = trips.map(t => t.id);
|
||||||
|
if (tripIds.length === 0) return res.json({ places: [], trips: [] });
|
||||||
|
|
||||||
|
const placeholders = tripIds.map(() => '?').join(',');
|
||||||
|
const places = db.prepare(`SELECT * FROM places WHERE trip_id IN (${placeholders})`).all(...tripIds);
|
||||||
|
|
||||||
|
const matchingPlaces = [];
|
||||||
|
const matchingTripIds = new Set();
|
||||||
|
|
||||||
|
for (const place of places) {
|
||||||
|
let pCode = getCountryFromAddress(place.address);
|
||||||
|
if (!pCode && place.lat && place.lng) pCode = getCountryFromCoords(place.lat, place.lng);
|
||||||
|
if (pCode === code) {
|
||||||
|
matchingPlaces.push({ id: place.id, name: place.name, address: place.address, lat: place.lat, lng: place.lng, trip_id: place.trip_id });
|
||||||
|
matchingTripIds.add(place.trip_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingTrips = trips.filter(t => matchingTripIds.has(t.id)).map(t => ({ id: t.id, title: t.title, start_date: t.start_date, end_date: t.end_date }));
|
||||||
|
|
||||||
|
res.json({ places: matchingPlaces, trips: matchingTrips });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -67,10 +67,19 @@ router.get('/app-config', (req, res) => {
|
|||||||
const allowRegistration = userCount === 0 || (setting?.value ?? 'true') === 'true';
|
const allowRegistration = userCount === 0 || (setting?.value ?? 'true') === 'true';
|
||||||
const isDemo = process.env.DEMO_MODE === 'true';
|
const isDemo = process.env.DEMO_MODE === 'true';
|
||||||
const { version } = require('../../package.json');
|
const { version } = require('../../package.json');
|
||||||
|
const hasGoogleKey = !!db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get();
|
||||||
|
const oidcDisplayName = db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_display_name'").get()?.value || null;
|
||||||
|
const oidcConfigured = !!(
|
||||||
|
db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get()?.value &&
|
||||||
|
db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get()?.value
|
||||||
|
);
|
||||||
res.json({
|
res.json({
|
||||||
allow_registration: isDemo ? false : allowRegistration,
|
allow_registration: isDemo ? false : allowRegistration,
|
||||||
has_users: userCount > 0,
|
has_users: userCount > 0,
|
||||||
version,
|
version,
|
||||||
|
has_maps_key: hasGoogleKey,
|
||||||
|
oidc_configured: oidcConfigured,
|
||||||
|
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
|
||||||
demo_mode: isDemo,
|
demo_mode: isDemo,
|
||||||
demo_email: isDemo ? 'demo@nomad.app' : undefined,
|
demo_email: isDemo ? 'demo@nomad.app' : undefined,
|
||||||
demo_password: isDemo ? 'demo12345' : undefined,
|
demo_password: isDemo ? 'demo12345' : undefined,
|
||||||
@@ -158,6 +167,7 @@ router.post('/login', authLimiter, (req, res) => {
|
|||||||
return res.status(401).json({ error: 'Ungültige E-Mail oder Passwort' });
|
return res.status(401).json({ error: 'Ungültige E-Mail oder Passwort' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
|
||||||
const token = generateToken(user);
|
const token = generateToken(user);
|
||||||
const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...userWithoutSensitive } = user;
|
const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...userWithoutSensitive } = user;
|
||||||
|
|
||||||
@@ -167,7 +177,7 @@ router.post('/login', authLimiter, (req, res) => {
|
|||||||
// GET /api/auth/me
|
// GET /api/auth/me
|
||||||
router.get('/me', authenticate, (req, res) => {
|
router.get('/me', authenticate, (req, res) => {
|
||||||
const user = db.prepare(
|
const user = db.prepare(
|
||||||
'SELECT id, username, email, role, avatar, created_at FROM users WHERE id = ?'
|
'SELECT id, username, email, role, avatar, oidc_issuer, created_at FROM users WHERE id = ?'
|
||||||
).get(req.user.id);
|
).get(req.user.id);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -177,6 +187,37 @@ router.get('/me', authenticate, (req, res) => {
|
|||||||
res.json({ user: { ...user, avatar_url: avatarUrl(user) } });
|
res.json({ user: { ...user, avatar_url: avatarUrl(user) } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PUT /api/auth/me/password
|
||||||
|
router.put('/me/password', authenticate, (req, res) => {
|
||||||
|
if (process.env.DEMO_MODE === 'true' && req.user.email === 'demo@nomad.app') {
|
||||||
|
return res.status(403).json({ error: 'Password change is disabled in demo mode.' });
|
||||||
|
}
|
||||||
|
const { new_password } = req.body;
|
||||||
|
if (!new_password) return res.status(400).json({ error: 'New password is required' });
|
||||||
|
if (new_password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||||
|
|
||||||
|
const hash = bcrypt.hashSync(new_password, 10);
|
||||||
|
db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, req.user.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/auth/me — delete own account
|
||||||
|
router.delete('/me', authenticate, (req, res) => {
|
||||||
|
// Block demo user
|
||||||
|
if (process.env.DEMO_MODE === 'true' && req.user.email === 'demo@nomad.app') {
|
||||||
|
return res.status(403).json({ error: 'Account deletion is disabled in demo mode.' });
|
||||||
|
}
|
||||||
|
// Prevent deleting last admin
|
||||||
|
if (req.user.role === 'admin') {
|
||||||
|
const adminCount = db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get().count;
|
||||||
|
if (adminCount <= 1) {
|
||||||
|
return res.status(400).json({ error: 'Cannot delete the last admin account' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.prepare('DELETE FROM users WHERE id = ?').run(req.user.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
// PUT /api/auth/me/maps-key
|
// PUT /api/auth/me/maps-key
|
||||||
router.put('/me/maps-key', authenticate, (req, res) => {
|
router.put('/me/maps-key', authenticate, (req, res) => {
|
||||||
const { maps_api_key } = req.body;
|
const { maps_api_key } = req.body;
|
||||||
|
|||||||
@@ -138,25 +138,37 @@ async function restoreFromZip(zipPath, res) {
|
|||||||
// Step 1: close DB connection BEFORE touching the file (required on Windows)
|
// Step 1: close DB connection BEFORE touching the file (required on Windows)
|
||||||
closeDb();
|
closeDb();
|
||||||
|
|
||||||
// Step 2: remove WAL/SHM and overwrite DB file
|
try {
|
||||||
const dbDest = path.join(dataDir, 'travel.db');
|
// Step 2: remove WAL/SHM and overwrite DB file
|
||||||
for (const ext of ['', '-wal', '-shm']) {
|
const dbDest = path.join(dataDir, 'travel.db');
|
||||||
try { fs.unlinkSync(dbDest + ext); } catch (e) {}
|
for (const ext of ['', '-wal', '-shm']) {
|
||||||
}
|
try { fs.unlinkSync(dbDest + ext); } catch (e) {}
|
||||||
fs.copyFileSync(extractedDb, dbDest);
|
}
|
||||||
|
fs.copyFileSync(extractedDb, dbDest);
|
||||||
|
|
||||||
// Step 3: restore uploads
|
// Step 3: restore uploads — overwrite in-place instead of rmSync
|
||||||
const extractedUploads = path.join(extractDir, 'uploads');
|
// (rmSync fails with EBUSY because express.static holds the directory)
|
||||||
if (fs.existsSync(extractedUploads)) {
|
const extractedUploads = path.join(extractDir, 'uploads');
|
||||||
if (fs.existsSync(uploadsDir)) fs.rmSync(uploadsDir, { recursive: true, force: true });
|
if (fs.existsSync(extractedUploads)) {
|
||||||
fs.cpSync(extractedUploads, uploadsDir, { recursive: true });
|
// Clear contents of each subdirectory without removing the root uploads dir
|
||||||
|
for (const sub of fs.readdirSync(uploadsDir)) {
|
||||||
|
const subPath = path.join(uploadsDir, sub);
|
||||||
|
if (fs.statSync(subPath).isDirectory()) {
|
||||||
|
for (const file of fs.readdirSync(subPath)) {
|
||||||
|
try { fs.unlinkSync(path.join(subPath, file)); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Copy restored files over
|
||||||
|
fs.cpSync(extractedUploads, uploadsDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Step 4: ALWAYS reopen DB — even if file copy failed, so the server stays functional
|
||||||
|
reinitialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||||
|
|
||||||
// Step 4: reopen DB with restored data
|
|
||||||
reinitialize();
|
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Restore error:', err);
|
console.error('Restore error:', err);
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ const upload = multer({
|
|||||||
'text/plain',
|
'text/plain',
|
||||||
'text/csv',
|
'text/csv',
|
||||||
];
|
];
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
const blockedExts = ['.svg', '.html', '.htm', '.xml'];
|
||||||
|
if (blockedExts.includes(ext) || file.mimetype.includes('svg')) {
|
||||||
|
return cb(new Error('File type not allowed'));
|
||||||
|
}
|
||||||
if (allowed.includes(file.mimetype) || file.mimetype.startsWith('image/')) {
|
if (allowed.includes(file.mimetype) || file.mimetype.startsWith('image/')) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -17,6 +17,34 @@ function getMapsKey(userId) {
|
|||||||
const photoCache = new Map();
|
const photoCache = new Map();
|
||||||
const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours
|
const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours
|
||||||
|
|
||||||
|
// Nominatim search (OpenStreetMap) — free fallback when no Google API key
|
||||||
|
async function searchNominatim(query, lang) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
q: query,
|
||||||
|
format: 'json',
|
||||||
|
addressdetails: '1',
|
||||||
|
limit: '10',
|
||||||
|
'accept-language': lang || 'en',
|
||||||
|
});
|
||||||
|
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
|
||||||
|
headers: { 'User-Agent': 'NOMAD Travel Planner (https://github.com/mauriceboe/NOMAD)' },
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Nominatim API error');
|
||||||
|
const data = await response.json();
|
||||||
|
return data.map(item => ({
|
||||||
|
google_place_id: null,
|
||||||
|
osm_id: `${item.osm_type}/${item.osm_id}`,
|
||||||
|
name: item.name || item.display_name?.split(',')[0] || '',
|
||||||
|
address: item.display_name || '',
|
||||||
|
lat: parseFloat(item.lat) || null,
|
||||||
|
lng: parseFloat(item.lon) || null,
|
||||||
|
rating: null,
|
||||||
|
website: null,
|
||||||
|
phone: null,
|
||||||
|
source: 'openstreetmap',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/maps/search
|
// POST /api/maps/search
|
||||||
router.post('/search', authenticate, async (req, res) => {
|
router.post('/search', authenticate, async (req, res) => {
|
||||||
const { query } = req.body;
|
const { query } = req.body;
|
||||||
@@ -24,8 +52,16 @@ router.post('/search', authenticate, async (req, res) => {
|
|||||||
if (!query) return res.status(400).json({ error: 'Suchanfrage ist erforderlich' });
|
if (!query) return res.status(400).json({ error: 'Suchanfrage ist erforderlich' });
|
||||||
|
|
||||||
const apiKey = getMapsKey(req.user.id);
|
const apiKey = getMapsKey(req.user.id);
|
||||||
|
|
||||||
|
// No Google API key → use Nominatim (OpenStreetMap)
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return res.status(400).json({ error: 'Google Maps API-Schlüssel nicht konfiguriert. Bitte in den Einstellungen hinzufügen.' });
|
try {
|
||||||
|
const places = await searchNominatim(query, req.query.lang);
|
||||||
|
return res.json({ places, source: 'openstreetmap' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Nominatim search error:', err);
|
||||||
|
return res.status(500).json({ error: 'Fehler bei der OpenStreetMap Suche' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -54,9 +90,10 @@ router.post('/search', authenticate, async (req, res) => {
|
|||||||
rating: p.rating || null,
|
rating: p.rating || null,
|
||||||
website: p.websiteUri || null,
|
website: p.websiteUri || null,
|
||||||
phone: p.nationalPhoneNumber || null,
|
phone: p.nationalPhoneNumber || null,
|
||||||
|
source: 'google',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
res.json({ places });
|
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: 'Fehler bei der Google Places Suche' });
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fetch = require('node-fetch');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { db } = require('../db/database');
|
||||||
|
const { JWT_SECRET } = require('../config');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// In-memory state store for CSRF protection (state → { createdAt, redirectUri })
|
||||||
|
const pendingStates = new Map();
|
||||||
|
const STATE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
// Cleanup expired states periodically
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [state, data] of pendingStates) {
|
||||||
|
if (now - data.createdAt > STATE_TTL) pendingStates.delete(state);
|
||||||
|
}
|
||||||
|
}, 60 * 1000);
|
||||||
|
|
||||||
|
// Read OIDC config from app_settings
|
||||||
|
function getOidcConfig() {
|
||||||
|
const get = (key) => db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key)?.value || null;
|
||||||
|
const issuer = get('oidc_issuer');
|
||||||
|
const clientId = get('oidc_client_id');
|
||||||
|
const clientSecret = get('oidc_client_secret');
|
||||||
|
const displayName = get('oidc_display_name') || 'SSO';
|
||||||
|
if (!issuer || !clientId || !clientSecret) return null;
|
||||||
|
return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache discovery document
|
||||||
|
let discoveryCache = null;
|
||||||
|
let discoveryCacheTime = 0;
|
||||||
|
const DISCOVERY_TTL = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
|
async function discover(issuer) {
|
||||||
|
if (discoveryCache && Date.now() - discoveryCacheTime < DISCOVERY_TTL && discoveryCache._issuer === issuer) {
|
||||||
|
return discoveryCache;
|
||||||
|
}
|
||||||
|
const res = await fetch(`${issuer}/.well-known/openid-configuration`);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch OIDC discovery document');
|
||||||
|
const doc = await res.json();
|
||||||
|
doc._issuer = issuer;
|
||||||
|
discoveryCache = doc;
|
||||||
|
discoveryCacheTime = Date.now();
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateToken(user) {
|
||||||
|
return jwt.sign(
|
||||||
|
{ id: user.id, username: user.username, email: user.email, role: user.role },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '24h' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function frontendUrl(path) {
|
||||||
|
const base = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:5173';
|
||||||
|
return base + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/auth/oidc/login — redirect to OIDC provider
|
||||||
|
router.get('/login', async (req, res) => {
|
||||||
|
const config = getOidcConfig();
|
||||||
|
if (!config) return res.status(400).json({ error: 'OIDC not configured' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doc = await discover(config.issuer);
|
||||||
|
const state = crypto.randomBytes(32).toString('hex');
|
||||||
|
const proto = req.headers['x-forwarded-proto'] || req.protocol;
|
||||||
|
const host = req.headers['x-forwarded-host'] || req.headers.host;
|
||||||
|
const redirectUri = `${proto}://${host}/api/auth/oidc/callback`;
|
||||||
|
|
||||||
|
pendingStates.set(state, { createdAt: Date.now(), redirectUri });
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: config.clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope: 'openid email profile',
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.redirect(`${doc.authorization_endpoint}?${params}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[OIDC] Login error:', err.message);
|
||||||
|
res.status(500).json({ error: 'OIDC login failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/oidc/callback — handle provider callback
|
||||||
|
router.get('/callback', async (req, res) => {
|
||||||
|
const { code, state, error: oidcError } = req.query;
|
||||||
|
|
||||||
|
if (oidcError) {
|
||||||
|
console.error('[OIDC] Provider error:', oidcError);
|
||||||
|
return res.redirect(frontendUrl('/login?oidc_error=' + encodeURIComponent(oidcError)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
return res.redirect(frontendUrl('/login?oidc_error=missing_params'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = pendingStates.get(state);
|
||||||
|
if (!pending) {
|
||||||
|
return res.redirect(frontendUrl('/login?oidc_error=invalid_state'));
|
||||||
|
}
|
||||||
|
pendingStates.delete(state);
|
||||||
|
|
||||||
|
const config = getOidcConfig();
|
||||||
|
if (!config) return res.redirect(frontendUrl('/login?oidc_error=not_configured'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doc = await discover(config.issuer);
|
||||||
|
|
||||||
|
// Exchange code for tokens
|
||||||
|
const tokenRes = await fetch(doc.token_endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: pending.redirectUri,
|
||||||
|
client_id: config.clientId,
|
||||||
|
client_secret: config.clientSecret,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenData = await tokenRes.json();
|
||||||
|
if (!tokenRes.ok || !tokenData.access_token) {
|
||||||
|
console.error('[OIDC] Token exchange failed:', tokenData);
|
||||||
|
return res.redirect(frontendUrl('/login?oidc_error=token_failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user info
|
||||||
|
const userInfoRes = await fetch(doc.userinfo_endpoint, {
|
||||||
|
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
||||||
|
});
|
||||||
|
const userInfo = await userInfoRes.json();
|
||||||
|
|
||||||
|
if (!userInfo.email) {
|
||||||
|
return res.redirect(frontendUrl('/login?oidc_error=no_email'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = userInfo.email.toLowerCase();
|
||||||
|
const name = userInfo.name || userInfo.preferred_username || email.split('@')[0];
|
||||||
|
const sub = userInfo.sub;
|
||||||
|
|
||||||
|
// Find existing user by OIDC sub or email
|
||||||
|
let user = db.prepare('SELECT * FROM users WHERE oidc_sub = ? AND oidc_issuer = ?').get(sub, config.issuer);
|
||||||
|
if (!user) {
|
||||||
|
user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Existing user — link OIDC if not already linked
|
||||||
|
if (!user.oidc_sub) {
|
||||||
|
db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New user — check if registration is allowed
|
||||||
|
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
||||||
|
const isFirstUser = userCount === 0;
|
||||||
|
|
||||||
|
if (!isFirstUser) {
|
||||||
|
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get();
|
||||||
|
if (setting?.value === 'false') {
|
||||||
|
return res.redirect(frontendUrl('/login?oidc_error=registration_disabled'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user (first user = admin)
|
||||||
|
const role = isFirstUser ? 'admin' : 'user';
|
||||||
|
// Generate a random password hash (user won't use password login)
|
||||||
|
const randomPass = crypto.randomBytes(32).toString('hex');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const hash = bcrypt.hashSync(randomPass, 10);
|
||||||
|
|
||||||
|
// Ensure unique username
|
||||||
|
let username = name.replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30) || 'user';
|
||||||
|
const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
|
||||||
|
if (existing) username = `${username}_${Date.now() % 10000}`;
|
||||||
|
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer) VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
|
).run(username, email, hash, role, sub, config.issuer);
|
||||||
|
|
||||||
|
user = { id: Number(result.lastInsertRowid), username, email, role };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
|
||||||
|
|
||||||
|
// Generate JWT and redirect to frontend
|
||||||
|
const token = generateToken(user);
|
||||||
|
// In dev mode, frontend runs on a different port
|
||||||
|
res.redirect(frontendUrl(`/login#token=${token}`));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[OIDC] Callback error:', err);
|
||||||
|
res.redirect(frontendUrl('/login?oidc_error=server_error'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -25,10 +25,12 @@ const upload = multer({
|
|||||||
storage,
|
storage,
|
||||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
if (file.mimetype.startsWith('image/')) {
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||||
|
if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowedExts.includes(ext)) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(new Error('Nur Bilddateien sind erlaubt'));
|
cb(new Error('Only jpg, png, gif, webp images allowed'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,8 +24,13 @@ const uploadCover = multer({
|
|||||||
storage: coverStorage,
|
storage: coverStorage,
|
||||||
limits: { fileSize: 20 * 1024 * 1024 },
|
limits: { fileSize: 20 * 1024 * 1024 },
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
if (file.mimetype.startsWith('image/')) cb(null, true);
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
else cb(new Error('Nur Bilder erlaubt'));
|
const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||||
|
if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowedExts.includes(ext)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only jpg, png, gif, webp images allowed'));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,582 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { db } = require('../db/database');
|
||||||
|
const { authenticate } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// In-memory cache for holiday API results (key: "year-country", ttl: 24h)
|
||||||
|
const holidayCache = new Map();
|
||||||
|
const CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// Broadcast vacay updates to all users in the same plan
|
||||||
|
function notifyPlanUsers(planId, excludeUserId, event = 'vacay:update') {
|
||||||
|
try {
|
||||||
|
const { broadcastToUser } = require('../websocket');
|
||||||
|
const plan = db.prepare('SELECT owner_id FROM vacay_plans WHERE id = ?').get(planId);
|
||||||
|
if (!plan) return;
|
||||||
|
const userIds = [plan.owner_id];
|
||||||
|
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));
|
||||||
|
userIds.filter(id => id !== excludeUserId).forEach(id => broadcastToUser(id, { type: event }));
|
||||||
|
} catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Get or create the user's own plan
|
||||||
|
function getOwnPlan(userId) {
|
||||||
|
let plan = db.prepare('SELECT * FROM vacay_plans WHERE owner_id = ?').get(userId);
|
||||||
|
if (!plan) {
|
||||||
|
db.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(userId);
|
||||||
|
plan = db.prepare('SELECT * FROM vacay_plans WHERE owner_id = ?').get(userId);
|
||||||
|
const yr = new Date().getFullYear();
|
||||||
|
db.prepare('INSERT OR IGNORE INTO vacay_years (plan_id, year) VALUES (?, ?)').run(plan.id, yr);
|
||||||
|
// Create user config for current year
|
||||||
|
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)').run(userId, plan.id, yr);
|
||||||
|
}
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the plan the user is currently part of (own or fused)
|
||||||
|
function getActivePlan(userId) {
|
||||||
|
// Check if user has accepted a fusion
|
||||||
|
const membership = db.prepare(`
|
||||||
|
SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted'
|
||||||
|
`).get(userId);
|
||||||
|
if (membership) {
|
||||||
|
return db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(membership.plan_id);
|
||||||
|
}
|
||||||
|
return getOwnPlan(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivePlanId(userId) {
|
||||||
|
return getActivePlan(userId).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all users in a plan (owner + accepted members)
|
||||||
|
function getPlanUsers(planId) {
|
||||||
|
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
|
||||||
|
if (!plan) return [];
|
||||||
|
const owner = db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(plan.owner_id);
|
||||||
|
const members = db.prepare(`
|
||||||
|
SELECT u.id, u.username, u.email FROM vacay_plan_members m
|
||||||
|
JOIN users u ON m.user_id = u.id
|
||||||
|
WHERE m.plan_id = ? AND m.status = 'accepted'
|
||||||
|
`).all(planId);
|
||||||
|
return [owner, ...members];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plan ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/plan', (req, res) => {
|
||||||
|
const plan = getActivePlan(req.user.id);
|
||||||
|
const activePlanId = plan.id;
|
||||||
|
|
||||||
|
// Get user colors
|
||||||
|
const users = getPlanUsers(activePlanId).map(u => {
|
||||||
|
const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(u.id, activePlanId);
|
||||||
|
return { ...u, color: colorRow?.color || '#6366f1' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pending invites (sent from this plan)
|
||||||
|
const pendingInvites = db.prepare(`
|
||||||
|
SELECT m.id, m.user_id, u.username, u.email, m.created_at
|
||||||
|
FROM vacay_plan_members m JOIN users u ON m.user_id = u.id
|
||||||
|
WHERE m.plan_id = ? AND m.status = 'pending'
|
||||||
|
`).all(activePlanId);
|
||||||
|
|
||||||
|
// Pending invites FOR this user (from others)
|
||||||
|
const incomingInvites = db.prepare(`
|
||||||
|
SELECT m.id, m.plan_id, u.username, u.email, m.created_at
|
||||||
|
FROM vacay_plan_members m
|
||||||
|
JOIN vacay_plans p ON m.plan_id = p.id
|
||||||
|
JOIN users u ON p.owner_id = u.id
|
||||||
|
WHERE m.user_id = ? AND m.status = 'pending'
|
||||||
|
`).all(req.user.id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
plan: {
|
||||||
|
...plan,
|
||||||
|
block_weekends: !!plan.block_weekends,
|
||||||
|
holidays_enabled: !!plan.holidays_enabled,
|
||||||
|
company_holidays_enabled: !!plan.company_holidays_enabled,
|
||||||
|
carry_over_enabled: !!plan.carry_over_enabled,
|
||||||
|
},
|
||||||
|
users,
|
||||||
|
pendingInvites,
|
||||||
|
incomingInvites,
|
||||||
|
isOwner: plan.owner_id === req.user.id,
|
||||||
|
isFused: users.length > 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/plan', async (req, res) => {
|
||||||
|
const planId = getActivePlanId(req.user.id);
|
||||||
|
const { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled } = req.body;
|
||||||
|
|
||||||
|
const updates = [];
|
||||||
|
const params = [];
|
||||||
|
if (block_weekends !== undefined) { updates.push('block_weekends = ?'); params.push(block_weekends ? 1 : 0); }
|
||||||
|
if (holidays_enabled !== undefined) { updates.push('holidays_enabled = ?'); params.push(holidays_enabled ? 1 : 0); }
|
||||||
|
if (holidays_region !== undefined) { updates.push('holidays_region = ?'); params.push(holidays_region); }
|
||||||
|
if (company_holidays_enabled !== undefined) { updates.push('company_holidays_enabled = ?'); params.push(company_holidays_enabled ? 1 : 0); }
|
||||||
|
|
||||||
|
if (carry_over_enabled !== undefined) { updates.push('carry_over_enabled = ?'); params.push(carry_over_enabled ? 1 : 0); }
|
||||||
|
|
||||||
|
if (updates.length > 0) {
|
||||||
|
params.push(planId);
|
||||||
|
db.prepare(`UPDATE vacay_plans SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If company holidays re-enabled, remove vacation entries that overlap with company holidays
|
||||||
|
if (company_holidays_enabled === true) {
|
||||||
|
const companyDates = db.prepare('SELECT date FROM vacay_company_holidays WHERE plan_id = ?').all(planId);
|
||||||
|
for (const { date } of companyDates) {
|
||||||
|
db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If public holidays enabled (or region changed), remove vacation entries that land on holidays
|
||||||
|
// Only if a full region is selected (for countries that require it)
|
||||||
|
const updatedPlan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
|
||||||
|
if (updatedPlan.holidays_enabled && updatedPlan.holidays_region) {
|
||||||
|
const country = updatedPlan.holidays_region.split('-')[0];
|
||||||
|
const region = updatedPlan.holidays_region.includes('-') ? updatedPlan.holidays_region : null;
|
||||||
|
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId);
|
||||||
|
for (const { year } of years) {
|
||||||
|
try {
|
||||||
|
const cacheKey = `${year}-${country}`;
|
||||||
|
let holidays = holidayCache.get(cacheKey)?.data;
|
||||||
|
if (!holidays) {
|
||||||
|
const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`);
|
||||||
|
holidays = await resp.json();
|
||||||
|
holidayCache.set(cacheKey, { data: holidays, time: Date.now() });
|
||||||
|
}
|
||||||
|
const hasRegions = holidays.some(h => h.counties && h.counties.length > 0);
|
||||||
|
// If country has regions but no region selected, skip cleanup
|
||||||
|
if (hasRegions && !region) continue;
|
||||||
|
for (const h of holidays) {
|
||||||
|
if (h.global || !h.counties || (region && h.counties.includes(region))) {
|
||||||
|
db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, h.date);
|
||||||
|
db.prepare('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').run(planId, h.date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* API error, skip */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If carry-over was just disabled, reset all carried_over values to 0
|
||||||
|
if (carry_over_enabled === false) {
|
||||||
|
db.prepare('UPDATE vacay_user_years SET carried_over = 0 WHERE plan_id = ?').run(planId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If carry-over was just enabled, recalculate all years
|
||||||
|
if (carry_over_enabled === true) {
|
||||||
|
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId);
|
||||||
|
const users = getPlanUsers(planId);
|
||||||
|
for (let i = 0; i < years.length - 1; i++) {
|
||||||
|
const yr = years[i].year;
|
||||||
|
const nextYr = years[i + 1].year;
|
||||||
|
for (const u of users) {
|
||||||
|
const used = db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${yr}-%`).count;
|
||||||
|
const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, yr);
|
||||||
|
const total = (config ? config.vacation_days : 30) + (config ? config.carried_over : 0);
|
||||||
|
const carry = Math.max(0, total - used);
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?)
|
||||||
|
ON CONFLICT(user_id, plan_id, year) DO UPDATE SET carried_over = ?
|
||||||
|
`).run(u.id, planId, nextYr, carry, carry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyPlanUsers(planId, req.user.id, 'vacay:settings');
|
||||||
|
|
||||||
|
const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
|
||||||
|
res.json({
|
||||||
|
plan: { ...updated, block_weekends: !!updated.block_weekends, holidays_enabled: !!updated.holidays_enabled, company_holidays_enabled: !!updated.company_holidays_enabled, carry_over_enabled: !!updated.carry_over_enabled }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── User color ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.put('/color', (req, res) => {
|
||||||
|
const { color, target_user_id } = req.body;
|
||||||
|
const planId = getActivePlanId(req.user.id);
|
||||||
|
const userId = target_user_id ? parseInt(target_user_id) : req.user.id;
|
||||||
|
const planUsers = getPlanUsers(planId);
|
||||||
|
if (!planUsers.find(u => u.id === userId)) {
|
||||||
|
return res.status(403).json({ error: 'User not in plan' });
|
||||||
|
}
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color
|
||||||
|
`).run(userId, planId, color || '#6366f1');
|
||||||
|
notifyPlanUsers(planId, req.user.id, 'vacay:update');
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Invite / Accept / Decline / Dissolve ───────────────────
|
||||||
|
|
||||||
|
// Invite a user
|
||||||
|
router.post('/invite', (req, res) => {
|
||||||
|
const { user_id } = req.body;
|
||||||
|
if (!user_id) return res.status(400).json({ error: 'user_id required' });
|
||||||
|
if (user_id === req.user.id) return res.status(400).json({ error: 'Cannot invite yourself' });
|
||||||
|
|
||||||
|
const targetUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(user_id);
|
||||||
|
if (!targetUser) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
const plan = getActivePlan(req.user.id);
|
||||||
|
|
||||||
|
// Check if already invited or member
|
||||||
|
const existing = db.prepare('SELECT id, status FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(plan.id, user_id);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.status === 'accepted') return res.status(400).json({ error: 'Already fused' });
|
||||||
|
if (existing.status === 'pending') return res.status(400).json({ error: 'Invite already pending' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if target user is already fused with someone else
|
||||||
|
const targetFusion = db.prepare("SELECT id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted'").get(user_id);
|
||||||
|
if (targetFusion) return res.status(400).json({ error: 'User is already fused with another plan' });
|
||||||
|
|
||||||
|
db.prepare('INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)').run(plan.id, user_id, 'pending');
|
||||||
|
|
||||||
|
// Broadcast via WebSocket if available
|
||||||
|
try {
|
||||||
|
const { broadcastToUser } = require('../websocket');
|
||||||
|
broadcastToUser(user_id, {
|
||||||
|
type: 'vacay:invite',
|
||||||
|
from: { id: req.user.id, username: req.user.username },
|
||||||
|
planId: plan.id,
|
||||||
|
});
|
||||||
|
} catch { /* websocket not available */ }
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accept invite
|
||||||
|
router.post('/invite/accept', (req, res) => {
|
||||||
|
const { plan_id } = req.body;
|
||||||
|
const invite = db.prepare("SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").get(plan_id, req.user.id);
|
||||||
|
if (!invite) return res.status(404).json({ error: 'No pending invite' });
|
||||||
|
|
||||||
|
// Accept
|
||||||
|
db.prepare("UPDATE vacay_plan_members SET status = 'accepted' WHERE id = ?").run(invite.id);
|
||||||
|
|
||||||
|
// Migrate user's own entries into the fused plan
|
||||||
|
const ownPlan = db.prepare('SELECT id FROM vacay_plans WHERE owner_id = ?').get(req.user.id);
|
||||||
|
if (ownPlan && ownPlan.id !== plan_id) {
|
||||||
|
// Move entries
|
||||||
|
db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(plan_id, ownPlan.id, req.user.id);
|
||||||
|
// Copy year configs
|
||||||
|
const ownYears = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ?').all(req.user.id, ownPlan.id);
|
||||||
|
for (const y of ownYears) {
|
||||||
|
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, ?)').run(req.user.id, plan_id, y.year, y.vacation_days, y.carried_over);
|
||||||
|
}
|
||||||
|
// Copy color
|
||||||
|
const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(req.user.id, ownPlan.id);
|
||||||
|
if (colorRow) {
|
||||||
|
db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(req.user.id, plan_id, colorRow.color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-change color if it collides with existing plan users
|
||||||
|
const COLORS = ['#6366f1','#ec4899','#14b8a6','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#64748b','#be185d','#0d9488'];
|
||||||
|
const existingColors = db.prepare('SELECT color FROM vacay_user_colors WHERE plan_id = ? AND user_id != ?').all(plan_id, req.user.id).map(r => r.color);
|
||||||
|
const myColor = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(req.user.id, plan_id);
|
||||||
|
if (myColor && existingColors.includes(myColor.color)) {
|
||||||
|
const available = COLORS.find(c => !existingColors.includes(c));
|
||||||
|
if (available) {
|
||||||
|
db.prepare('UPDATE vacay_user_colors SET color = ? WHERE user_id = ? AND plan_id = ?').run(available, req.user.id, plan_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure years exist in target plan
|
||||||
|
const targetYears = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(plan_id);
|
||||||
|
for (const y of targetYears) {
|
||||||
|
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)').run(req.user.id, plan_id, y.year);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify all plan users (not just owner)
|
||||||
|
notifyPlanUsers(plan_id, req.user.id, 'vacay:accepted');
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Decline invite
|
||||||
|
router.post('/invite/decline', (req, res) => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
notifyPlanUsers(plan_id, req.user.id, 'vacay:declined');
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel pending invite (by inviter)
|
||||||
|
router.post('/invite/cancel', (req, res) => {
|
||||||
|
const { user_id } = req.body;
|
||||||
|
const plan = getActivePlan(req.user.id);
|
||||||
|
db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(plan.id, user_id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { broadcastToUser } = require('../websocket');
|
||||||
|
broadcastToUser(user_id, { type: 'vacay:cancelled' });
|
||||||
|
} catch { /* */ }
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dissolve fusion
|
||||||
|
router.post('/dissolve', (req, res) => {
|
||||||
|
const plan = getActivePlan(req.user.id);
|
||||||
|
const isOwner = plan.owner_id === req.user.id;
|
||||||
|
|
||||||
|
// Collect all user IDs and company holidays before dissolving
|
||||||
|
const allUserIds = getPlanUsers(plan.id).map(u => u.id);
|
||||||
|
const companyHolidays = db.prepare('SELECT date, note FROM vacay_company_holidays WHERE plan_id = ?').all(plan.id);
|
||||||
|
|
||||||
|
if (isOwner) {
|
||||||
|
const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(plan.id);
|
||||||
|
for (const m of members) {
|
||||||
|
const memberPlan = getOwnPlan(m.user_id);
|
||||||
|
db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(memberPlan.id, plan.id, m.user_id);
|
||||||
|
// Copy company holidays to member's own plan
|
||||||
|
for (const ch of companyHolidays) {
|
||||||
|
db.prepare('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(memberPlan.id, ch.date, ch.note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.prepare('DELETE FROM vacay_plan_members WHERE plan_id = ?').run(plan.id);
|
||||||
|
} else {
|
||||||
|
const ownPlan = getOwnPlan(req.user.id);
|
||||||
|
db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(ownPlan.id, plan.id, req.user.id);
|
||||||
|
// Copy company holidays to own plan
|
||||||
|
for (const ch of companyHolidays) {
|
||||||
|
db.prepare('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(ownPlan.id, ch.date, ch.note);
|
||||||
|
}
|
||||||
|
db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?").run(plan.id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify all former plan members
|
||||||
|
try {
|
||||||
|
const { broadcastToUser } = require('../websocket');
|
||||||
|
allUserIds.filter(id => id !== req.user.id).forEach(id => broadcastToUser(id, { type: 'vacay:dissolved' }));
|
||||||
|
} catch { /* */ }
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Available users to invite ──────────────────────────────
|
||||||
|
|
||||||
|
router.get('/available-users', (req, res) => {
|
||||||
|
const planId = getActivePlanId(req.user.id);
|
||||||
|
// All users except: self, already in this plan, already fused elsewhere
|
||||||
|
const users = db.prepare(`
|
||||||
|
SELECT u.id, u.username, u.email FROM users u
|
||||||
|
WHERE u.id != ?
|
||||||
|
AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE plan_id = ?)
|
||||||
|
AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE status = 'accepted')
|
||||||
|
AND u.id NOT IN (SELECT owner_id FROM vacay_plans WHERE id IN (
|
||||||
|
SELECT plan_id FROM vacay_plan_members WHERE status = 'accepted'
|
||||||
|
))
|
||||||
|
ORDER BY u.username
|
||||||
|
`).all(req.user.id, planId);
|
||||||
|
res.json({ users });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Years ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/years', (req, res) => {
|
||||||
|
const planId = getActivePlanId(req.user.id);
|
||||||
|
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) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/years', (req, res) => {
|
||||||
|
const { year } = req.body;
|
||||||
|
if (!year) return res.status(400).json({ error: 'Year required' });
|
||||||
|
const planId = getActivePlanId(req.user.id);
|
||||||
|
try {
|
||||||
|
db.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(planId, year);
|
||||||
|
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
|
||||||
|
const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
|
||||||
|
const users = getPlanUsers(planId);
|
||||||
|
for (const u of users) {
|
||||||
|
// Calculate carry-over from previous year if enabled
|
||||||
|
let carriedOver = 0;
|
||||||
|
if (carryOverEnabled) {
|
||||||
|
const prevConfig = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year - 1);
|
||||||
|
if (prevConfig) {
|
||||||
|
const used = db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${year - 1}-%`).count;
|
||||||
|
const total = prevConfig.vacation_days + prevConfig.carried_over;
|
||||||
|
carriedOver = Math.max(0, total - used);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 */ }
|
||||||
|
notifyPlanUsers(planId, req.user.id, 'vacay:settings');
|
||||||
|
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) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/years/:year', (req, res) => {
|
||||||
|
const year = parseInt(req.params.year);
|
||||||
|
const planId = getActivePlanId(req.user.id);
|
||||||
|
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_company_holidays WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
|
||||||
|
notifyPlanUsers(planId, req.user.id, 'vacay:settings');
|
||||||
|
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) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Entries ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/entries/:year', (req, res) => {
|
||||||
|
const year = req.params.year;
|
||||||
|
const planId = getActivePlanId(req.user.id);
|
||||||
|
const entries = db.prepare(`
|
||||||
|
SELECT e.*, u.username as person_name, COALESCE(c.color, '#6366f1') as person_color
|
||||||
|
FROM vacay_entries e
|
||||||
|
JOIN users u ON e.user_id = u.id
|
||||||
|
LEFT JOIN vacay_user_colors c ON c.user_id = e.user_id AND c.plan_id = e.plan_id
|
||||||
|
WHERE e.plan_id = ? AND e.date LIKE ?
|
||||||
|
`).all(planId, `${year}-%`);
|
||||||
|
const companyHolidays = db.prepare("SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date LIKE ?").all(planId, `${year}-%`);
|
||||||
|
res.json({ entries, companyHolidays });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/entries/toggle', (req, res) => {
|
||||||
|
const { date, target_user_id } = req.body;
|
||||||
|
if (!date) return res.status(400).json({ error: 'date required' });
|
||||||
|
const planId = getActivePlanId(req.user.id);
|
||||||
|
// Allow toggling for another user if they are in the same plan
|
||||||
|
let userId = req.user.id;
|
||||||
|
if (target_user_id && parseInt(target_user_id) !== req.user.id) {
|
||||||
|
const planUsers = getPlanUsers(planId);
|
||||||
|
const tid = parseInt(target_user_id);
|
||||||
|
if (!planUsers.find(u => u.id === tid)) {
|
||||||
|
return res.status(403).json({ error: 'User not in plan' });
|
||||||
|
}
|
||||||
|
userId = tid;
|
||||||
|
}
|
||||||
|
const existing = db.prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId);
|
||||||
|
if (existing) {
|
||||||
|
db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id);
|
||||||
|
notifyPlanUsers(planId, req.user.id);
|
||||||
|
res.json({ action: 'removed' });
|
||||||
|
} else {
|
||||||
|
db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, userId, date, '');
|
||||||
|
notifyPlanUsers(planId, req.user.id);
|
||||||
|
res.json({ action: 'added' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/entries/company-holiday', (req, res) => {
|
||||||
|
const { date, note } = req.body;
|
||||||
|
const planId = getActivePlanId(req.user.id);
|
||||||
|
const existing = db.prepare('SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').get(planId, date);
|
||||||
|
if (existing) {
|
||||||
|
db.prepare('DELETE FROM vacay_company_holidays WHERE id = ?').run(existing.id);
|
||||||
|
notifyPlanUsers(planId, req.user.id);
|
||||||
|
res.json({ action: 'removed' });
|
||||||
|
} else {
|
||||||
|
db.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, date, note || '');
|
||||||
|
// Remove any vacation entries on this date
|
||||||
|
db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, date);
|
||||||
|
notifyPlanUsers(planId, req.user.id);
|
||||||
|
res.json({ action: 'added' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Stats ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/stats/:year', (req, res) => {
|
||||||
|
const year = parseInt(req.params.year);
|
||||||
|
const planId = getActivePlanId(req.user.id);
|
||||||
|
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
|
||||||
|
const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
|
||||||
|
const users = getPlanUsers(planId);
|
||||||
|
|
||||||
|
const stats = users.map(u => {
|
||||||
|
const used = db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${year}-%`).count;
|
||||||
|
const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year);
|
||||||
|
const vacationDays = config ? config.vacation_days : 30;
|
||||||
|
const carriedOver = carryOverEnabled ? (config ? config.carried_over : 0) : 0;
|
||||||
|
const total = vacationDays + carriedOver;
|
||||||
|
const remaining = total - used;
|
||||||
|
const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(u.id, planId);
|
||||||
|
|
||||||
|
// Auto-update carry-over into next year (only if enabled)
|
||||||
|
const nextYearExists = db.prepare('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?').get(planId, year + 1);
|
||||||
|
if (nextYearExists && carryOverEnabled) {
|
||||||
|
const carry = Math.max(0, remaining);
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?)
|
||||||
|
ON CONFLICT(user_id, plan_id, year) DO UPDATE SET carried_over = ?
|
||||||
|
`).run(u.id, planId, year + 1, carry, carry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user_id: u.id, person_name: u.username, person_color: colorRow?.color || '#6366f1',
|
||||||
|
year, vacation_days: vacationDays, carried_over: carriedOver,
|
||||||
|
total_available: total, used, remaining,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ stats });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update vacation days for a year (own or fused partner)
|
||||||
|
router.put('/stats/:year', (req, res) => {
|
||||||
|
const year = parseInt(req.params.year);
|
||||||
|
const { vacation_days, target_user_id } = req.body;
|
||||||
|
const planId = getActivePlanId(req.user.id);
|
||||||
|
const userId = target_user_id ? parseInt(target_user_id) : req.user.id;
|
||||||
|
const planUsers = getPlanUsers(planId);
|
||||||
|
if (!planUsers.find(u => u.id === userId)) {
|
||||||
|
return res.status(403).json({ error: 'User not in plan' });
|
||||||
|
}
|
||||||
|
db.prepare(`
|
||||||
|
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
|
||||||
|
`).run(userId, planId, year, vacation_days);
|
||||||
|
notifyPlanUsers(planId, req.user.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Public Holidays API (proxy to Nager.Date) ─────────────
|
||||||
|
|
||||||
|
router.get('/holidays/countries', async (req, res) => {
|
||||||
|
const cacheKey = 'countries';
|
||||||
|
const cached = holidayCache.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.time < CACHE_TTL) return res.json(cached.data);
|
||||||
|
try {
|
||||||
|
const resp = await fetch('https://date.nager.at/api/v3/AvailableCountries');
|
||||||
|
const data = await resp.json();
|
||||||
|
holidayCache.set(cacheKey, { data, time: Date.now() });
|
||||||
|
res.json(data);
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ error: 'Failed to fetch countries' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/holidays/:year/:country', async (req, res) => {
|
||||||
|
const { year, country } = req.params;
|
||||||
|
const cacheKey = `${year}-${country}`;
|
||||||
|
const cached = holidayCache.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.time < CACHE_TTL) return res.json(cached.data);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
holidayCache.set(cacheKey, { data, time: Date.now() });
|
||||||
|
res.json(data);
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ error: 'Failed to fetch holidays' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -141,4 +141,26 @@ function broadcast(tripId, eventType, payload, excludeSid) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { setupWebSocket, broadcast };
|
function broadcastToUser(userId, payload) {
|
||||||
|
if (!wss) return;
|
||||||
|
for (const ws of wss.clients) {
|
||||||
|
if (ws.readyState !== 1) continue;
|
||||||
|
const user = socketUser.get(ws);
|
||||||
|
if (user && user.id === userId) {
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOnlineUserIds() {
|
||||||
|
const ids = new Set();
|
||||||
|
if (!wss) return ids;
|
||||||
|
for (const ws of wss.clients) {
|
||||||
|
if (ws.readyState !== 1) continue;
|
||||||
|
const user = socketUser.get(ws);
|
||||||
|
if (user) ids.add(user.id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { setupWebSocket, broadcast, broadcastToUser, getOnlineUserIds };
|
||||||
|
|||||||