Compare commits

...

36 Commits

Author SHA1 Message Date
Maurice 0497032ed7 v2.5.7: Reservation overhaul, Day Detail Panel, i18n, paste support, auto dark mode
BREAKING: Reservations have been completely rebuilt. Existing place-level
reservations are no longer used. All reservations must be re-created via
the Bookings tab. Your trips, places, and other data are unaffected.

Reservation System (rebuilt from scratch):
- Reservations now link to specific day assignments instead of places
- Same place on different days can have independent reservations
- New assignment picker in booking modal (grouped by day, searchable)
- Removed day/place dropdowns from booking form
- Reservation badges in day plan sidebar with type-specific icons
- Reservation details in place inspector (only for selected assignment)
- Reservation summary in day detail panel

Day Detail Panel (new):
- Opens on day click in the sidebar
- Detailed weather: hourly forecast, precipitation, wind, sunrise/sunset
- Historical climate averages for dates beyond 16 days
- Accommodation management with check-in/check-out, confirmation number
- Hotel assignment across multiple days with day range picker
- Reservation overview for the day

Places:
- Places can now be assigned to the same day multiple times
- Start time + end time fields (replaces single time field)
- Map badges show multiple position numbers (e.g. "1 · 4")
- Route optimization fixed for duplicate places
- File attachments during place editing (not just creation)
- Cover image upload during trip creation (not just editing)
- Paste support (Ctrl+V) for images in trip, place, and file forms

Internationalization:
- 200+ hardcoded German strings translated to i18n (EN + DE)
- Server error messages in English
- Category seeds in English for new installations
- All planner, register, photo, packing components translated

UI/UX:
- Auto dark mode (follows system preference, configurable in settings)
- Navbar toggle switches light/dark (overrides auto)
- Sidebar minimize buttons z-index fixed
- Transport mode selector removed from day plan
- CustomSelect supports grouped headers (isHeader option)
- Optimistic updates for day notes (instant feedback)
- Booking cards redesigned with type-colored headers and structured details

Weather:
- Wind speed in mph when using Fahrenheit setting
- Weather description language matches app language

Admin:
- Weather info panel replaces OpenWeatherMap key input
- "Recommended" badge styling updated
2026-03-24 20:10:45 +01:00
Maurice e4607e426c v2.5.6: Open-Meteo weather, WebSocket fixes, admin improvements
- Replace OpenWeatherMap with Open-Meteo (no API key needed)
  - 16-day forecast (up from 5 days)
  - Historical climate averages as fallback beyond 16 days
  - Auto-upgrade from climate to real forecast when available
- Fix Vacay WebSocket sync across devices (socket-ID exclusion instead of user-ID)
- Add GitHub release history tab in admin panel
- Show cluster count "1" for single map markers when zoomed out
- Add weather info panel in admin settings (replaces OpenWeatherMap key input)
- Update i18n translations (DE + EN)
2026-03-24 10:02:03 +01:00
Maurice faa8c84655 Vacay drag-to-paint, "Everyone" button, live exchange rates
- Vacay: click-and-drag to paint/erase vacation days across calendar
- Vacay: "Everyone" button sets days for all persons (2+ only)
- Budget: live currency conversion via frankfurter.app (cached 1h)
- Budget: conversion widget in total card with selectable target currency
- Day planner: remove transport mode buttons from day view
2026-03-23 21:11:20 +01:00
Maurice 88dca41ef7 Standardize all Docker paths to /opt/nomad in README
All examples (Quick Start, Docker Compose, Update) now use absolute
/opt/nomad/data and /opt/nomad/uploads paths consistently.
2026-03-23 20:47:39 +01:00
Maurice 33162123af Fix README update command: use real paths instead of placeholders
Placeholder paths (/your/data) caused data loss when copied literally.
Now uses /opt/nomad/data with a warning about correct paths.
2026-03-23 20:46:20 +01:00
Maurice 10662e0b63 v2.5.5 — Fix PDF preview overlay, mobile login tagline
- PDF file preview now renders via portal above navbar
- Fix mobile login tagline text wrapping
2026-03-23 20:09:29 +01:00
Maurice 8286fa8591 Fix mobile login tagline wrapping to single line 2026-03-23 20:03:34 +01:00
Maurice 5f2bd51824 Fix update for Docker: show commands instead of one-click install
- Detect Docker environment (/.dockerenv) on server
- Version check returns is_docker flag
- Docker: show terminal commands for docker pull/restart
- Git installs: keep one-click update button
- Data safety hint shown in both modes
2026-03-23 19:17:59 +01:00
Maurice 4d3ee08481 v2.5.4 — Smart map zoom & place files in reservations
- Map auto-fits to day places when selecting a day or place
- Dynamic padding accounts for sidebars and place inspector overlay
- Place-based reservations now show linked files in the bookings tab
- Increased max zoom to 16 for closer detail on nearby places
2026-03-23 19:14:14 +01:00
Maurice aeb530515e v2.5.3 — Admin update checker & one-click self-update
- Add version check against GitHub releases in admin dashboard
- Show amber banner when a newer version is available
- One-click update: git pull + npm install + auto-restart
- Confirmation dialog with backup recommendation and data safety info
- Dark mode support for update banner
- Fix fresh DB migration: initial schema now includes all columns
- i18n: English + German translations for all update UI
2026-03-23 19:02:08 +01:00
Maurice f4d3542d99 Update README: node:sqlite → better-sqlite3 2026-03-22 17:58:41 +01:00
Maurice d604ad1c5b Stabilize core: better-sqlite3, versioned migrations, graceful shutdown
- Replace experimental node:sqlite with better-sqlite3 (stable API)
- Replace try/catch migration pattern with schema_version tracking
- Add SIGTERM/SIGINT handler for clean shutdown (DB flush, scheduler stop)
- Fix BudgetPanel crash: remove undefined setShowAddCategory call
- Update Dockerfile: remove --experimental-sqlite, add native build tools
2026-03-22 17:52:24 +01:00
Maurice 3919c61eb6 Fix demo seed: add trip_id to day_notes insert 2026-03-22 03:22:13 +01:00
Maurice 98556c9aaf Add takeoff animation to demo login 2026-03-22 03:17:36 +01:00
Maurice 7e4ec82d3e Overhaul demo content: English, 3 trips with Google Place IDs, notes, budgets 2026-03-22 03:16:56 +01:00
Maurice 5f891c83e8 Update screenshots with new branding 2026-03-22 03:09:33 +01:00
Maurice 3d6ae6811c README: use light logo as default, auto-switch for dark/light GitHub theme 2026-03-22 02:51:32 +01:00
Maurice dd3d4263a7 v2.5.2 — PWA, new branding, bug fixes
Progressive Web App:
- Service worker with Workbox caching (map tiles, API, uploads, CDN)
- Web app manifest with standalone display mode
- Custom app icon with PNG generation from SVG
- Apple meta tags, dynamic theme-color for dark/light mode
- iOS safe area handling

New Branding:
- Custom NOMAD logo (icon + text variants for light/dark mode)
- Logo used in navbar, login page, demo banner, admin, PDF export
- MuseoModerno font for login tagline
- Plane takeoff animation on login
- Liquid glass hover effect on dashboard spotlight & widgets
- Brand images protected from save/copy/drag
- "made with NOMAD" footer on PDF exports

Bug Fixes:
- Fix mobile note reorder (missing tripId prop)
- Fix Atlas city counting (strip postal codes, normalize case)
- Fix Atlas country detection (add Japanese/Korean/Thai names)
- Fix PDF note positioning (use order_index instead of sort_order)
- Fix PDF note icons (render actual icon instead of hardcoded notepad)
- Fix file source badge overflow on mobile (text truncation)
- Fix navbar dropdown z-index overlap with mobile plan/places buttons
- Fix dashboard trip card hover contrast in dark mode
- Fix day header hover color matching place background in dark mode
- Shorten settings button labels on mobile

UI Improvements:
- Mobile navbar shows icon only, desktop shows full logo
- NOMAD version badge in profile dropdown
- Top padding before first item in day planner
- Improved drag & drop stability (larger drop zones, less flickering)
2026-03-22 02:50:13 +01:00
Maurice 5f4e7f9487 Fix mobile note reorder, shorten settings buttons, fix Atlas city/country counting 2026-03-21 23:27:04 +01:00
Maurice e652efee8a Switch to default status bar style — iOS handles safe areas cleanly 2026-03-21 23:10:23 +01:00
Maurice 47028798b1 Fix bottom bar: use transform trick to paint safe area 2026-03-21 23:07:10 +01:00
Maurice 6076a482b8 Fix bottom safe area: use fixed body to cover full screen 2026-03-21 23:01:45 +01:00
Maurice a590db8e10 Truncate long place names in file source badges on mobile 2026-03-21 22:56:09 +01:00
Maurice 502c334cbf Fix bottom safe area: html background + fixed inset for map page 2026-03-21 22:54:20 +01:00
Maurice 031cc3587b Fix hardcoded pixel offsets in TripPlannerPage for safe area 2026-03-21 22:47:48 +01:00
Maurice f55f5ea449 Fix PWA safe area: navbar extends behind status bar, remove bottom black bar 2026-03-21 22:42:21 +01:00
Maurice 557de4cd5a Add PWA support for iOS home screen install
- Web app manifest (standalone display, NOMAD branding, icons)
- Service worker with Workbox caching (map tiles, API, uploads, CDN)
- SVG app icon + PNG generation script via sharp
- Apple meta tags (web-app-capable, black-translucent status bar)
- Dynamic theme-color for dark/light mode
- Safe area insets for notch devices
2026-03-21 22:21:23 +01:00
Maurice 544ac796d5 Auto-route on reorder/assign/remove, lock places for optimization, fix zoom
- Route line auto-updates on day select, reorder, assign, remove (no manual button)
- Remove manual route calculation button (keep optimize + Google Maps)
- Lock places at their position during route optimization (click avatar to toggle)
- Locked places shown with red background, border and lock overlay
- Custom tooltip for lock feature (DE/EN, dark mode)
- Fix map zoom: panTo instead of setView keeps current zoom level
- Fix fitBounds only on actual day change, not on place click
- Missing translations: needTwoPlaces, routeOptimized, noGeoPlaces
2026-03-21 16:12:13 +01:00
Maurice 5b6e3d6c1a Fix drag/drop to bottom of day, add restore warning modal, add missing translations 2026-03-21 15:35:05 +01:00
Maurice df695ee8d8 v2.5.1 — Security hardening, backup restore fix & restore warning modal 2026-03-21 15:13:10 +01:00
Maurice d845057f84 Security hardening, backup restore fix & restore warning modal
- Fix backup restore: try/finally ensures DB always reopens after closeDb
- Fix EBUSY on uploads during restore (in-place overwrite instead of rmSync)
- Add DB proxy null guard for clearer errors during restore window
- Add red warning modal before backup restore (DE/EN, dark mode support)
- JWT secret: empty docker-compose default so auto-generation kicks in
- OIDC: pass token via URL fragment instead of query param (no server logs)
- Block SVG uploads on photos, files and covers (stored XSS prevention)
- Add helmet for security headers (HSTS, X-Frame, nosniff, etc.)
- Explicit express.json body size limit (100kb)
- Fix XSS in Leaflet map markers (escape image_url in HTML)
- Remove verbose WebSocket debug logging from client
2026-03-21 15:09:41 +01:00
Maurice e70fe50ae3 Fix demo banner: i18n for demo button, icon alignment, add addon mgmt & OIDC to full version features 2026-03-21 11:14:53 +01:00
mauriceboe 2000371844 Update README.md 2026-03-20 23:42:49 +01:00
Maurice d45d9c2cfa Fix Atlas labels, update Demo Banner with addons & NOMAD intro (v2.5.0)
- Remove Next Trip from Atlas bottom panel
- Fix label wrapping with whitespace-nowrap on streak/year labels
- Redesign Demo Banner: add addon showcase (Vacay, Atlas, Packing, Budget, Documents, Widgets), "What is NOMAD?" section, 2-column grid layout, compact design
2026-03-20 23:39:12 +01:00
Maurice d24f0b3ccd Fix Atlas: remove Next Trip, fix label wrapping (v2.5.0) 2026-03-20 23:39:12 +01:00
mauriceboe c1fb745627 Update README.md 2026-03-20 23:20:34 +01:00
103 changed files with 9525 additions and 1820 deletions
+3
View File
@@ -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
+5 -3
View File
@@ -11,9 +11,11 @@ FROM node:22-alpine
WORKDIR /app WORKDIR /app
# Server-Dependencies installieren # Server-Dependencies installieren (better-sqlite3 braucht Build-Tools)
COPY server/package*.json ./ COPY server/package*.json ./
RUN npm ci --production RUN apk add --no-cache python3 make g++ && \
npm ci --production && \
apk del python3 make g++
# Server-Code kopieren # Server-Code kopieren
COPY server/ ./ COPY server/ ./
@@ -33,4 +35,4 @@ ENV PORT=3000
EXPOSE 3000 EXPOSE 3000
CMD ["node", "--experimental-sqlite", "src/index.js"] CMD ["node", "src/index.js"]
+64 -30
View File
@@ -1,17 +1,28 @@
# NOMAD <p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="client/public/logo-light.svg" />
<source media="(prefers-color-scheme: light)" srcset="client/public/logo-dark.svg" />
<img src="client/public/logo-light.svg" alt="NOMAD" height="60" />
</picture>
<br />
<em>Navigation Organizer for Maps, Activities & Destinations</em>
</p>
**Navigation Organizer for Maps, Activities & Destinations** <p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
<a href="https://hub.docker.com/r/mauriceboe/nomad"><img src="https://img.shields.io/docker/pulls/mauriceboe/nomad" alt="Docker Pulls" /></a>
<a href="https://github.com/mauriceboe/NOMAD"><img src="https://img.shields.io/github/stars/mauriceboe/NOMAD" alt="GitHub Stars" /></a>
<a href="https://github.com/mauriceboe/NOMAD/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/NOMAD" alt="Last Commit" /></a>
</p>
A self-hosted, real-time collaborative travel planner for organizing trips with interactive maps, budgets, packing lists, and more. <p align="center">
A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more.
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](LICENSE) <br />
[![Docker Pulls](https://img.shields.io/docker/pulls/mauriceboe/nomad)](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.
[![GitHub Stars](https://img.shields.io/github/stars/mauriceboe/NOMAD)](https://github.com/mauriceboe/NOMAD) </p>
[![Last Commit](https://img.shields.io/github/last-commit/mauriceboe/NOMAD)](https://github.com/mauriceboe/NOMAD/commits)
**[Live Demo](https://demo-nomad.pakulat.org)** — Try NOMAD without installing. Resets hourly.
![NOMAD Screenshot](docs/screenshot.png) ![NOMAD Screenshot](docs/screenshot.png)
![NOMAD Screenshot 2](docs/screenshot-2.png)
<details> <details>
<summary>More Screenshots</summary> <summary>More Screenshots</summary>
@@ -19,47 +30,61 @@ A self-hosted, real-time collaborative travel planner for organizing trips with
| | | | | |
|---|---| |---|---|
| ![Plan Detail](docs/screenshot-plan-detail.png) | ![Bookings](docs/screenshot-bookings.png) | | ![Plan Detail](docs/screenshot-plan-detail.png) | ![Bookings](docs/screenshot-bookings.png) |
| ![Packing List](docs/screenshot-packing.png) | ![Budget](docs/screenshot-budget.png) | | ![Budget](docs/screenshot-budget.png) | ![Packing List](docs/screenshot-packing.png) |
| ![Files](docs/screenshot-files.png) | | | ![Files](docs/screenshot-files.png) | |
</details> </details>
## Features ## Features
- **Real-Time Collaboration** — Plan together via WebSocket live sync — changes appear instantly across all connected users ### Trip Planning
- **Interactive Map** — Leaflet map with marker clustering, route visualization, and customizable tile sources
- **Place Search** — Search via Google Places (with photos, ratings, opening hours) or OpenStreetMap (free, no API key needed)
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
- **Drag & Drop Planner** — Organize places into day plans with reordering and cross-day moves - **Drag & Drop Planner** — Organize places into day plans with reordering and cross-day moves
- **Weather Forecasts** — Current weather and 5-day forecasts with smart caching (requires API key) - **Interactive Map** — Leaflet map with photo markers, clustering, route visualization, and customizable tile sources
- **Place Search** — Search via Google Places (with photos, ratings, opening hours) or OpenStreetMap (free, no API key needed)
- **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering
- **Route Optimization** — Auto-optimize place order and export to Google Maps
- **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback
### Travel Management
- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments
- **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support - **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support
- **Packing Lists** — Categorized checklists with progress tracking, color coding, and smart suggestions - **Packing Lists** — Categorized checklists with progress tracking, color coding, and smart suggestions
- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file) - **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
- **PDF Export** — Export complete trip plans as PDF with images and notes - **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and NOMAD branding
### Mobile & PWA
- **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed
- **Offline Support** — Service Worker caches map tiles, API data, uploads, and static assets via Workbox
- **Native App Feel** — Fullscreen standalone mode, custom app icon, themed status bar, and splash screen
- **Touch Optimized** — Responsive design with mobile-specific layouts, touch-friendly controls, and safe area handling
### Collaboration
- **Real-Time Sync** — Plan together via WebSocket — changes appear instantly across all connected users
- **Multi-User** — Invite members to collaborate on shared trips with role-based access - **Multi-User** — Invite members to collaborate on shared trips with role-based access
- **Addon System** — Modular features that admins can enable/disable: Packing Lists, Budget, Documents, and global addons - **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with WebSocket live sync, and carry-over tracking
- **Atlas** — Interactive world map showing visited countries with travel stats, continent breakdown, streak tracking, and country details on click ### Addons (modular, admin-toggleable)
- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with live sync, and carry-over tracking
- **Atlas** — Interactive world map with visited countries, travel stats, continent breakdown, streak tracking, and liquid glass UI effects
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user - **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
- **Admin Panel** — User management with online status, global categories, addon management, API key configuration, and backups
- **Auto-Backups** — Scheduled backups with configurable interval and retention ### Customization & Admin
- **Route Optimization** — Auto-optimize place order and export to Google Maps - **Dark Mode** — Full light and dark theme with dynamic status bar color matching
- **Day Notes** — Add timestamped notes to individual days
- **Dark Mode** — Full light and dark theme support
- **Multilingual** — English and German (i18n) - **Multilingual** — English and German (i18n)
- **Mobile Friendly** — Responsive design with touch-optimized controls - **Admin Panel** — User management, global categories, addon management, API keys, backups, and GitHub release history
- **Auto-Backups** — Scheduled backups with configurable interval and retention
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates - **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
## Tech Stack ## Tech Stack
- **Backend**: Node.js 22 + Express + SQLite (`node:sqlite`) - **Backend**: Node.js 22 + Express + SQLite (`better-sqlite3`)
- **Frontend**: React 18 + Vite + Tailwind CSS - **Frontend**: React 18 + Vite + Tailwind CSS
- **PWA**: vite-plugin-pwa + Workbox
- **Real-Time**: WebSocket (`ws`) - **Real-Time**: WebSocket (`ws`)
- **State**: Zustand - **State**: Zustand
- **Auth**: JWT - **Auth**: JWT + OIDC
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional) - **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
- **Weather**: OpenWeatherMap API (optional) - **Weather**: Open-Meteo API (free, no key required)
- **Icons**: lucide-react - **Icons**: lucide-react
## Quick Start ## Quick Start
@@ -70,6 +95,15 @@ docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads maurice
The app runs on port `3000`. The first user to register becomes the admin. The app runs on port `3000`. The first user to register becomes the admin.
### Install as App (PWA)
NOMAD works as a Progressive Web App — no App Store needed:
1. Open your NOMAD instance in the browser (HTTPS required)
2. **iOS**: Share button → "Add to Home Screen"
3. **Android**: Menu → "Install app" or "Add to Home Screen"
4. NOMAD launches fullscreen with its own icon, just like a native app
<details> <details>
<summary>Docker Compose (recommended for production)</summary> <summary>Docker Compose (recommended for production)</summary>
+17 -1
View File
@@ -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>
+4586 -3
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -1,10 +1,11 @@
{ {
"name": "nomad-client", "name": "nomad-client",
"version": "2.5.0", "version": "2.5.7",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"prebuild": "node scripts/generate-icons.mjs",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
@@ -30,7 +31,9 @@
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.18", "autoprefixer": "^10.4.18",
"postcss": "^8.4.35", "postcss": "^8.4.35",
"sharp": "^0.33.0",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"vite": "^5.1.4" "vite": "^5.1.4",
"vite-plugin-pwa": "^0.21.0"
} }
} }
+1
View File
@@ -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

+1
View File
@@ -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

+15
View File
@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

+29
View File
@@ -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.');
+19 -7
View File
@@ -13,19 +13,20 @@ import SettingsPage from './pages/SettingsPage'
import VacayPage from './pages/VacayPage' import VacayPage from './pages/VacayPage'
import AtlasPage from './pages/AtlasPage' import AtlasPage from './pages/AtlasPage'
import { ToastContainer } from './components/shared/Toast' import { ToastContainer } from './components/shared/Toast'
import { TranslationProvider } from './i18n' import { TranslationProvider, useTranslation } from './i18n'
import DemoBanner from './components/Layout/DemoBanner' import DemoBanner from './components/Layout/DemoBanner'
import { authApi } from './api/client' import { authApi } from './api/client'
function ProtectedRoute({ children, adminRequired = false }) { function ProtectedRoute({ children, adminRequired = false }) {
const { isAuthenticated, user, isLoading } = useAuthStore() const { isAuthenticated, user, isLoading } = useAuthStore()
const { t } = useTranslation()
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-slate-50"> <div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div> <div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
<p className="text-slate-500 text-sm">Wird geladen...</p> <p className="text-slate-500 text-sm">{t('common.loading')}</p>
</div> </div>
</div> </div>
) )
@@ -78,13 +79,24 @@ export default function App() {
} }
}, [isAuthenticated]) }, [isAuthenticated])
// Apply dark mode class to <html> // Apply dark mode class to <html> + update PWA theme-color
useEffect(() => { useEffect(() => {
if (settings.dark_mode) { const mode = settings.dark_mode
document.documentElement.classList.add('dark') const applyDark = (isDark) => {
} else { document.documentElement.classList.toggle('dark', isDark)
document.documentElement.classList.remove('dark') const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff')
} }
if (mode === 'auto') {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
applyDark(mq.matches)
const handler = (e) => applyDark(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}
// Support legacy boolean + new string values
applyDark(mode === true || mode === 'dark')
}, [settings.dark_mode]) }, [settings.dark_mode])
return ( return (
+13 -2
View File
@@ -94,6 +94,7 @@ export const assignmentsApi = {
delete: (tripId, dayId, id) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data), delete: (tripId, dayId, id) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
reorder: (tripId, dayId, orderedIds) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data), reorder: (tripId, dayId, orderedIds) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data),
move: (tripId, assignmentId, newDayId, orderIndex) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data), move: (tripId, assignmentId, newDayId, orderIndex) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
update: (tripId, dayId, id, data) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data),
} }
export const packingApi = { export const packingApi = {
@@ -129,6 +130,8 @@ export const adminApi = {
updateOidc: (data) => apiClient.put('/admin/oidc', data).then(r => r.data), updateOidc: (data) => apiClient.put('/admin/oidc', data).then(r => r.data),
addons: () => apiClient.get('/admin/addons').then(r => r.data), addons: () => apiClient.get('/admin/addons').then(r => r.data),
updateAddon: (id, data) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data), updateAddon: (id, data) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data),
} }
export const addonsApi = { export const addonsApi = {
@@ -165,7 +168,8 @@ export const reservationsApi = {
} }
export const weatherApi = { export const weatherApi = {
get: (lat, lng, date) => apiClient.get('/weather', { params: { lat, lng, date, units: 'metric' } }).then(r => r.data), get: (lat, lng, date) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
getDetailed: (lat, lng, date, lang) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
} }
export const settingsApi = { export const settingsApi = {
@@ -174,6 +178,13 @@ export const settingsApi = {
setBulk: (settings) => apiClient.post('/settings/bulk', { settings }).then(r => r.data), setBulk: (settings) => apiClient.post('/settings/bulk', { settings }).then(r => r.data),
} }
export const accommodationsApi = {
list: (tripId) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
create: (tripId, data) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
}
export const dayNotesApi = { export const dayNotesApi = {
list: (tripId, dayId) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data), list: (tripId, dayId) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data),
create: (tripId, dayId, data) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data), create: (tripId, dayId, data) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
@@ -189,7 +200,7 @@ export const backupApi = {
const res = await fetch(`/api/backup/download/${filename}`, { const res = await fetch(`/api/backup/download/${filename}`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) })
if (!res.ok) throw new Error('Download fehlgeschlagen') if (!res.ok) throw new Error('Download failed')
const blob = await res.blob() const blob = await res.blob()
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
+2 -4
View File
@@ -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
+6 -1
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { adminApi } from '../../api/client' import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase } from 'lucide-react' import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase } from 'lucide-react'
@@ -15,6 +16,8 @@ function AddonIcon({ name, size = 20 }) {
export default function AddonManager() { export default function AddonManager() {
const { t } = useTranslation() const { t } = useTranslation()
const dm = useSettingsStore(s => s.settings.dark_mode)
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const toast = useToast() const toast = useToast()
const [addons, setAddons] = useState([]) const [addons, setAddons] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -67,7 +70,9 @@ export default function AddonManager() {
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}> <div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}> <div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2> <h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('admin.addons.subtitle')}</p> <p className="text-xs mt-1" style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="NOMAD" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
</p>
</div> </div>
{addons.length === 0 ? ( {addons.length === 0 ? (
+95 -23
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { backupApi } from '../../api/client' import { backupApi } from '../../api/client'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive } from 'lucide-react' import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
const INTERVAL_OPTIONS = [ const INTERVAL_OPTIONS = [
@@ -29,9 +29,10 @@ export default function BackupPanel() {
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 }) const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false) const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false) const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
const toast = useToast() const toast = useToast()
const { t, locale } = useTranslation() const { t, language, locale } = useTranslation()
const loadBackups = async () => { const loadBackups = async () => {
setIsLoading(true) setIsLoading(true)
@@ -67,32 +68,42 @@ export default function BackupPanel() {
} }
} }
const handleRestore = async (filename) => { const handleRestore = (filename) => {
if (!confirm(t('backup.confirm.restore', { name: filename }))) return setRestoreConfirm({ type: 'file', filename })
setRestoringFile(filename)
try {
await backupApi.restore(filename)
toast.success(t('backup.toast.restored'))
setTimeout(() => window.location.reload(), 1500)
} catch (err) {
toast.error(err.response?.data?.error || t('backup.toast.restoreError'))
setRestoringFile(null)
}
} }
const handleUploadRestore = async (e) => { const handleUploadRestore = (e) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
e.target.value = '' e.target.value = ''
if (!confirm(t('backup.confirm.uploadRestore', { name: file.name }))) return setRestoreConfirm({ type: 'upload', filename: file.name, file })
setIsUploading(true) }
try {
await backupApi.uploadRestore(file) const executeRestore = async () => {
toast.success(t('backup.toast.restored')) if (!restoreConfirm) return
setTimeout(() => window.location.reload(), 1500) const { type, filename, file } = restoreConfirm
} catch (err) { setRestoreConfirm(null)
toast.error(err.response?.data?.error || t('backup.toast.uploadError'))
setIsUploading(false) if (type === 'file') {
setRestoringFile(filename)
try {
await backupApi.restore(filename)
toast.success(t('backup.toast.restored'))
setTimeout(() => window.location.reload(), 1500)
} catch (err) {
toast.error(err.response?.data?.error || t('backup.toast.restoreError'))
setRestoringFile(null)
}
} else {
setIsUploading(true)
try {
await backupApi.uploadRestore(file)
toast.success(t('backup.toast.restored'))
setTimeout(() => window.location.reload(), 1500)
} catch (err) {
toast.error(err.response?.data?.error || t('backup.toast.uploadError'))
setIsUploading(false)
}
} }
} }
@@ -357,6 +368,67 @@ export default function BackupPanel() {
</div> </div>
</div> </div>
</div> </div>
{/* Restore Warning Modal */}
{restoreConfirm && (
<div
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={() => setRestoreConfirm(null)}
>
<div
onClick={e => e.stopPropagation()}
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
>
{/* Red header */}
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<AlertTriangle size={20} style={{ color: 'white' }} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>
{t('backup.restoreConfirmTitle')}
</h3>
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
{restoreConfirm.filename}
</p>
</div>
</div>
{/* Body */}
<div style={{ padding: '20px 24px' }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{t('backup.restoreWarning')}
</p>
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
>
{t('backup.restoreTip')}
</div>
</div>
{/* Footer */}
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
onClick={() => setRestoreConfirm(null)}
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
<button
onClick={executeRestore}
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: '#dc2626', color: 'white' }}
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
>
{t('backup.restoreConfirm')}
</button>
</div>
</div>
</div>
)}
</div> </div>
) )
} }
+263
View File
@@ -0,0 +1,263 @@
import React, { useState, useEffect } from 'react'
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'
import { useTranslation } from '../../i18n'
const REPO = 'mauriceboe/NOMAD'
const PER_PAGE = 10
export default function GitHubPanel() {
const { t, language } = useTranslation()
const [releases, setReleases] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [expanded, setExpanded] = useState({})
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const fetchReleases = async (pageNum = 1, append = false) => {
try {
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=${PER_PAGE}&page=${pageNum}`)
if (!res.ok) throw new Error(`GitHub API: ${res.status}`)
const data = await res.json()
setReleases(prev => append ? [...prev, ...data] : data)
setHasMore(data.length === PER_PAGE)
} catch (err) {
setError(err.message)
}
}
useEffect(() => {
setLoading(true)
fetchReleases(1).finally(() => setLoading(false))
}, [])
const handleLoadMore = async () => {
const next = page + 1
setLoadingMore(true)
await fetchReleases(next, true)
setPage(next)
setLoadingMore(false)
}
const toggleExpand = (id) => {
setExpanded(prev => ({ ...prev, [id]: !prev[id] }))
}
const formatDate = (dateStr) => {
const d = new Date(dateStr)
return d.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric' })
}
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
const renderBody = (body) => {
if (!body) return null
const lines = body.split('\n')
const elements = []
let listItems = []
const flushList = () => {
if (listItems.length > 0) {
elements.push(
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
{listItems.map((item, i) => (
<li key={i} className="flex gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
</li>
))}
</ul>
)
listItems = []
}
}
const inlineFormat = (text) => {
return text
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">$1</a>')
}
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) { flushList(); continue }
if (trimmed.startsWith('### ')) {
flushList()
elements.push(
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
{trimmed.slice(4)}
</h4>
)
} else if (trimmed.startsWith('## ')) {
flushList()
elements.push(
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
{trimmed.slice(3)}
</h3>
)
} else if (/^[-*] /.test(trimmed)) {
listItems.push(trimmed.slice(2))
} else {
flushList()
elements.push(
<p key={elements.length} className="text-xs my-1" style={{ color: 'var(--text-muted)' }}
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
/>
)
}
}
flushList()
return elements
}
if (loading) {
return (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="p-8 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--text-muted)' }} />
</div>
</div>
)
}
if (error) {
return (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="p-6 text-center">
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.github.error')}</p>
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{error}</p>
</div>
</div>
)
}
return (
<div className="space-y-3">
{/* Header card */}
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-secondary)' }}>
<div>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.github.title')}</h2>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
</div>
<a
href={`https://github.com/${REPO}/releases`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
>
<ExternalLink size={12} />
GitHub
</a>
</div>
{/* Timeline */}
<div className="px-5 py-4">
<div className="relative">
{/* Timeline line */}
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
<div className="space-y-0">
{releases.map((release, idx) => {
const isLatest = idx === 0
const isExpanded = expanded[release.id]
return (
<div key={release.id} className="relative pl-8 pb-5">
{/* Timeline dot */}
<div
className="absolute left-0 top-1 w-[23px] h-[23px] rounded-full flex items-center justify-center border-2"
style={{
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
}}
>
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
</div>
{/* Release content */}
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{release.tag_name}
</span>
{isLatest && (
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
style={{ background: 'rgba(34,197,94,0.12)', color: '#16a34a' }}>
{t('admin.github.latest')}
</span>
)}
{release.prerelease && (
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
style={{ background: 'rgba(245,158,11,0.12)', color: '#d97706' }}>
{t('admin.github.prerelease')}
</span>
)}
</div>
{release.name && release.name !== release.tag_name && (
<p className="text-xs font-medium mt-0.5" style={{ color: 'var(--text-muted)' }}>
{release.name}
</p>
)}
<div className="flex items-center gap-3 mt-1">
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-faint)' }}>
<Calendar size={10} />
{formatDate(release.published_at || release.created_at)}
</span>
{release.author && (
<span className="text-[11px]" style={{ color: 'var(--text-faint)' }}>
{t('admin.github.by')} {release.author.login}
</span>
)}
</div>
{/* Expandable body */}
{release.body && (
<div className="mt-2">
<button
onClick={() => toggleExpand(release.id)}
className="flex items-center gap-1 text-[11px] font-medium transition-colors"
style={{ color: 'var(--text-muted)' }}
>
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
</button>
{isExpanded && (
<div className="mt-2 p-3 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
{renderBody(release.body)}
</div>
)}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
{/* Load more */}
{hasMore && (
<div className="text-center pt-2">
<button
onClick={handleLoadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
>
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
</button>
</div>
)}
</div>
</div>
</div>
)
}
+2 -2
View File
@@ -163,7 +163,7 @@ export default function BudgetPanel({ tripId }) {
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId]) useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
const grouped = useMemo(() => (budgetItems || []).reduce((acc, item) => { const grouped = useMemo(() => (budgetItems || []).reduce((acc, item) => {
const cat = item.category || 'Sonstiges' const cat = item.category || 'Other'
if (!acc[cat]) acc[cat] = [] if (!acc[cat]) acc[cat] = []
acc[cat].push(item) acc[cat].push(item)
return acc return acc
@@ -190,7 +190,7 @@ export default function BudgetPanel({ tripId }) {
const handleAddCategory = () => { const handleAddCategory = () => {
if (!newCategoryName.trim()) return if (!newCategoryName.trim()) return
addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 }) addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 })
setNewCategoryName(''); setShowAddCategory(false) setNewCategoryName('')
} }
const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' } const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
@@ -106,7 +106,8 @@ async function loadGeoJson() {
export default function TravelStats() { export default function TravelStats() {
const { t } = useTranslation() const { t } = useTranslation()
const dark = useSettingsStore(s => s.settings.dark_mode) const dm = useSettingsStore(s => s.settings.dark_mode)
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const [stats, setStats] = useState(null) const [stats, setStats] = useState(null)
const [geoData, setGeoData] = useState(null) const [geoData, setGeoData] = useState(null)
+27 -8
View File
@@ -1,4 +1,5 @@
import React, { useState, useCallback } from 'react' import React, { useState, useCallback } from 'react'
import ReactDOM from 'react-dom'
import { useDropzone } from 'react-dropzone' import { useDropzone } from 'react-dropzone'
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket } from 'lucide-react' import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket } from 'lucide-react'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
@@ -68,10 +69,10 @@ function SourceBadge({ icon: Icon, label }) {
fontSize: 10.5, color: '#4b5563', fontSize: 10.5, color: '#4b5563',
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)', background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
borderRadius: 6, padding: '2px 7px', borderRadius: 6, padding: '2px 7px',
fontWeight: 500, whiteSpace: 'nowrap', fontWeight: 500, maxWidth: '100%', overflow: 'hidden',
}}> }}>
<Icon size={10} style={{ flexShrink: 0, color: 'var(--text-muted)' }} /> <Icon size={10} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
{label} <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</span> </span>
) )
} }
@@ -106,6 +107,23 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
noClick: false, noClick: false,
}) })
// Paste support
const handlePaste = useCallback((e) => {
const items = e.clipboardData?.items
if (!items) return
const files = []
for (const item of items) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) files.push(file)
}
}
if (files.length > 0) {
e.preventDefault()
onDrop(files)
}
}, [onDrop])
const filteredFiles = files.filter(f => { const filteredFiles = files.filter(f => {
if (filterType === 'pdf') return f.mime_type === 'application/pdf' if (filterType === 'pdf') return f.mime_type === 'application/pdf'
if (filterType === 'image') return isImage(f.mime_type) if (filterType === 'image') return isImage(f.mime_type)
@@ -134,18 +152,18 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
} }
return ( return (
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}> <div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} onPaste={handlePaste} tabIndex={-1}>
{/* Lightbox */} {/* Lightbox */}
{lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />} {lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />}
{/* Datei-Vorschau Modal */} {/* Datei-Vorschau Modal — portal to body to escape stacking context */}
{previewFile && ( {previewFile && ReactDOM.createPortal(
<div <div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 8 }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={() => setPreviewFile(null)} onClick={() => setPreviewFile(null)}
> >
<div <div
style={{ width: '100%', maxWidth: 950, height: '95vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }} style={{ width: '100%', maxWidth: 950, height: '94vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
@@ -176,7 +194,8 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</p> </p>
</object> </object>
</div> </div>
</div> </div>,
document.body
)} )}
{/* Header */} {/* Header */}
+119 -53
View File
@@ -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}
+25 -19
View File
@@ -1,10 +1,11 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import { Link, useNavigate, useLocation } from 'react-router-dom' import { Link, useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { addonsApi } from '../../api/client' import { addonsApi } from '../../api/client'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, CalendarDays, Briefcase, Globe } from 'lucide-react' import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react'
const ADDON_ICONS = { CalendarDays, Briefcase, Globe } const ADDON_ICONS = { CalendarDays, Briefcase, Globe }
@@ -17,7 +18,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
const [userMenuOpen, setUserMenuOpen] = useState(false) const [userMenuOpen, setUserMenuOpen] = useState(false)
const [appVersion, setAppVersion] = useState(null) const [appVersion, setAppVersion] = useState(null)
const [globalAddons, setGlobalAddons] = useState([]) const [globalAddons, setGlobalAddons] = useState([])
const dark = settings.dark_mode const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const loadAddons = () => { const loadAddons = () => {
if (user) { if (user) {
@@ -45,8 +47,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
navigate('/login') navigate('/login')
} }
const toggleDark = () => { const toggleDarkMode = () => {
updateSetting('dark_mode', !dark).catch(() => {}) updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
} }
return ( return (
@@ -56,7 +58,9 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`, borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
boxShadow: dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)', boxShadow: dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)',
touchAction: 'manipulation', touchAction: 'manipulation',
}} className="h-14 flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]"> paddingTop: 'env(safe-area-inset-top, 0px)',
height: 'var(--nav-h)',
}} className="flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
{/* Left side */} {/* Left side */}
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
{showBack && ( {showBack && (
@@ -70,10 +74,9 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
</button> </button>
)} )}
<Link to="/dashboard" className="flex items-center gap-2 transition-colors flex-shrink-0" <Link to="/dashboard" className="flex items-center transition-colors flex-shrink-0">
style={{ color: 'var(--text-primary)' }}> <img src={dark ? '/icons/icon-white.svg' : '/icons/icon-dark.svg'} alt="NOMAD" className="sm:hidden" style={{ height: 22, width: 22 }} />
<Plane className="w-5 h-5" style={{ color: 'var(--text-primary)' }} /> <img src={dark ? '/logo-light.svg' : '/logo-dark.svg'} alt="NOMAD" className="hidden sm:block" style={{ height: 28 }} />
<span className="font-bold text-sm hidden sm:inline">NOMAD</span>
</Link> </Link>
{/* Global addon nav items */} {/* Global addon nav items */}
@@ -137,8 +140,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
</button> </button>
)} )}
{/* Dark mode toggle */} {/* Dark mode toggle (light ↔ dark, overrides auto) */}
<button onClick={toggleDark} title={dark ? t('nav.lightMode') : t('nav.darkMode')} <button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
className="p-2 rounded-lg transition-colors flex-shrink-0" className="p-2 rounded-lg transition-colors flex-shrink-0"
style={{ color: 'var(--text-muted)' }} style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
@@ -167,11 +170,10 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-faint)' }} /> <ChevronDown className="w-4 h-4" style={{ color: 'var(--text-faint)' }} />
</button> </button>
{userMenuOpen && ( {userMenuOpen && ReactDOM.createPortal(
<> <>
<div className="fixed inset-0 z-10" onClick={() => setUserMenuOpen(false)} /> <div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setUserMenuOpen(false)} />
<div className="absolute right-0 top-full mt-2 w-52 rounded-xl shadow-xl border z-20 overflow-hidden" <div className="w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}> <div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p> <p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p> <p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
@@ -211,13 +213,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
{t('nav.logout')} {t('nav.logout')}
</button> </button>
{appVersion && ( {appVersion && (
<div className="px-4 py-1.5 text-center" style={{ fontSize: 10, color: 'var(--text-faint)' }}> <div className="px-4 pt-2 pb-2.5 text-center" style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
NOMAD v{appVersion} <div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="NOMAD" style={{ height: 10, opacity: 0.5 }} />
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
</div>
</div> </div>
)} )}
</div> </div>
</div> </div>
</> </>,
document.body
)} )}
</div> </div>
)} )}
+56 -26
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState, useMemo } from 'react'
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet' import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster' import MarkerClusterGroup from 'react-leaflet-cluster'
import L from 'leaflet' import L from 'leaflet'
@@ -19,7 +19,12 @@ L.Icon.Default.mergeOptions({
* Create a round photo-circle marker. * Create a round photo-circle marker.
* Shows image_url if available, otherwise category icon in colored circle. * Shows image_url if available, otherwise category icon in colored circle.
*/ */
function createPlaceIcon(place, orderNumber, isSelected) { function escAttr(s) {
if (!s) return ''
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function createPlaceIcon(place, orderNumbers, isSelected) {
const size = isSelected ? 44 : 36 const size = isSelected ? 44 : 36
const borderColor = isSelected ? '#111827' : 'white' const borderColor = isSelected ? '#111827' : 'white'
const borderWidth = isSelected ? 3 : 2.5 const borderWidth = isSelected ? 3 : 2.5
@@ -29,20 +34,23 @@ function createPlaceIcon(place, orderNumber, isSelected) {
const bgColor = place.category_color || '#6b7280' const bgColor = place.category_color || '#6b7280'
const icon = place.category_icon || '📍' const icon = place.category_icon || '📍'
// White semi-transparent number badge (bottom-right), only when orderNumber is set // Number badges (bottom-right), supports multiple numbers for duplicate places
const badgeHtml = orderNumber != null ? ` let badgeHtml = ''
<span style=" if (orderNumbers && orderNumbers.length > 0) {
position:absolute;bottom:-3px;right:-3px; const label = orderNumbers.join(' · ')
min-width:18px;height:18px;border-radius:9px; badgeHtml = `<span style="
padding:0 3px; position:absolute;bottom:-4px;right:-4px;
background:rgba(255,255,255,0.92); min-width:18px;height:${orderNumbers.length > 1 ? 16 : 18}px;border-radius:${orderNumbers.length > 1 ? 8 : 9}px;
border:1.5px solid rgba(0,0,0,0.18); padding:0 ${orderNumbers.length > 1 ? 4 : 3}px;
background:rgba(255,255,255,0.94);
border:1.5px solid rgba(0,0,0,0.15);
box-shadow:0 1px 4px rgba(0,0,0,0.18); box-shadow:0 1px 4px rgba(0,0,0,0.18);
display:flex;align-items:center;justify-content:center; display:flex;align-items:center;justify-content:center;
font-size:9px;font-weight:800;color:#111827; font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827;
font-family:-apple-system,system-ui,sans-serif;line-height:1; font-family:-apple-system,system-ui,sans-serif;line-height:1;
box-sizing:border-box; box-sizing:border-box;white-space:nowrap;
">${orderNumber}</span>` : '' ">${label}</span>`
}
if (place.image_url) { if (place.image_url) {
return L.divIcon({ return L.divIcon({
@@ -55,7 +63,7 @@ function createPlaceIcon(place, orderNumber, isSelected) {
cursor:pointer;flex-shrink:0;position:relative; cursor:pointer;flex-shrink:0;position:relative;
"> ">
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;"> <div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
<img src="${place.image_url}" style="width:100%;height:100%;object-fit:cover;" /> <img src="${escAttr(place.image_url)}" style="width:100%;height:100%;object-fit:cover;" />
</div> </div>
${badgeHtml} ${badgeHtml}
</div>`, </div>`,
@@ -84,19 +92,26 @@ function createPlaceIcon(place, orderNumber, isSelected) {
}) })
} }
function SelectionController({ places, selectedPlaceId }) { function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }) {
const map = useMap() const map = useMap()
const prev = useRef(null) const prev = useRef(null)
useEffect(() => { useEffect(() => {
if (selectedPlaceId && selectedPlaceId !== prev.current) { if (selectedPlaceId && selectedPlaceId !== prev.current) {
const place = places.find(p => p.id === selectedPlaceId) // Fit all day places into view (so you see context), but ensure selected is visible
if (place?.lat && place?.lng) { const toFit = dayPlaces.length > 0 ? dayPlaces : places.filter(p => p.id === selectedPlaceId)
map.setView([place.lat, place.lng], Math.max(map.getZoom(), 15), { animate: true, duration: 0.5 }) const withCoords = toFit.filter(p => p.lat && p.lng)
if (withCoords.length > 0) {
try {
const bounds = L.latLngBounds(withCoords.map(p => [p.lat, p.lng]))
if (bounds.isValid()) {
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
}
} catch {}
} }
} }
prev.current = selectedPlaceId prev.current = selectedPlaceId
}, [selectedPlaceId, places, map]) }, [selectedPlaceId, places, dayPlaces, paddingOpts, map])
return null return null
} }
@@ -116,7 +131,7 @@ function MapController({ center, zoom }) {
} }
// Fit bounds when places change (fitKey triggers re-fit) // Fit bounds when places change (fitKey triggers re-fit)
function BoundsController({ places, fitKey }) { function BoundsController({ places, fitKey, paddingOpts }) {
const map = useMap() const map = useMap()
const prevFitKey = useRef(-1) const prevFitKey = useRef(-1)
@@ -126,9 +141,9 @@ function BoundsController({ places, fitKey }) {
if (places.length === 0) return if (places.length === 0) return
try { try {
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng])) const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
if (bounds.isValid()) map.fitBounds(bounds, { padding: [60, 60], maxZoom: 15, animate: true }) if (bounds.isValid()) map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
} catch {} } catch {}
}, [fitKey, places, map]) }, [fitKey, places, paddingOpts, map])
return null return null
} }
@@ -148,6 +163,7 @@ const mapPhotoCache = new Map()
export function MapView({ export function MapView({
places = [], places = [],
dayPlaces = [],
route = null, route = null,
selectedPlaceId = null, selectedPlaceId = null,
onMarkerClick, onMarkerClick,
@@ -157,7 +173,20 @@ export function MapView({
tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
fitKey = 0, fitKey = 0,
dayOrderMap = {}, dayOrderMap = {},
leftWidth = 0,
rightWidth = 0,
hasInspector = false,
}) { }) {
// Dynamic padding: account for sidebars + bottom inspector
const paddingOpts = useMemo(() => {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
if (isMobile) return { padding: [40, 20] }
const top = 60
const bottom = hasInspector ? 320 : 60
const left = leftWidth + 40
const right = rightWidth + 40
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
}, [leftWidth, rightWidth, hasInspector])
const [photoUrls, setPhotoUrls] = useState({}) const [photoUrls, setPhotoUrls] = useState({})
// Fetch Google photos for places that have google_place_id but no image_url // Fetch Google photos for places that have google_place_id but no image_url
@@ -195,8 +224,8 @@ export function MapView({
/> />
<MapController center={center} zoom={zoom} /> <MapController center={center} zoom={zoom} />
<BoundsController places={places} fitKey={fitKey} /> <BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} /> <SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} /> <MapClickHandler onClick={onMapClick} />
<MarkerClusterGroup <MarkerClusterGroup
@@ -206,6 +235,7 @@ export function MapView({
spiderfyOnMaxZoom spiderfyOnMaxZoom
showCoverageOnHover={false} showCoverageOnHover={false}
zoomToBoundsOnClick zoomToBoundsOnClick
singleMarkerMode
iconCreateFunction={(cluster) => { iconCreateFunction={(cluster) => {
const count = cluster.getChildCount() const count = cluster.getChildCount()
const size = count < 10 ? 36 : count < 50 ? 42 : 48 const size = count < 10 ? 36 : count < 50 ? 42 : 48
@@ -222,8 +252,8 @@ export function MapView({
{places.map((place) => { {places.map((place) => {
const isSelected = place.id === selectedPlaceId const isSelected = place.id === selectedPlaceId
const resolvedPhotoUrl = place.image_url || (place.google_place_id && photoUrls[place.google_place_id]) || null const resolvedPhotoUrl = place.image_url || (place.google_place_id && photoUrls[place.google_place_id]) || null
const orderNumber = dayOrderMap[place.id] ?? null const orderNumbers = dayOrderMap[place.id] ?? null
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumber, isSelected) const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected)
return ( return (
<Marker <Marker
+15 -12
View File
@@ -9,7 +9,7 @@ const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
*/ */
export async function calculateRoute(waypoints, profile = 'driving') { export async function calculateRoute(waypoints, profile = 'driving') {
if (!waypoints || waypoints.length < 2) { if (!waypoints || waypoints.length < 2) {
throw new Error('Mindestens 2 Wegpunkte erforderlich') throw new Error('At least 2 waypoints required')
} }
const coords = waypoints.map(p => `${p.lng},${p.lat}`).join(';') const coords = waypoints.map(p => `${p.lng},${p.lat}`).join(';')
@@ -18,13 +18,13 @@ export async function calculateRoute(waypoints, profile = 'driving') {
const response = await fetch(url) const response = await fetch(url)
if (!response.ok) { if (!response.ok) {
throw new Error('Route konnte nicht berechnet werden') throw new Error('Route could not be calculated')
} }
const data = await response.json() const data = await response.json()
if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) { if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
throw new Error('Keine Route gefunden') throw new Error('No route found')
} }
const route = data.routes[0] const route = data.routes[0]
@@ -74,20 +74,23 @@ export function optimizeRoute(places) {
const visited = new Set() const visited = new Set()
const result = [] const result = []
let current = valid[0] let current = valid[0]
visited.add(current.id) visited.add(0)
result.push(current) result.push(current)
while (result.length < valid.length) { while (result.length < valid.length) {
let nearest = null let nearestIdx = -1
let minDist = Infinity let minDist = Infinity
for (const place of valid) { for (let i = 0; i < valid.length; i++) {
if (visited.has(place.id)) continue if (visited.has(i)) continue
const d = Math.sqrt( const d = Math.sqrt(
Math.pow(place.lat - current.lat, 2) + Math.pow(place.lng - current.lng, 2) Math.pow(valid[i].lat - current.lat, 2) + Math.pow(valid[i].lng - current.lng, 2)
) )
if (d < minDist) { minDist = d; nearest = place } if (d < minDist) { minDist = d; nearestIdx = i }
} }
if (nearest) { visited.add(nearest.id); result.push(nearest); current = nearest } if (nearestIdx === -1) break
visited.add(nearestIdx)
current = valid[nearestIdx]
result.push(current)
} }
return result return result
} }
@@ -103,7 +106,7 @@ function formatDuration(seconds) {
const h = Math.floor(seconds / 3600) const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60) const m = Math.floor((seconds % 3600) / 60)
if (h > 0) { if (h > 0) {
return `${h} Std. ${m} Min.` return `${h} h ${m} min`
} }
return `${m} Min.` return `${m} min`
} }
+37 -16
View File
@@ -1,8 +1,16 @@
// Trip PDF via browser print window // Trip PDF via browser print window
import { createElement } from 'react' import { createElement } from 'react'
import { getCategoryIcon } from '../shared/categoryIcons' import { getCategoryIcon } from '../shared/categoryIcons'
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark } from 'lucide-react'
import { mapsApi } from '../../api/client' import { mapsApi } from '../../api/client'
const NOTE_ICON_MAP = { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark }
function noteIconSvg(iconId) {
if (!_renderToStaticMarkup) return ''
const Icon = NOTE_ICON_MAP[iconId] || FileText
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' }))
}
// ── SVG inline icons (for chips) ───────────────────────────────────────────── // ── SVG inline icons (for chips) ─────────────────────────────────────────────
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>` const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>` const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
@@ -104,7 +112,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const cost = dayCost(assignments, day.id, loc) const cost = dayCost(assignments, day.id, loc)
const merged = [] const merged = []
assigned.forEach(a => merged.push({ type: 'place', k: a.sort_order ?? 0, data: a })) assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n })) notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
merged.sort((a, b) => a.k - b.k) merged.sort((a, b) => a.k - b.k)
@@ -117,12 +125,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
return ` return `
<div class="note-card"> <div class="note-card">
<div class="note-line"></div> <div class="note-line"></div>
<svg class="note-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.8" stroke-linecap="round"> <span class="note-icon">${noteIconSvg(note.icon)}</span>
<rect x="4" y="3" width="16" height="18" rx="2"/>
<line x1="8" y1="8" x2="16" y2="8"/>
<line x1="8" y1="12" x2="16" y2="12"/>
<line x1="8" y1="16" x2="13" y2="16"/>
</svg>
<div class="note-body"> <div class="note-body">
<div class="note-text">${escHtml(note.text)}</div> <div class="note-text">${escHtml(note.text)}</div>
${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''} ${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''}
@@ -141,9 +144,6 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const googleImg = photoMap[place.id] || null const googleImg = photoMap[place.id] || null
const img = directImg || googleImg const img = directImg || googleImg
const confirmed = place.reservation_status === 'confirmed'
const pending = place.reservation_status === 'pending'
const iconSvg = categoryIconSvg(cat?.icon, color, 24) const iconSvg = categoryIconSvg(cat?.icon, color, 24)
const thumbHtml = img const thumbHtml = img
? `<img class="place-thumb" src="${escHtml(img)}" />` ? `<img class="place-thumb" src="${escHtml(img)}" />`
@@ -154,8 +154,6 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const chips = [ const chips = [
place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '', place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '',
place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString('de-DE')} EUR</span>` : '', place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString('de-DE')} EUR</span>` : '',
confirmed ? `<span class="chip chip-green">${svgCheck}${escHtml(tr('reservations.confirmed'))}</span>` : '',
pending ? `<span class="chip chip-amber">${svgClock2}${escHtml(tr('reservations.pending'))}</span>` : '',
].filter(Boolean).join('') ].filter(Boolean).join('')
return ` return `
@@ -200,6 +198,24 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
body { font-family: 'Poppins', sans-serif; background: #fff; color: #1e293b; -webkit-print-color-adjust: exact; print-color-adjust: exact; } body { font-family: 'Poppins', sans-serif; background: #fff; color: #1e293b; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
svg { -webkit-print-color-adjust: exact; print-color-adjust: exact; } svg { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
/* Footer on every printed page */
.pdf-footer {
position: fixed;
bottom: 20px;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
opacity: 0.3;
}
.pdf-footer span {
font-size: 7px;
color: #64748b;
letter-spacing: 0.5px;
}
/* ── Cover ─────────────────────────────────────── */ /* ── Cover ─────────────────────────────────────── */
.cover { .cover {
width: 100%; min-height: 100vh; width: 100%; min-height: 100vh;
@@ -215,8 +231,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
.cover-dim { position: absolute; inset: 0; background: rgba(8,12,28,0.55); } .cover-dim { position: absolute; inset: 0; background: rgba(8,12,28,0.55); }
.cover-brand { .cover-brand {
position: absolute; top: 36px; right: 52px; position: absolute; top: 36px; right: 52px;
font-size: 9px; font-weight: 600; letter-spacing: 2.5px; z-index: 2;
color: rgba(255,255,255,0.3); text-transform: uppercase;
} }
.cover-body { position: relative; z-index: 1; } .cover-body { position: relative; z-index: 1; }
.cover-circle { .cover-circle {
@@ -316,17 +331,23 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
</head> </head>
<body> <body>
<!-- Footer on every page -->
<div class="pdf-footer">
<span>made with</span>
<img src="${absUrl('/logo-dark.svg')}" style="height:10px;opacity:0.6;" />
</div>
<!-- Cover --> <!-- Cover -->
<div class="cover"> <div class="cover">
${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(coverImg)}')"></div>` : ''} ${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(coverImg)}')"></div>` : ''}
<div class="cover-dim"></div> <div class="cover-dim"></div>
<div class="cover-brand">NOMAD</div> <div class="cover-brand"><img src="${absUrl('/logo-light.svg')}" style="height:28px;opacity:0.5;" /></div>
<div class="cover-body"> <div class="cover-body">
${coverImg ${coverImg
? `<div class="cover-circle"><img src="${escHtml(coverImg)}" /></div>` ? `<div class="cover-circle"><img src="${escHtml(coverImg)}" /></div>`
: `<div class="cover-circle-ph"></div>`} : `<div class="cover-circle-ph"></div>`}
<div class="cover-label">${escHtml(tr('pdf.travelPlan'))}</div> <div class="cover-label">${escHtml(tr('pdf.travelPlan'))}</div>
<div class="cover-title">${escHtml(trip?.title || 'Meine Reise')}</div> <div class="cover-title">${escHtml(trip?.title || 'My Trip')}</div>
${trip?.description ? `<div class="cover-desc">${escHtml(trip.description)}</div>` : ''} ${trip?.description ? `<div class="cover-desc">${escHtml(trip.description)}</div>` : ''}
${range ? `<div class="cover-dates">${range}</div>` : ''} ${range ? `<div class="cover-dates">${range}</div>` : ''}
<div class="cover-line"></div> <div class="cover-line"></div>
@@ -8,36 +8,36 @@ import {
} from 'lucide-react' } from 'lucide-react'
const VORSCHLAEGE = [ const VORSCHLAEGE = [
{ name: 'Reisepass', kategorie: 'Dokumente' }, { name: 'Passport', category: 'Documents' },
{ name: 'Reiseversicherung', kategorie: 'Dokumente' }, { name: 'Travel Insurance', category: 'Documents' },
{ name: 'Visum-Unterlagen', kategorie: 'Dokumente' }, { name: 'Visa Documents', category: 'Documents' },
{ name: 'Flugtickets', kategorie: 'Dokumente' }, { name: 'Flight Tickets', category: 'Documents' },
{ name: 'Hotelbuchungen', kategorie: 'Dokumente' }, { name: 'Hotel Bookings', category: 'Documents' },
{ name: 'Impfpass', kategorie: 'Dokumente' }, { name: 'Vaccination Card', category: 'Documents' },
{ name: 'T-Shirts (5×)', kategorie: 'Kleidung' }, { name: 'T-Shirts (5x)', category: 'Clothing' },
{ name: 'Hosen (2×)', kategorie: 'Kleidung' }, { name: 'Pants (2x)', category: 'Clothing' },
{ name: 'Unterwäsche (7×)', kategorie: 'Kleidung' }, { name: 'Underwear (7x)', category: 'Clothing' },
{ name: 'Socken (7×)', kategorie: 'Kleidung' }, { name: 'Socks (7x)', category: 'Clothing' },
{ name: 'Jacke', kategorie: 'Kleidung' }, { name: 'Jacket', category: 'Clothing' },
{ name: 'Badeanzug / Badehose', kategorie: 'Kleidung' }, { name: 'Swimwear', category: 'Clothing' },
{ name: 'Sportschuhe', kategorie: 'Kleidung' }, { name: 'Sport Shoes', category: 'Clothing' },
{ name: 'Zahnbürste', kategorie: 'Körperpflege' }, { name: 'Toothbrush', category: 'Toiletries' },
{ name: 'Zahnpasta', kategorie: 'Körperpflege' }, { name: 'Toothpaste', category: 'Toiletries' },
{ name: 'Shampoo', kategorie: 'Körperpflege' }, { name: 'Shampoo', category: 'Toiletries' },
{ name: 'Sonnencreme', kategorie: 'Körperpflege' }, { name: 'Sunscreen', category: 'Toiletries' },
{ name: 'Deo', kategorie: 'Körperpflege' }, { name: 'Deodorant', category: 'Toiletries' },
{ name: 'Rasierer', kategorie: 'Körperpflege' }, { name: 'Razor', category: 'Toiletries' },
{ name: 'Ladekabel Handy', kategorie: 'Elektronik' }, { name: 'Phone Charger', category: 'Electronics' },
{ name: 'Reiseadapter', kategorie: 'Elektronik' }, { name: 'Travel Adapter', category: 'Electronics' },
{ name: 'Kopfhörer', kategorie: 'Elektronik' }, { name: 'Headphones', category: 'Electronics' },
{ name: 'Kamera', kategorie: 'Elektronik' }, { name: 'Camera', category: 'Electronics' },
{ name: 'Powerbank', kategorie: 'Elektronik' }, { name: 'Power Bank', category: 'Electronics' },
{ name: 'Erste-Hilfe-Set', kategorie: 'Gesundheit' }, { name: 'First Aid Kit', category: 'Health' },
{ name: 'Verschreibungspflichtige Medikamente', kategorie: 'Gesundheit' }, { name: 'Prescription Medication', category: 'Health' },
{ name: 'Schmerzmittel', kategorie: 'Gesundheit' }, { name: 'Pain Medication', category: 'Health' },
{ name: 'Mückenschutz', kategorie: 'Gesundheit' }, { name: 'Insect Repellent', category: 'Health' },
{ name: 'Bargeld', kategorie: 'Finanzen' }, { name: 'Cash', category: 'Finances' },
{ name: 'Kreditkarte', kategorie: 'Finanzen' }, { name: 'Credit Card', category: 'Finances' },
] ]
// Cycling color palette — works in light & dark mode // Cycling color palette — works in light & dark mode
@@ -3,8 +3,10 @@ import { PhotoLightbox } from './PhotoLightbox'
import { PhotoUpload } from './PhotoUpload' import { PhotoUpload } from './PhotoUpload'
import { Upload, Camera } from 'lucide-react' import { Upload, Camera } from 'lucide-react'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import { useTranslation } from '../../i18n'
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }) { export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }) {
const { t } = useTranslation()
const [lightboxIndex, setLightboxIndex] = useState(null) const [lightboxIndex, setLightboxIndex] = useState(null)
const [showUpload, setShowUpload] = useState(false) const [showUpload, setShowUpload] = useState(false)
const [filterDayId, setFilterDayId] = useState('') const [filterDayId, setFilterDayId] = useState('')
@@ -49,7 +51,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
onChange={e => setFilterDayId(e.target.value)} onChange={e => setFilterDayId(e.target.value)}
className="border border-gray-200 rounded-lg px-3 py-1.5 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-slate-900" className="border border-gray-200 rounded-lg px-3 py-1.5 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-slate-900"
> >
<option value="">Alle Tage</option> <option value="">{t('photos.allDays')}</option>
{(days || []).map(day => ( {(days || []).map(day => (
<option key={day.id} value={day.id}> <option key={day.id} value={day.id}>
Tag {day.day_number}{day.date ? ` · ${formatDate(day.date)}` : ''} Tag {day.day_number}{day.date ? ` · ${formatDate(day.date)}` : ''}
@@ -62,7 +64,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
onClick={() => setFilterDayId('')} onClick={() => setFilterDayId('')}
className="text-xs text-gray-500 hover:text-gray-700 underline" className="text-xs text-gray-500 hover:text-gray-700 underline"
> >
Zurücksetzen {t('common.reset')}
</button> </button>
)} )}
@@ -80,8 +82,8 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
{filteredPhotos.length === 0 ? ( {filteredPhotos.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: '#9ca3af' }}> <div style={{ textAlign: 'center', padding: '60px 20px', color: '#9ca3af' }}>
<Camera size={40} style={{ color: '#d1d5db', display: 'block', margin: '0 auto 12px' }} /> <Camera size={40} style={{ color: '#d1d5db', display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: '#374151', margin: '0 0 4px' }}>Noch keine Fotos</p> <p style={{ fontSize: 14, fontWeight: 600, color: '#374151', margin: '0 0 4px' }}>{t('photos.noPhotos')}</p>
<p style={{ fontSize: 13, color: '#9ca3af', margin: '0 0 20px' }}>Lade deine Reisefotos hoch</p> <p style={{ fontSize: 13, color: '#9ca3af', margin: '0 0 20px' }}>{t('photos.uploadHint')}</p>
<button <button
onClick={() => setShowUpload(true)} onClick={() => setShowUpload(true)}
className="flex items-center gap-2 bg-slate-900 text-white px-6 py-3 rounded-xl hover:bg-slate-700 font-medium" className="flex items-center gap-2 bg-slate-900 text-white px-6 py-3 rounded-xl hover:bg-slate-700 font-medium"
@@ -109,7 +111,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
className="aspect-square rounded-xl border-2 border-dashed border-gray-200 hover:border-slate-400 flex flex-col items-center justify-center gap-2 text-gray-400 hover:text-slate-700 transition-colors" className="aspect-square rounded-xl border-2 border-dashed border-gray-200 hover:border-slate-400 flex flex-col items-center justify-center gap-2 text-gray-400 hover:text-slate-700 transition-colors"
> >
<Upload className="w-6 h-6" /> <Upload className="w-6 h-6" />
<span className="text-xs">Hinzufügen</span> <span className="text-xs">{t('common.add')}</span>
</button> </button>
</div> </div>
)} )}
@@ -1,7 +1,9 @@
import React, { useState, useEffect, useCallback } from 'react' import React, { useState, useEffect, useCallback } from 'react'
import { X, ChevronLeft, ChevronRight, Edit2, Trash2, Check } from 'lucide-react' import { X, ChevronLeft, ChevronRight, Edit2, Trash2, Check } from 'lucide-react'
import { useTranslation } from '../../i18n'
export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelete, days, places, tripId }) { export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelete, days, places, tripId }) {
const { t } = useTranslation()
const [index, setIndex] = useState(initialIndex || 0) const [index, setIndex] = useState(initialIndex || 0)
const [editCaption, setEditCaption] = useState(false) const [editCaption, setEditCaption] = useState(false)
const [caption, setCaption] = useState('') const [caption, setCaption] = useState('')
@@ -81,7 +83,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
<button <button
onClick={handleDelete} onClick={handleDelete}
className="p-2 text-white/60 hover:text-red-400 hover:bg-white/10 rounded-lg transition-colors" className="p-2 text-white/60 hover:text-red-400 hover:bg-white/10 rounded-lg transition-colors"
title="Löschen" title={t('common.delete')}
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
+7 -5
View File
@@ -1,8 +1,10 @@
import React, { useState, useCallback } from 'react' import React, { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone' import { useDropzone } from 'react-dropzone'
import { Upload, X, Image } from 'lucide-react' import { Upload, X, Image } from 'lucide-react'
import { useTranslation } from '../../i18n'
export function PhotoUpload({ tripId, days, places, onUpload, onClose }) { export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
const { t } = useTranslation()
const [files, setFiles] = useState([]) const [files, setFiles] = useState([])
const [dayId, setDayId] = useState('') const [dayId, setDayId] = useState('')
const [placeId, setPlaceId] = useState('') const [placeId, setPlaceId] = useState('')
@@ -78,7 +80,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
) : ( ) : (
<> <>
<p className="text-gray-600 font-medium">Fotos hier ablegen</p> <p className="text-gray-600 font-medium">Fotos hier ablegen</p>
<p className="text-gray-400 text-sm mt-1">oder klicken zum Auswählen</p> <p className="text-gray-400 text-sm mt-1">{t('photos.clickToSelect')}</p>
<p className="text-gray-400 text-xs mt-2">JPG, PNG, WebP · max. 10 MB · bis zu 30 Fotos</p> <p className="text-gray-400 text-xs mt-2">JPG, PNG, WebP · max. 10 MB · bis zu 30 Fotos</p>
</> </>
)} )}
@@ -128,13 +130,13 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
</select> </select>
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-gray-700 mb-1">Ort verknüpfen</label> <label className="block text-xs font-medium text-gray-700 mb-1">{t('photos.linkPlace')}</label>
<select <select
value={placeId} value={placeId}
onChange={e => setPlaceId(e.target.value)} onChange={e => setPlaceId(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900" className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
> >
<option value="">Kein Ort</option> <option value="">{t('photos.noPlace')}</option>
{(places || []).map(place => ( {(places || []).map(place => (
<option key={place.id} value={place.id}>{place.name}</option> <option key={place.id} value={place.id}>{place.name}</option>
))} ))}
@@ -175,7 +177,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50" className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50"
> >
Abbrechen {t('common.cancel')}
</button> </button>
<button <button
onClick={handleUpload} onClick={handleUpload}
@@ -183,7 +185,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
className="flex items-center gap-2 px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium" className="flex items-center gap-2 px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
> >
<Upload className="w-4 h-4" /> <Upload className="w-4 h-4" />
{uploading ? 'Hochladen...' : `${files.length} Foto${files.length !== 1 ? 's' : ''} hochladen`} {uploading ? t('common.uploading') : t('photos.uploadN', { n: files.length })}
</button> </button>
</div> </div>
</div> </div>
@@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import { useSortable } from '@dnd-kit/sortable' import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { GripVertical, X, Edit2, Clock, DollarSign, CheckCircle, Clock3, MapPin } from 'lucide-react' import { GripVertical, X, Edit2, Clock, DollarSign, MapPin } from 'lucide-react'
export default function AssignedPlaceItem({ assignment, dayId, onRemove, onEdit }) { export default function AssignedPlaceItem({ assignment, dayId, onRemove, onEdit }) {
const { place } = assignment const { place } = assignment
@@ -27,16 +27,6 @@ export default function AssignedPlaceItem({ assignment, dayId, onRemove, onEdit
transition, transition,
} }
const reservationIcon = () => {
if (place.reservation_status === 'confirmed') {
return <CheckCircle className="w-3.5 h-3.5 text-emerald-500" title="Confirmed" />
}
if (place.reservation_status === 'pending') {
return <Clock3 className="w-3.5 h-3.5 text-amber-500" title="Pending" />
}
return null
}
return ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
@@ -71,7 +61,6 @@ export default function AssignedPlaceItem({ assignment, dayId, onRemove, onEdit
/> />
)} )}
<span className="text-sm font-medium text-slate-800 truncate">{place.name}</span> <span className="text-sm font-medium text-slate-800 truncate">{place.name}</span>
{reservationIcon()}
</div> </div>
{/* Time & price row */} {/* Time & price row */}
@@ -0,0 +1,537 @@
import React, { useState, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind, Droplets, Sunrise, Sunset, Hotel, Calendar, MapPin, LogIn, LogOut, Hash, Pencil, Plane, Utensils, Train, Car, Ship, Ticket, FileText, Users } from 'lucide-react'
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
import { weatherApi, accommodationsApi } from '../../api/client'
import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
const WEATHER_ICON_MAP = {
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
Thunderstorm: CloudLightning, Snow: CloudSnow, Mist: Wind, Fog: Wind, Haze: Wind,
}
function WIcon({ main, size = 14 }) {
const Icon = WEATHER_ICON_MAP[main] || Cloud
return <Icon size={size} strokeWidth={1.8} />
}
function cTemp(c, f) { return Math.round(f ? c * 9 / 5 + 32 : c) }
function formatTime12(val, is12h) {
if (!val) return val
const [h, m] = val.split(':').map(Number)
if (isNaN(h) || isNaN(m)) return val
if (!is12h) return val
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
return `${h12}:${String(m).padStart(2, '0')} ${period}`
}
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange }) {
const { t, language } = useTranslation()
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const fmtTime = (v) => formatTime12(v, is12h)
const unit = isFahrenheit ? '°F' : '°C'
const [weather, setWeather] = useState(null)
const [loading, setLoading] = useState(false)
const [accommodation, setAccommodation] = useState(null)
const [accommodations, setAccommodations] = useState([])
const [showHotelPicker, setShowHotelPicker] = useState(false)
const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id })
const [hotelCategoryFilter, setHotelCategoryFilter] = useState('')
const [hotelForm, setHotelForm] = useState({ check_in: '', check_out: '', confirmation: '' })
useEffect(() => {
if (!day?.date || !lat || !lng) { setWeather(null); return }
setLoading(true)
weatherApi.getDetailed(lat, lng, day.date, language)
.then(data => setWeather(data.error ? null : data))
.catch(() => setWeather(null))
.finally(() => setLoading(false))
}, [day?.date, lat, lng, language])
useEffect(() => {
if (!tripId) return
accommodationsApi.list(tripId)
.then(data => {
setAccommodations(data.accommodations || [])
const acc = (data.accommodations || []).find(a =>
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
)
setAccommodation(acc || null)
})
.catch(() => {})
}, [tripId, day?.id])
useEffect(() => { if (day) setHotelDayRange({ start: day.id, end: day.id }) }, [day?.id])
const handleSetAccommodation = async (placeId) => {
try {
const data = await accommodationsApi.create(tripId, {
place_id: placeId,
start_day_id: hotelDayRange.start,
end_day_id: hotelDayRange.end,
check_in: hotelForm.check_in || null,
check_out: hotelForm.check_out || null,
confirmation: hotelForm.confirmation || null,
})
setAccommodation(data.accommodation)
setAccommodations(prev => [...prev, data.accommodation])
setShowHotelPicker(false)
setHotelForm({ check_in: '', check_out: '', confirmation: '' })
onAccommodationChange?.()
} catch {}
}
const updateAccommodationField = async (field, value) => {
if (!accommodation) return
try {
const data = await accommodationsApi.update(tripId, accommodation.id, { [field]: value || null })
setAccommodation(data.accommodation)
onAccommodationChange?.()
} catch {}
}
const handleRemoveAccommodation = async () => {
if (!accommodation) return
try {
await accommodationsApi.delete(tripId, accommodation.id)
setAccommodations(prev => prev.filter(a => a.id !== accommodation.id))
setAccommodation(null)
onAccommodationChange?.()
} catch {}
}
if (!day) return null
const formattedDate = day.date ? new Date(day.date + 'T00:00:00').toLocaleDateString(
language === 'de' ? 'de-DE' : 'en-US',
{ weekday: 'long', day: 'numeric', month: 'long' }
) : null
const placesWithCoords = places.filter(p => p.lat && p.lng)
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return (
<div style={{ position: 'fixed', bottom: 20, left: '50%', transform: 'translateX(-50%)', width: 'min(800px, calc(100vw - 32px))', zIndex: 50, ...font }}>
<div style={{
background: 'var(--bg-elevated)',
backdropFilter: 'blur(40px) saturate(180%)',
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
borderRadius: 20,
boxShadow: '0 8px 40px rgba(0,0,0,0.14), 0 0 0 1px rgba(0,0,0,0.06)',
overflow: 'hidden', maxHeight: '60vh', display: 'flex', flexDirection: 'column',
}}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '18px 16px 14px 20px', borderBottom: '1px solid var(--border-faint)' }}>
<div style={{ width: 44, height: 44, borderRadius: 12, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Calendar size={20} style={{ color: 'var(--text-primary)' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
{day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
</div>
{formattedDate && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 1 }}>{formattedDate}</div>}
</div>
<button onClick={onClose} style={{ background: 'var(--bg-secondary)', border: 'none', borderRadius: 10, width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0 }}>
<X size={14} style={{ color: 'var(--text-muted)' }} />
</button>
</div>
{/* Scrollable content */}
<div style={{ overflowY: 'auto', padding: '14px 20px 18px' }}>
{/* ── Weather ── */}
{day.date && lat && lng && (
loading ? (
<div style={{ textAlign: 'center', padding: 16, color: 'var(--text-faint)', fontSize: 12 }}>
<div style={{ width: 18, height: 18, border: '2px solid var(--border-primary)', borderTopColor: 'var(--text-primary)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto 6px' }} />
</div>
) : weather ? (
<div>
{/* Summary row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
<div style={{ width: 40, height: 40, borderRadius: 12, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<WIcon main={weather.main} size={20} />
</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'baseline', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1 }}>
{weather.type === 'climate' ? 'Ø ' : ''}{cTemp(weather.temp, isFahrenheit)}{unit}
</span>
{weather.temp_max != null && (
<span style={{ fontSize: 12, color: 'var(--text-faint)' }}>
{cTemp(weather.temp_min, isFahrenheit)}° / {cTemp(weather.temp_max, isFahrenheit)}°
</span>
)}
{weather.description && (
<span style={{ fontSize: 12, color: 'var(--text-muted)', textTransform: 'capitalize' }}>{weather.description}</span>
)}
</div>
</div>
{/* Chips row */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: weather.hourly ? 10 : 0 }}>
{weather.precipitation_probability_max != null && (
<Chip icon={Droplets} value={`${weather.precipitation_probability_max}%`} />
)}
{weather.precipitation_sum > 0 && (
<Chip icon={CloudRain} value={`${weather.precipitation_sum.toFixed(1)} mm`} />
)}
{weather.wind_max != null && (
<Chip icon={Wind} value={isFahrenheit ? `${Math.round(weather.wind_max * 0.621371)} mph` : `${Math.round(weather.wind_max)} km/h`} />
)}
{weather.sunrise && <Chip icon={Sunrise} value={weather.sunrise} />}
{weather.sunset && <Chip icon={Sunset} value={weather.sunset} />}
</div>
{/* Hourly scroll */}
{weather.hourly?.length > 0 && (
<div style={{ overflowX: 'auto', margin: '0 -6px', padding: '0 6px 4px' }}>
<div style={{ display: 'inline-flex', gap: 2 }}>
{weather.hourly.filter((_, i) => i % 2 === 0).map(h => (
<div key={h.hour} style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3,
width: 44, padding: '5px 2px', borderRadius: 8,
background: h.precipitation_probability > 50 ? 'rgba(59,130,246,0.07)' : 'transparent',
}}>
<span style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500 }}>{String(h.hour).padStart(2, '0')}</span>
<WIcon main={h.main} size={12} />
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-primary)' }}>{cTemp(h.temp, isFahrenheit)}°</span>
{h.precipitation_probability > 0 && (
<span style={{ fontSize: 8, color: '#3b82f6', fontWeight: 500 }}>{h.precipitation_probability}%</span>
)}
</div>
))}
</div>
</div>
)}
{weather.type === 'climate' && (
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 6, fontStyle: 'italic' }}>{t('day.climateHint')}</div>
)}
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--text-faint)', textAlign: 'center', padding: 8 }}>{t('day.noWeather')}</div>
)
)}
{/* Divider */}
{day.date && lat && lng && <div style={{ height: 1, background: 'var(--border-faint)', margin: '12px 0' }} />}
{/* ── Reservations for this day's assignments ── */}
{(() => {
const dayAssignments = assignments[String(day.id)] || []
const dayReservations = reservations.filter(r => dayAssignments.some(a => a.id === r.assignment_id))
if (dayReservations.length === 0) return null
return (
<div style={{ marginBottom: 0 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>{t('day.reservations')}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{dayReservations.map(r => {
const linkedAssignment = dayAssignments.find(a => a.id === r.assignment_id)
const confirmed = r.status === 'confirmed'
return (
<div key={r.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', borderRadius: 8, background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.15)' : 'rgba(217,119,6,0.15)'}` }}>
{(() => { const TIcon = RES_TYPE_ICONS[r.type] || FileText; return <TIcon size={12} style={{ color: RES_TYPE_COLORS[r.type] || 'var(--text-faint)', flexShrink: 0 }} /> })()}
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 6, overflow: 'hidden' }}>
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
{linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
</div>
{r.reservation_time && (
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
{new Date(r.reservation_time).toLocaleTimeString(language === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit', hour12: is12h })}
</span>
)}
</div>
)
})}
</div>
</div>
)
})()}
{/* Divider before accommodation */}
<div style={{ height: 1, background: 'var(--border-faint)', margin: '12px 0' }} />
{/* ── Accommodation ── */}
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>{t('day.accommodation')}</div>
{accommodation ? (
<div style={{ borderRadius: 12, background: 'var(--bg-secondary)', overflow: 'hidden' }}>
{/* Hotel header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px' }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{accommodation.place_image ? (
<img src={accommodation.place_image} style={{ width: '100%', height: '100%', borderRadius: 10, objectFit: 'cover' }} />
) : (
<Hotel size={16} style={{ color: 'var(--text-muted)' }} />
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{accommodation.place_name}</div>
{accommodation.place_address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{accommodation.place_address}</div>}
</div>
<button onClick={handleRemoveAccommodation} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
<X size={12} style={{ color: 'var(--text-faint)' }} />
</button>
</div>
{/* Details row */}
{/* Details grid */}
<div style={{ display: 'flex', gap: 0, margin: '0 12px 10px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}>
{accommodation.check_in && (
<div style={{ flex: 1, padding: '8px 10px', borderRight: '1px solid var(--border-faint)', textAlign: 'center' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{fmtTime(accommodation.check_in)}</div>
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
<LogIn size={8} /> {t('day.checkIn')}
</div>
</div>
)}
{accommodation.check_out && (
<div style={{ flex: 1, padding: '8px 10px', borderRight: accommodation.confirmation ? '1px solid var(--border-faint)' : 'none', textAlign: 'center' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{fmtTime(accommodation.check_out)}</div>
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
<LogOut size={8} /> {t('day.checkOut')}
</div>
</div>
)}
{accommodation.confirmation && (
<div style={{ flex: 1, padding: '8px 10px', textAlign: 'center' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{accommodation.confirmation}</div>
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
<Hash size={8} /> {t('day.confirmation')}
</div>
</div>
)}
<button onClick={() => { setHotelForm({ check_in: accommodation.check_in || '', check_out: accommodation.check_out || '', confirmation: accommodation.confirmation || '' }); setShowHotelPicker('edit') }}
style={{ padding: '0 8px', background: 'none', border: 'none', borderLeft: '1px solid var(--border-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Pencil size={10} style={{ color: 'var(--text-faint)' }} />
</button>
</div>
</div>
) : (
<button onClick={() => setShowHotelPicker(true)} style={{
width: '100%', padding: 10, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit',
}}>
<Hotel size={12} /> {t('day.addAccommodation')}
</button>
)}
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
{showHotelPicker && ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, zIndex: 99999, background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={() => setShowHotelPicker(false)}>
<div onClick={e => e.stopPropagation()} style={{
width: '100%', maxWidth: 900, borderRadius: 16, overflow: 'hidden',
background: 'var(--bg-card)', boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
...font,
}}>
{/* Popup Header */}
<div style={{ padding: '16px 18px 12px', borderBottom: '1px solid var(--border-faint)', display: 'flex', alignItems: 'center', gap: 10 }}>
<Hotel size={16} style={{ color: 'var(--text-primary)' }} />
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', flex: 1 }}>{showHotelPicker === 'edit' ? t('day.editAccommodation') : t('day.addAccommodation')}</span>
<button onClick={() => setShowHotelPicker(false)} style={{ background: 'var(--bg-secondary)', border: 'none', borderRadius: 8, width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
<X size={12} style={{ color: 'var(--text-muted)' }} />
</button>
</div>
{/* Day Range (hidden in edit mode) */}
{showHotelPicker !== 'edit' && <div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border-faint)', background: 'var(--bg-secondary)' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{t('day.hotelDayRange')}</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<CustomSelect
value={hotelDayRange.start}
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
options={days.map((d, i) => ({
value: d.id,
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00').toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short' })}` : ''}`,
}))}
size="sm"
/>
</div>
<span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}></span>
<div style={{ flex: 1, minWidth: 0 }}>
<CustomSelect
value={hotelDayRange.end}
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
options={days.map((d, i) => ({
value: d.id,
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00').toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short' })}` : ''}`,
}))}
size="sm"
/>
</div>
<button onClick={() => setHotelDayRange({ start: days[0]?.id, end: days[days.length - 1]?.id })} style={{
padding: '6px 14px', borderRadius: 8, border: 'none', fontSize: 11, fontWeight: 600, cursor: 'pointer', flexShrink: 0,
background: hotelDayRange.start === days[0]?.id && hotelDayRange.end === days[days.length - 1]?.id ? 'var(--text-primary)' : 'var(--bg-card)',
color: hotelDayRange.start === days[0]?.id && hotelDayRange.end === days[days.length - 1]?.id ? 'var(--bg-card)' : 'var(--text-muted)',
}}>
{t('day.allDays')}
</button>
</div>
</div>}
{/* Check-in / Check-out / Confirmation */}
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: 100 }}>
<label style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.checkIn')}</label>
<CustomTimePicker value={hotelForm.check_in} onChange={v => setHotelForm(f => ({ ...f, check_in: v }))} placeholder="14:00" />
</div>
<div style={{ flex: 1, minWidth: 100 }}>
<label style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.checkOut')}</label>
<CustomTimePicker value={hotelForm.check_out} onChange={v => setHotelForm(f => ({ ...f, check_out: v }))} placeholder="11:00" />
</div>
<div style={{ flex: 2, minWidth: 120 }}>
<label style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.confirmation')}</label>
<input type="text" value={hotelForm.confirmation} onChange={e => setHotelForm(f => ({ ...f, confirmation: e.target.value }))}
placeholder="ABC-12345" style={{ width: '100%', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)', fontSize: 13, fontFamily: 'inherit', boxSizing: 'border-box', height: 38 }} />
</div>
</div>
{/* Edit mode: save button instead of place list */}
{showHotelPicker === 'edit' ? (
<div style={{ padding: '14px 18px', display: 'flex', justifyContent: 'flex-end' }}>
<button onClick={async () => {
await updateAccommodationField('check_in', hotelForm.check_in)
await updateAccommodationField('check_out', hotelForm.check_out)
await updateAccommodationField('confirmation', hotelForm.confirmation)
setShowHotelPicker(false)
}} style={{
padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer',
background: 'var(--text-primary)', color: 'var(--bg-card)',
}}>
{t('common.save')}
</button>
</div>
) : <>
{/* Category Filter */}
{categories.length > 0 && (
<div style={{ padding: '8px 18px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<button onClick={() => setHotelCategoryFilter('')} style={{
padding: '3px 10px', borderRadius: 6, border: 'none', fontSize: 10, fontWeight: 600, cursor: 'pointer',
background: !hotelCategoryFilter ? 'var(--text-primary)' : 'var(--bg-secondary)',
color: !hotelCategoryFilter ? 'var(--bg-card)' : 'var(--text-muted)',
}}>{t('day.allDays')}</button>
{categories.map(c => (
<button key={c.id} onClick={() => setHotelCategoryFilter(c.id)} style={{
padding: '3px 10px', borderRadius: 6, border: 'none', fontSize: 10, fontWeight: 600, cursor: 'pointer',
background: hotelCategoryFilter === c.id ? c.color || 'var(--text-primary)' : 'var(--bg-secondary)',
color: hotelCategoryFilter === c.id ? '#fff' : 'var(--text-muted)',
}}>{c.name}</button>
))}
</div>
)}
{/* Place List */}
<div style={{ maxHeight: 250, overflowY: 'auto' }}>
{(() => {
const filtered = hotelCategoryFilter ? places.filter(p => p.category_id === hotelCategoryFilter) : places
return filtered.length === 0 ? (
<div style={{ padding: 20, textAlign: 'center', fontSize: 12, color: 'var(--text-faint)' }}>{t('day.noPlacesForHotel')}</div>
) : filtered.map(p => (
<button key={p.id} onClick={() => handleSetAccommodation(p.id)} style={{
display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '10px 18px',
border: 'none', borderBottom: '1px solid var(--border-faint)', background: 'none',
cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
transition: 'background 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{p.image_url ? (
<img src={p.image_url} style={{ width: '100%', height: '100%', borderRadius: 8, objectFit: 'cover' }} />
) : (
<MapPin size={13} style={{ color: 'var(--text-faint)' }} />
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</div>
{p.address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.address}</div>}
</div>
</button>
))
})()}
</div>
</>}
</div>
</div>,
document.body
)}
</div>
</div>
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
function Chip({ icon: Icon, value }) {
return (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
<Icon size={11} style={{ flexShrink: 0, opacity: 0.6 }} />
<span style={{ fontWeight: 500 }}>{value}</span>
</div>
)
}
function InfoChip({ icon: Icon, label, value, placeholder, onEdit, type }) {
const [editing, setEditing] = React.useState(false)
const [val, setVal] = React.useState(value || '')
const inputRef = React.useRef(null)
React.useEffect(() => { setVal(value || '') }, [value])
React.useEffect(() => { if (editing && inputRef.current) inputRef.current.focus() }, [editing])
const save = () => {
setEditing(false)
if (val !== (value || '')) onEdit(val)
}
return (
<div
onClick={() => setEditing(true)}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
cursor: 'pointer', minWidth: 0, flex: type === 'text' ? 1 : undefined,
}}
>
<Icon size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 8, color: 'var(--text-faint)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', lineHeight: 1 }}>{label}</div>
{editing ? (
<input
ref={inputRef}
type={type}
value={val}
onChange={e => setVal(e.target.value)}
onBlur={save}
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setVal(value || ''); setEditing(false) } }}
onClick={e => e.stopPropagation()}
style={{
border: 'none', outline: 'none', background: 'none', padding: 0, margin: 0,
fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', fontFamily: 'inherit',
width: type === 'time' ? 50 : '100%', lineHeight: 1.3,
}}
/>
) : (
<div style={{ fontSize: 11, fontWeight: 600, color: value ? 'var(--text-primary)' : 'var(--text-faint)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{value || placeholder}
</div>
)}
</div>
</div>
)
}
+163 -75
View File
@@ -1,6 +1,8 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, AlertCircle, CheckCircle2, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown } from 'lucide-react' import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react'
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
import { downloadTripPDF } from '../PDF/TripPDF' import { downloadTripPDF } from '../PDF/TripPDF'
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator' import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar' import PlaceAvatar from '../shared/PlaceAvatar'
@@ -71,32 +73,28 @@ const TYPE_ICONS = {
export default function DayPlanSidebar({ export default function DayPlanSidebar({
tripId, tripId,
trip, days, places, categories, assignments, trip, days, places, categories, assignments,
selectedDayId, selectedPlaceId, selectedDayId, selectedPlaceId, selectedAssignmentId,
onSelectDay, onPlaceClick, onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
onReorder, onUpdateDayTitle, onRouteCalculated, onReorder, onUpdateDayTitle, onRouteCalculated,
onAssignToDay, onAssignToDay,
reservations = [], reservations = [],
onAddReservation, onAddReservation,
}) { }) {
const toast = useToast() const toast = useToast()
const { t, locale } = useTranslation() const { t, language, locale } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const tripStore = useTripStore() const tripStore = useTripStore()
const TRANSPORT_MODES = [
{ value: 'driving', label: t('dayplan.transport.car') },
{ value: 'walking', label: t('dayplan.transport.walk') },
{ value: 'cycling', label: t('dayplan.transport.bike') },
]
const dayNotes = tripStore.dayNotes || {} const dayNotes = tripStore.dayNotes || {}
const [expandedDays, setExpandedDays] = useState(() => new Set(days.map(d => d.id))) const [expandedDays, setExpandedDays] = useState(() => new Set(days.map(d => d.id)))
const [editingDayId, setEditingDayId] = useState(null) const [editingDayId, setEditingDayId] = useState(null)
const [editTitle, setEditTitle] = useState('') const [editTitle, setEditTitle] = useState('')
const [transportMode, setTransportMode] = useState('driving')
const [isCalculating, setIsCalculating] = useState(false) const [isCalculating, setIsCalculating] = useState(false)
const [routeInfo, setRouteInfo] = useState(null) const [routeInfo, setRouteInfo] = useState(null)
const [draggingId, setDraggingId] = useState(null) const [draggingId, setDraggingId] = useState(null)
const [lockedIds, setLockedIds] = useState(new Set())
const [lockHoverId, setLockHoverId] = useState(null)
const [dropTargetKey, setDropTargetKey] = useState(null) const [dropTargetKey, setDropTargetKey] = useState(null)
const [dragOverDayId, setDragOverDayId] = useState(null) const [dragOverDayId, setDragOverDayId] = useState(null)
const [hoveredId, setHoveredId] = useState(null) const [hoveredId, setHoveredId] = useState(null)
@@ -205,16 +203,17 @@ export default function DayPlanSidebar({
catch (err) { toast.error(err.message) } catch (err) { toast.error(err.message) }
} }
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId) => { const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
const m = getMergedItems(dayId) const m = getMergedItems(dayId)
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId) const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId) const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
// Neue Reihenfolge erstellen — VOR dem Ziel einfügen (Standardkonvention) // Neue Reihenfolge erstellen — VOR dem Ziel einfügen (Standard), oder NACH dem Ziel wenn insertAfter
const newOrder = [...m] const newOrder = [...m]
const [moved] = newOrder.splice(fromIdx, 1) const [moved] = newOrder.splice(fromIdx, 1)
const adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx let adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx
if (insertAfter) adjustedTo += 1
newOrder.splice(adjustedTo, 0, moved) newOrder.splice(adjustedTo, 0, moved)
// Orte: neuer order_index über onReorder // Orte: neuer order_index über onReorder
@@ -281,7 +280,7 @@ export default function DayPlanSidebar({
if (waypoints.length < 2) { toast.error(t('dayplan.toast.needTwoPlaces')); return } if (waypoints.length < 2) { toast.error(t('dayplan.toast.needTwoPlaces')); return }
setIsCalculating(true) setIsCalculating(true)
try { try {
const result = await calculateRoute(waypoints, transportMode) const result = await calculateRoute(waypoints, 'walking')
// Luftlinien zwischen Wegpunkten anzeigen // Luftlinien zwischen Wegpunkten anzeigen
const lineCoords = waypoints.map(p => [p.lat, p.lng]) const lineCoords = waypoints.map(p => [p.lat, p.lng])
setRouteInfo({ distance: result.distanceText, duration: result.durationText }) setRouteInfo({ distance: result.distanceText, duration: result.durationText })
@@ -290,15 +289,45 @@ export default function DayPlanSidebar({
finally { setIsCalculating(false) } finally { setIsCalculating(false) }
} }
const toggleLock = (assignmentId) => {
setLockedIds(prev => {
const next = new Set(prev)
if (next.has(assignmentId)) next.delete(assignmentId)
else next.add(assignmentId)
return next
})
}
const handleOptimize = async () => { const handleOptimize = async () => {
if (!selectedDayId) return if (!selectedDayId) return
const da = getDayAssignments(selectedDayId) const da = getDayAssignments(selectedDayId)
if (da.length < 3) return if (da.length < 3) return
const withCoords = da.map(a => a.place).filter(p => p?.lat && p?.lng)
const optimized = optimizeRoute(withCoords) // Separate locked (stay at their index) and unlocked assignments
const reorderedIds = optimized.map(p => da.find(a => a.place?.id === p.id)?.id).filter(Boolean) const locked = new Map() // index -> assignment
for (const a of da) { if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id) } const unlocked = []
await onReorder(selectedDayId, reorderedIds) da.forEach((a, i) => {
if (lockedIds.has(a.id)) locked.set(i, a)
else unlocked.push(a)
})
// Optimize only unlocked assignments (work on assignments, not places)
const unlockedWithCoords = unlocked.filter(a => a.place?.lat && a.place?.lng)
const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng)
const optimizedAssignments = unlockedWithCoords.length >= 2
? optimizeRoute(unlockedWithCoords.map(a => ({ ...a.place, _assignmentId: a.id }))).map(p => unlockedWithCoords.find(a => a.id === p._assignmentId)).filter(Boolean)
: unlockedWithCoords
const optimizedQueue = [...optimizedAssignments, ...unlockedNoCoords]
// Merge: locked stay at their index, fill gaps with optimized
const result = new Array(da.length)
locked.forEach((a, i) => { result[i] = a })
let qi = 0
for (let i = 0; i < result.length; i++) {
if (!result[i]) result[i] = optimizedQueue[qi++]
}
await onReorder(selectedDayId, result.map(a => a.id))
toast.success(t('dayplan.toast.routeOptimized')) toast.success(t('dayplan.toast.routeOptimized'))
} }
@@ -415,7 +444,7 @@ export default function DayPlanSidebar({
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}> <div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */} {/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
<div <div
onClick={() => onSelectDay(isSelected ? null : day.id)} onClick={() => { onSelectDay(isSelected ? null : day.id); if (onDayDetail) onDayDetail(isSelected ? null : day) }}
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }} onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
onDrop={e => handleDropOnDay(e, day.id)} onDrop={e => handleDropOnDay(e, day.id)}
@@ -430,7 +459,7 @@ export default function DayPlanSidebar({
outlineOffset: -2, outlineOffset: -2,
borderRadius: isDragTarget ? 8 : 0, borderRadius: isDragTarget ? 8 : 0,
}} }}
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-hover)' }} onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }} onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
> >
{/* Tages-Badge */} {/* Tages-Badge */}
@@ -461,8 +490,8 @@ export default function DayPlanSidebar({
}} }}
/> />
) : ( ) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
{day.title || t('dayplan.dayN', { n: index + 1 })} {day.title || t('dayplan.dayN', { n: index + 1 })}
</span> </span>
<button <button
@@ -471,11 +500,21 @@ export default function DayPlanSidebar({
> >
<Pencil size={10} strokeWidth={1.8} color="var(--text-secondary)" /> <Pencil size={10} strokeWidth={1.8} color="var(--text-secondary)" />
</button> </button>
{(() => {
const acc = accommodations.find(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
return acc ? (
<span onClick={e => { e.stopPropagation(); onPlaceClick(acc.place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)', flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}>
<Hotel size={8} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</span>
</span>
) : null
})()}
</div> </div>
)} )}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2, flexWrap: 'wrap' }}>
{formattedDate && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formattedDate}</span>} {formattedDate && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formattedDate}</span>}
{cost && <span style={{ fontSize: 11, color: '#059669' }}>{cost}</span>} {cost && <span style={{ fontSize: 11, color: '#059669' }}>{cost}</span>}
{day.date && anyGeoPlace && <span style={{ width: 1, height: 10, background: 'var(--text-faint)', opacity: 0.3, flexShrink: 0 }} />}
{day.date && anyGeoPlace && (() => { {day.date && anyGeoPlace && (() => {
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
@@ -504,7 +543,7 @@ export default function DayPlanSidebar({
{/* Aufgeklappte Orte + Notizen */} {/* Aufgeklappte Orte + Notizen */}
{isExpanded && ( {isExpanded && (
<div <div
style={{ background: 'var(--bg-hover)' }} style={{ background: 'var(--bg-hover)', paddingTop: 6 }}
onDragOver={e => { e.preventDefault(); if (draggingId) setDropTargetKey(`end-${day.id}`) }} onDragOver={e => { e.preventDefault(); if (draggingId) setDropTargetKey(`end-${day.id}`) }}
onDrop={e => { onDrop={e => {
e.preventDefault() e.preventDefault()
@@ -522,9 +561,9 @@ export default function DayPlanSidebar({
if (m.length === 0) return if (m.length === 0) return
const lastItem = m[m.length - 1] const lastItem = m[m.length - 1]
if (assignmentId && String(lastItem?.data?.id) !== assignmentId) if (assignmentId && String(lastItem?.data?.id) !== assignmentId)
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id) handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true)
else if (noteId && String(lastItem?.data?.id) !== noteId) else if (noteId && String(lastItem?.data?.id) !== noteId)
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id) handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
}} }}
> >
{merged.length === 0 && !dayNoteUi ? ( {merged.length === 0 && !dayNoteUi ? (
@@ -548,9 +587,7 @@ export default function DayPlanSidebar({
const place = assignment.place const place = assignment.place
if (!place) return null if (!place) return null
const cat = categories.find(c => c.id === place.category_id) const cat = categories.find(c => c.id === place.category_id)
const isPlaceSelected = place.id === selectedPlaceId const isPlaceSelected = selectedAssignmentId ? assignment.id === selectedAssignmentId : place.id === selectedPlaceId
const hasReservation = place.reservation_status && place.reservation_status !== 'none'
const isConfirmed = place.reservation_status === 'confirmed'
const isDraggingThis = draggingId === assignment.id const isDraggingThis = draggingId === assignment.id
const isHovered = hoveredId === assignment.id const isHovered = hoveredId === assignment.id
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id) const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
@@ -582,7 +619,7 @@ export default function DayPlanSidebar({
dragDataRef.current = { assignmentId: String(assignment.id), fromDayId: String(day.id) } dragDataRef.current = { assignmentId: String(assignment.id), fromDayId: String(day.id) }
setDraggingId(assignment.id) setDraggingId(assignment.id)
}} }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); setDropTargetKey(`place-${assignment.id}`) }} onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); if (dropTargetKey !== `place-${assignment.id}`) setDropTargetKey(`place-${assignment.id}`) }}
onDrop={e => { onDrop={e => {
e.preventDefault(); e.stopPropagation() e.preventDefault(); e.stopPropagation()
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e) const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
@@ -607,25 +644,59 @@ export default function DayPlanSidebar({
} }
}} }}
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }} onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id); if (!isPlaceSelected) onSelectDay(day.id) }} onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
onMouseEnter={() => setHoveredId(assignment.id)} onMouseEnter={() => setHoveredId(assignment.id)}
onMouseLeave={() => setHoveredId(null)} onMouseLeave={() => setHoveredId(null)}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px 7px 10px', padding: '7px 8px 7px 10px',
cursor: 'pointer', cursor: 'pointer',
background: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'), background: lockedIds.has(assignment.id)
borderLeft: hasReservation ? 'rgba(220,38,38,0.08)'
? `3px solid ${isConfirmed ? '#10b981' : '#f59e0b'}` : isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
borderLeft: lockedIds.has(assignment.id)
? '3px solid #dc2626'
: '3px solid transparent', : '3px solid transparent',
transition: 'background 0.1s', transition: 'background 0.15s, border-color 0.15s',
opacity: isDraggingThis ? 0.4 : 1, opacity: isDraggingThis ? 0.4 : 1,
}} }}
> >
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}> <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} /> <GripVertical size={13} strokeWidth={1.8} />
</div> </div>
<PlaceAvatar place={place} category={cat} size={28} /> <div
onClick={e => { e.stopPropagation(); toggleLock(assignment.id) }}
onMouseEnter={e => { e.stopPropagation(); setLockHoverId(assignment.id) }}
onMouseLeave={() => setLockHoverId(null)}
style={{ position: 'relative', flexShrink: 0, cursor: 'pointer' }}
>
<PlaceAvatar place={place} category={cat} size={28} />
{/* Hover/locked overlay */}
{(lockHoverId === assignment.id || lockedIds.has(assignment.id)) && (
<div style={{
position: 'absolute', inset: 0, borderRadius: '50%',
background: lockedIds.has(assignment.id) ? 'rgba(220,38,38,0.6)' : 'rgba(220,38,38,0.4)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background 0.15s',
}}>
<Lock size={14} strokeWidth={2.5} style={{ color: 'white', filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))' }} />
</div>
)}
{/* Custom tooltip */}
{lockHoverId === assignment.id && (
<div style={{
position: 'absolute', left: '100%', top: '50%', transform: 'translateY(-50%)',
marginLeft: 8, whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 50,
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
}}>
{lockedIds.has(assignment.id)
? t('planner.clickToUnlock')
: t('planner.keepPosition')}
</div>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
{cat && (() => { {cat && (() => {
@@ -638,28 +709,36 @@ export default function DayPlanSidebar({
{place.place_time && ( {place.place_time && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
<Clock size={9} strokeWidth={2} /> <Clock size={9} strokeWidth={2} />
{formatTime(place.place_time, locale, timeFormat)} {formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` ${formatTime(place.end_time, locale, timeFormat)}` : ''}
</span> </span>
)} )}
</div> </div>
{(place.description || place.address || cat?.name) && !hasReservation && ( {(place.description || place.address || cat?.name) && (
<div style={{ marginTop: 2 }}> <div style={{ marginTop: 2 }}>
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}> <span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
{place.description || place.address || cat?.name} {place.description || place.address || cat?.name}
</span> </span>
</div> </div>
)} )}
{hasReservation && ( {(() => {
<div style={{ display: 'flex', alignItems: 'center', gap: 2, marginTop: 2 }}> const res = reservations.find(r => r.assignment_id === assignment.id)
<span style={{ fontSize: 10, color: isConfirmed ? '#059669' : '#d97706', display: 'flex', alignItems: 'center', gap: 2, fontWeight: 600 }}> if (!res) return null
{isConfirmed ? <><CheckCircle2 size={10} /> const confirmed = res.status === 'confirmed'
{place.reservation_datetime return (
? `Res. ${formatTime(new Date(place.reservation_datetime).toTimeString().slice(0,5), locale, timeFormat)}` <div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 9, fontWeight: 600,
: place.place_time ? `Res. ${formatTime(place.place_time, locale, timeFormat)}` : t('dayplan.confirmed')} background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)',
</> : <><AlertCircle size={10} />{t('dayplan.pendingRes')}</>} color: confirmed ? '#16a34a' : '#d97706',
</span> }}>
</div> {(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
)} <span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
{res.reservation_time && (
<span style={{ fontWeight: 400 }}>
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
</span>
)}
</div>
)
})()}
</div> </div>
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}> <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
<button onClick={moveUp} disabled={placeIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === 0 ? 'default' : 'pointer', color: placeIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}> <button onClick={moveUp} disabled={placeIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === 0 ? 'default' : 'pointer', color: placeIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
@@ -686,7 +765,7 @@ export default function DayPlanSidebar({
draggable draggable
onDragStart={e => { e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }} onDragStart={e => { e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }} onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDropTargetKey(`note-${note.id}`) }} onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
onDrop={e => { onDrop={e => {
e.preventDefault(); e.stopPropagation() e.preventDefault(); e.stopPropagation()
const { noteId: fromNoteId, assignmentId: fromAssignmentId, fromDayId } = getDragData(e) const { noteId: fromNoteId, assignmentId: fromAssignmentId, fromDayId } = getDragData(e)
@@ -749,26 +828,44 @@ export default function DayPlanSidebar({
) )
}) })
)} )}
{/* Drop-Indikator am Listenende */} {/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
{!!draggingId && dropTargetKey === `end-${day.id}` && ( <div
<div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} /> style={{ minHeight: 12, padding: '2px 8px' }}
)} onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `end-${day.id}`) setDropTargetKey(`end-${day.id}`) }}
onDrop={e => {
e.preventDefault(); e.stopPropagation()
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
// Neuer Ort von der Orte-Liste
if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id)
setDropTargetKey(null); window.__dragData = null; return
}
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
if (assignmentId && fromDayId !== day.id) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch(err => toast.error(err.message))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
if (noteId && fromDayId !== day.id) {
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch(err => toast.error(err.message))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
const m = getMergedItems(day.id)
if (m.length === 0) return
const lastItem = m[m.length - 1]
if (assignmentId && String(lastItem?.data?.id) !== assignmentId)
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true)
else if (noteId && String(lastItem?.data?.id) !== noteId)
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
}}
>
{dropTargetKey === `end-${day.id}` && (
<div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1 }} />
)}
</div>
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */} {/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
{isSelected && getDayAssignments(day.id).length >= 2 && ( {isSelected && getDayAssignments(day.id).length >= 2 && (
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}> <div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
<div style={{ display: 'flex', background: 'var(--bg-hover)', borderRadius: 8, padding: 2, gap: 2 }}>
{TRANSPORT_MODES.map(m => (
<button key={m.value} onClick={() => setTransportMode(m.value)} style={{
flex: 1, padding: '4px 0', fontSize: 11, fontWeight: transportMode === m.value ? 600 : 400,
background: transportMode === m.value ? 'var(--bg-card)' : 'transparent',
border: 'none', borderRadius: 6, cursor: 'pointer', color: transportMode === m.value ? 'var(--text-primary)' : 'var(--text-muted)',
boxShadow: transportMode === m.value ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
fontFamily: 'inherit',
}}>{m.label}</button>
))}
</div>
{routeInfo && ( {routeInfo && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}> <div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
<span>{routeInfo.distance}</span> <span>{routeInfo.distance}</span>
@@ -778,15 +875,6 @@ export default function DayPlanSidebar({
)} )}
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
<button onClick={handleCalculateRoute} disabled={isCalculating} style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer', fontFamily: 'inherit',
opacity: isCalculating ? 0.6 : 1,
}}>
<Navigation size={12} strokeWidth={2} />
{isCalculating ? t('dayplan.calculating') : t('dayplan.route')}
</button>
<button onClick={handleOptimize} style={{ <button onClick={handleOptimize} style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none', padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
+10 -8
View File
@@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import { CalendarDays, MapPin, Plus } from 'lucide-react' import { CalendarDays, MapPin, Plus } from 'lucide-react'
import WeatherWidget from '../Weather/WeatherWidget' import WeatherWidget from '../Weather/WeatherWidget'
import { useTranslation } from '../../i18n'
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr) return null if (!dateStr) return null
@@ -20,6 +21,7 @@ function dayTotal(dayId, assignments) {
} }
export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }) { export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }) {
const { t } = useTranslation()
const totalCost = days.reduce((sum, d) => sum + dayTotal(d.id, assignments), 0) const totalCost = days.reduce((sum, d) => sum + dayTotal(d.id, assignments), 0)
const currency = trip?.currency || 'EUR' const currency = trip?.currency || 'EUR'
@@ -27,8 +29,8 @@ export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header */} {/* Header */}
<div className="px-4 py-3 border-b border-gray-100 flex-shrink-0"> <div className="px-4 py-3 border-b border-gray-100 flex-shrink-0">
<h2 className="text-sm font-semibold text-gray-700">Tagesplan</h2> <h2 className="text-sm font-semibold text-gray-700">{t('planner.dayPlan')}</h2>
<p className="text-xs text-gray-400 mt-0.5">{days.length} Tage</p> <p className="text-xs text-gray-400 mt-0.5">{t('planner.dayCount', { n: days.length })}</p>
</div> </div>
{/* All places overview option */} {/* All places overview option */}
@@ -43,9 +45,9 @@ export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }
<MapPin className={`w-4 h-4 flex-shrink-0 ${selectedDayId === null ? 'text-slate-900' : 'text-gray-400'}`} /> <MapPin className={`w-4 h-4 flex-shrink-0 ${selectedDayId === null ? 'text-slate-900' : 'text-gray-400'}`} />
<div> <div>
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}> <p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
Alle Orte {t('planner.allPlaces')}
</p> </p>
<p className="text-xs text-gray-400">Gesamtübersicht</p> <p className="text-xs text-gray-400">{t('planner.overview')}</p>
</div> </div>
</button> </button>
@@ -54,8 +56,8 @@ export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }
{days.length === 0 ? ( {days.length === 0 ? (
<div className="px-4 py-6 text-center"> <div className="px-4 py-6 text-center">
<CalendarDays className="w-8 h-8 text-gray-300 mx-auto mb-2" /> <CalendarDays className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-xs text-gray-400">Noch keine Tage</p> <p className="text-xs text-gray-400">{t('planner.noDays')}</p>
<p className="text-xs text-gray-300 mt-1">Reise bearbeiten um Tage hinzuzufügen</p> <p className="text-xs text-gray-300 mt-1">{t('planner.editTripToAddDays')}</p>
</div> </div>
) : ( ) : (
days.map((day, index) => { days.map((day, index) => {
@@ -96,7 +98,7 @@ export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }
<div className="flex items-center gap-3 mt-1.5"> <div className="flex items-center gap-3 mt-1.5">
{placeCount > 0 && ( {placeCount > 0 && (
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">
{placeCount} {placeCount === 1 ? 'Ort' : 'Orte'} {placeCount === 1 ? t('planner.placeOne') : t('planner.placeN', { n: placeCount })}
</span> </span>
)} )}
{cost > 0 && ( {cost > 0 && (
@@ -124,7 +126,7 @@ export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }
{totalCost > 0 && ( {totalCost > 0 && (
<div className="flex-shrink-0 border-t border-gray-100 px-4 py-3 bg-gray-50"> <div className="flex-shrink-0 border-t border-gray-100 px-4 py-3 bg-gray-50">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-gray-500">Gesamtkosten</span> <span className="text-xs text-gray-500">{t('planner.totalCost')}</span>
<span className="text-sm font-semibold text-gray-800"> <span className="text-sm font-semibold text-gray-800">
{totalCost.toFixed(2)} {currency} {totalCost.toFixed(2)} {currency}
</span> </span>
@@ -1,17 +1,13 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { X, ExternalLink, Phone, MapPin, Clock, Euro, Edit2, Trash2, Plus, Minus } from 'lucide-react' import { X, ExternalLink, Phone, MapPin, Clock, Euro, Edit2, Trash2, Plus, Minus } from 'lucide-react'
import { mapsApi } from '../../api/client' import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
const RESERVATION_STATUS = {
none: { label: 'Keine Reservierung', color: 'gray' },
pending: { label: 'Res. ausstehend', color: 'yellow' },
confirmed: { label: 'Bestätigt', color: 'green' },
}
export function PlaceDetailPanel({ export function PlaceDetailPanel({
place, categories, tags, selectedDayId, dayAssignments, place, categories, tags, selectedDayId, dayAssignments,
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment, onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
}) { }) {
const { t } = useTranslation()
const [googlePhoto, setGooglePhoto] = useState(null) const [googlePhoto, setGooglePhoto] = useState(null)
const [photoAttribution, setPhotoAttribution] = useState(null) const [photoAttribution, setPhotoAttribution] = useState(null)
@@ -40,8 +36,6 @@ export function PlaceDetailPanel({
? dayAssignments?.find(a => a.place?.id === place.id) ? dayAssignments?.find(a => a.place?.id === place.id)
: null : null
const status = RESERVATION_STATUS[place.reservation_status] || RESERVATION_STATUS.none
return ( return (
<div className="bg-white"> <div className="bg-white">
{/* Image */} {/* Image */}
@@ -177,29 +171,6 @@ export function PlaceDetailPanel({
</div> </div>
)} )}
{/* Reservation status */}
{place.reservation_status && place.reservation_status !== 'none' && (
<div className={`rounded-lg px-3 py-2 border ${
place.reservation_status === 'confirmed'
? 'bg-emerald-50 border-emerald-200'
: 'bg-yellow-50 border-yellow-200'
}`}>
<div className={`text-xs font-semibold ${
place.reservation_status === 'confirmed' ? 'text-emerald-700' : 'text-yellow-700'
}`}>
{place.reservation_status === 'confirmed' ? '✅' : '⏳'} {status.label}
</div>
{place.reservation_datetime && (
<div className="text-xs text-gray-500 mt-0.5">
{formatDateTime(place.reservation_datetime)}
</div>
)}
{place.reservation_notes && (
<p className="text-xs text-gray-600 mt-1">{place.reservation_notes}</p>
)}
</div>
)}
{/* Day assignment actions */} {/* Day assignment actions */}
{selectedDayId && ( {selectedDayId && (
<div className="pt-1"> <div className="pt-1">
@@ -209,7 +180,7 @@ export function PlaceDetailPanel({
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50" className="w-full flex items-center justify-center gap-2 py-2 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
> >
<Minus className="w-4 h-4" /> <Minus className="w-4 h-4" />
Aus Tag entfernen {t('planner.removeFromDay')}
</button> </button>
) : ( ) : (
<button <button
@@ -217,7 +188,7 @@ export function PlaceDetailPanel({
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-white bg-slate-900 rounded-lg hover:bg-slate-700" className="w-full flex items-center justify-center gap-2 py-2 text-sm text-white bg-slate-900 rounded-lg hover:bg-slate-700"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Zum Tag hinzufügen {t('planner.addToThisDay')}
</button> </button>
)} )}
</div> </div>
@@ -230,7 +201,7 @@ export function PlaceDetailPanel({
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-gray-700 border border-gray-200 rounded-lg hover:bg-gray-50" className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-gray-700 border border-gray-200 rounded-lg hover:bg-gray-50"
> >
<Edit2 className="w-3.5 h-3.5" /> <Edit2 className="w-3.5 h-3.5" />
Bearbeiten {t('common.edit')}
</button> </button>
<button <button
onClick={onDelete} onClick={onDelete}
@@ -1,20 +1,12 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useRef } from 'react'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { mapsApi } from '../../api/client' import { mapsApi } from '../../api/client'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { Search } from 'lucide-react' import { Search, Paperclip, X } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker' import CustomTimePicker from '../shared/CustomTimePicker'
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
const TRANSPORT_MODES = [
{ value: 'walking', labelKey: 'places.transport.walking' },
{ value: 'driving', labelKey: 'places.transport.driving' },
{ value: 'cycling', labelKey: 'places.transport.cycling' },
{ value: 'transit', labelKey: 'places.transport.transit' },
]
const DEFAULT_FORM = { const DEFAULT_FORM = {
name: '', name: '',
@@ -24,11 +16,9 @@ const DEFAULT_FORM = {
lng: '', lng: '',
category_id: '', category_id: '',
place_time: '', place_time: '',
end_time: '',
notes: '', notes: '',
transport_mode: 'walking', transport_mode: 'walking',
reservation_status: 'none',
reservation_notes: '',
reservation_datetime: '',
website: '', website: '',
} }
@@ -43,6 +33,8 @@ export default function PlaceFormModal({
const [newCategoryName, setNewCategoryName] = useState('') const [newCategoryName, setNewCategoryName] = useState('')
const [showNewCategory, setShowNewCategory] = useState(false) const [showNewCategory, setShowNewCategory] = useState(false)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [pendingFiles, setPendingFiles] = useState([])
const fileRef = useRef(null)
const toast = useToast() const toast = useToast()
const { t, language } = useTranslation() const { t, language } = useTranslation()
const { hasMapsKey } = useAuthStore() const { hasMapsKey } = useAuthStore()
@@ -57,16 +49,15 @@ export default function PlaceFormModal({
lng: place.lng || '', lng: place.lng || '',
category_id: place.category_id || '', category_id: place.category_id || '',
place_time: place.place_time || '', place_time: place.place_time || '',
end_time: place.end_time || '',
notes: place.notes || '', notes: place.notes || '',
transport_mode: place.transport_mode || 'walking', transport_mode: place.transport_mode || 'walking',
reservation_status: place.reservation_status || 'none',
reservation_notes: place.reservation_notes || '',
reservation_datetime: place.reservation_datetime || '',
website: place.website || '', website: place.website || '',
}) })
} else { } else {
setForm(DEFAULT_FORM) setForm(DEFAULT_FORM)
} }
setPendingFiles([])
}, [place, isOpen]) }, [place, isOpen])
const handleChange = (field, value) => { const handleChange = (field, value) => {
@@ -111,6 +102,30 @@ export default function PlaceFormModal({
} }
} }
const handleFileAdd = (e) => {
const files = Array.from(e.target.files || [])
setPendingFiles(prev => [...prev, ...files])
e.target.value = ''
}
const handleRemoveFile = (idx) => {
setPendingFiles(prev => prev.filter((_, i) => i !== idx))
}
// Paste support for files/images
const handlePaste = (e) => {
const items = e.clipboardData?.items
if (!items) return
for (const item of items) {
if (item.type.startsWith('image/') || item.type === 'application/pdf') {
e.preventDefault()
const file = item.getAsFile()
if (file) setPendingFiles(prev => [...prev, file])
return
}
}
}
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault()
if (!form.name.trim()) { if (!form.name.trim()) {
@@ -124,6 +139,7 @@ export default function PlaceFormModal({
lat: form.lat ? parseFloat(form.lat) : null, lat: form.lat ? parseFloat(form.lat) : null,
lng: form.lng ? parseFloat(form.lng) : null, lng: form.lng ? parseFloat(form.lng) : null,
category_id: form.category_id || null, category_id: form.category_id || null,
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
}) })
onClose() onClose()
} catch (err) { } catch (err) {
@@ -140,7 +156,7 @@ export default function PlaceFormModal({
title={place ? t('places.editPlace') : t('places.addPlace')} title={place ? t('places.editPlace') : t('places.addPlace')}
size="lg" size="lg"
> >
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}>
{/* Place Search */} {/* Place Search */}
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200"> <div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
{!hasMapsKey && ( {!hasMapsKey && (
@@ -278,12 +294,21 @@ export default function PlaceFormModal({
</div> </div>
{/* Time */} {/* Time */}
<div> <div className="grid grid-cols-2 gap-3">
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formTime')}</label> <div>
<CustomTimePicker <label className="block text-sm font-medium text-gray-700 mb-1">{t('places.startTime')}</label>
value={form.place_time} <CustomTimePicker
onChange={v => handleChange('place_time', v)} value={form.place_time}
/> onChange={v => handleChange('place_time', v)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.endTime')}</label>
<CustomTimePicker
value={form.end_time}
onChange={v => handleChange('end_time', v)}
/>
</div>
</div> </div>
{/* Website */} {/* Website */}
@@ -298,45 +323,35 @@ export default function PlaceFormModal({
/> />
</div> </div>
{/* Reservation */} {/* File Attachments */}
<div className="border border-gray-200 rounded-xl p-3 space-y-3"> {true && (
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3"> <div className="border border-gray-200 rounded-xl p-3 space-y-2">
<label className="block text-sm font-medium text-gray-700 shrink-0">{t('places.formReservation')}</label> <div className="flex items-center justify-between">
<div className="flex gap-2 flex-wrap"> <label className="block text-sm font-medium text-gray-700">{t('files.title')}</label>
{['none', 'pending', 'confirmed'].map(status => ( <button type="button" onClick={() => fileRef.current?.click()}
<button className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-700 transition-colors">
key={status} <Paperclip size={12} /> {t('files.attach')}
type="button" </button>
onClick={() => handleChange('reservation_status', status)}
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
form.reservation_status === status
? status === 'confirmed' ? 'bg-emerald-600 text-white'
: status === 'pending' ? 'bg-yellow-500 text-white'
: 'bg-gray-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{status === 'none' ? t('common.none') : status === 'pending' ? t('reservations.pending') : t('reservations.confirmed')}
</button>
))}
</div> </div>
<input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={handleFileAdd} />
{pendingFiles.length > 0 && (
<div className="space-y-1">
{pendingFiles.map((file, idx) => (
<div key={idx} className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-slate-50 text-xs">
<Paperclip size={10} className="text-slate-400 shrink-0" />
<span className="truncate flex-1 text-slate-600">{file.name}</span>
<button type="button" onClick={() => handleRemoveFile(idx)} className="text-slate-400 hover:text-red-500 shrink-0">
<X size={12} />
</button>
</div>
))}
</div>
)}
{pendingFiles.length === 0 && (
<p className="text-xs text-slate-400">{t('files.pasteHint')}</p>
)}
</div> </div>
{form.reservation_status !== 'none' && ( )}
<>
<CustomDateTimePicker
value={form.reservation_datetime}
onChange={v => handleChange('reservation_datetime', v)}
/>
<textarea
value={form.reservation_notes}
onChange={e => handleChange('reservation_notes', e.target.value)}
rows={2}
placeholder={t('places.reservationNotesPlaceholder')}
className="form-input" style={{ resize: 'none' }}
/>
</>
)}
</div>
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100"> <div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react' import React, { useState, useEffect, useRef, useCallback } from 'react'
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, CheckCircle2, AlertCircle, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation } from 'lucide-react' import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar' import PlaceAvatar from '../shared/PlaceAvatar'
import { mapsApi } from '../../api/client' import { mapsApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
@@ -86,16 +86,6 @@ function formatTime(timeStr, locale, timeFormat) {
} catch { return timeStr } } catch { return timeStr }
} }
function formatReservationDatetime(dt, locale, timeFormat) {
if (!dt) return null
try {
const d = new Date(dt)
if (isNaN(d)) return dt
const datePart = d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
const timePart = formatTime(`${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`, locale, timeFormat)
return `${datePart}, ${timePart}`
} catch { return dt }
}
function formatFileSize(bytes) { function formatFileSize(bytes) {
if (!bytes) return '' if (!bytes) return ''
@@ -105,7 +95,7 @@ function formatFileSize(bytes) {
} }
export default function PlaceInspector({ export default function PlaceInspector({
place, categories, days, selectedDayId, assignments, place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment, onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
files, onFileUpload, files, onFileUpload,
}) { }) {
@@ -279,45 +269,72 @@ export default function PlaceInspector({
</div> </div>
)} )}
{/* Description + Reservation in one box */} {/* Description */}
{(place.description || place.notes || (place.reservation_status && place.reservation_status !== 'none')) && ( {(place.description || place.notes) && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}> <div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
{(place.description || place.notes) && ( <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px' }}>
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px', {place.description || place.notes}
borderBottom: (place.reservation_status && place.reservation_status !== 'none') ? '1px solid var(--border-faint)' : 'none' </p>
}}>
{place.description || place.notes}
</p>
)}
{place.reservation_status && place.reservation_status !== 'none' && (
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
{place.reservation_status === 'confirmed'
? <CheckCircle2 size={12} color="#059669" />
: <AlertCircle size={12} color="#d97706" />
}
<span style={{ fontSize: 12, fontWeight: 600, color: place.reservation_status === 'confirmed' ? '#059669' : '#d97706' }}>
{place.reservation_status === 'confirmed' ? t('inspector.confirmedRes') : t('inspector.pendingRes')}
</span>
{(place.reservation_datetime || place.place_time) && (
<>
<span style={{ fontSize: 11, color: '#d1d5db' }}>·</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{place.reservation_datetime
? formatReservationDatetime(place.reservation_datetime, locale, timeFormat)
: formatTime(place.place_time, locale, timeFormat)}
</span>
</>
)}
</div>
{place.reservation_notes && (
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0, lineHeight: '1.5', paddingLeft: 17 }}>{place.reservation_notes}</p>
)}
</div>
)}
</div> </div>
)} )}
{/* Reservation for this specific assignment */}
{(() => {
const res = selectedAssignmentId ? reservations.find(r => r.assignment_id === selectedAssignmentId) : null
if (!res) return null
const confirmed = res.status === 'confirmed'
const accentColor = confirmed ? '#16a34a' : '#d97706'
return (
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
{/* Header bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: confirmed ? 'rgba(22,163,74,0.08)' : 'rgba(217,119,6,0.08)' }}>
<div style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: accentColor }} />
<span style={{ fontSize: 11, fontWeight: 700, color: accentColor }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
<span style={{ flex: 1 }} />
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)' }}>{res.title}</span>
</div>
{/* Details grid */}
{(res.reservation_time || res.confirmation_number || res.location || res.notes) && (
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
{res.reservation_time && (
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.date')}</div>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}
</div>
</div>
)}
{res.reservation_time && (
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
</div>
</div>
)}
{res.confirmation_number && (
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.confirmationCode')}</div>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{res.confirmation_number}</div>
</div>
)}
{res.location && (
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.locationAddress')}</div>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-muted)', marginTop: 1 }}>{res.location}</div>
</div>
)}
</div>
{res.notes && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', lineHeight: 1.4, borderTop: '1px solid var(--border-faint)', paddingTop: 5 }}>{res.notes}</div>
)}
</div>
)}
</div>
)
})()}
{/* Opening hours */} {/* Opening hours */}
{openingHours && openingHours.length > 0 && ( {openingHours && openingHours.length > 0 && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}> <div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
@@ -204,19 +204,17 @@ export default function PlacesSidebar({
</div> </div>
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 16px' }}> <div style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 16px' }}>
{days.map((day, i) => { {days.map((day, i) => {
const alreadyAssigned = (assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id)
return ( return (
<button <button
key={day.id} key={day.id}
disabled={alreadyAssigned}
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null) }} onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null) }}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 10, width: '100%', display: 'flex', alignItems: 'center', gap: 10, width: '100%',
padding: '12px 14px', borderRadius: 12, border: 'none', cursor: alreadyAssigned ? 'default' : 'pointer', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer',
background: 'transparent', fontFamily: 'inherit', textAlign: 'left', background: 'transparent', fontFamily: 'inherit', textAlign: 'left',
opacity: alreadyAssigned ? 0.4 : 1, transition: 'background 0.1s', transition: 'background 0.1s',
}} }}
onMouseEnter={e => { if (!alreadyAssigned) e.currentTarget.style.background = 'var(--bg-hover)' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
> >
<div style={{ <div style={{
@@ -230,7 +228,7 @@ export default function PlacesSidebar({
</div> </div>
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00').toLocaleDateString()}</div>} {day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00').toLocaleDateString()}</div>}
</div> </div>
{alreadyAssigned && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}></span>} {(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}></span>}
</button> </button>
) )
})} })}
@@ -13,20 +13,7 @@ import { PlaceDetailPanel } from './PlaceDetailPanel'
import WeatherWidget from '../Weather/WeatherWidget' import WeatherWidget from '../Weather/WeatherWidget'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
const SEGMENTS = [
{ id: 'plan', label: 'Plan' },
{ id: 'orte', label: 'Orte' },
{ id: 'reservierungen', label: 'Buchungen' },
{ id: 'packliste', label: 'Packliste' },
{ id: 'dokumente', label: 'Dokumente' },
]
const TRANSPORT_MODES = [
{ value: 'driving', label: 'Auto', icon: '🚗' },
{ value: 'walking', label: 'Fuß', icon: '🚶' },
{ value: 'cycling', label: 'Rad', icon: '🚲' },
]
function formatShortDate(dateStr) { function formatShortDate(dateStr) {
if (!dateStr) return '' if (!dateStr) return ''
@@ -53,7 +40,6 @@ export default function PlannerSidebar({
const [activeSegment, setActiveSegment] = useState('plan') const [activeSegment, setActiveSegment] = useState('plan')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState('') const [categoryFilter, setCategoryFilter] = useState('')
const [transportMode, setTransportMode] = useState('driving')
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false) const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
const [showReservationModal, setShowReservationModal] = useState(false) const [showReservationModal, setShowReservationModal] = useState(false)
const [editingReservation, setEditingReservation] = useState(null) const [editingReservation, setEditingReservation] = useState(null)
@@ -65,6 +51,16 @@ export default function PlannerSidebar({
const tripStore = useTripStore() const tripStore = useTripStore()
const toast = useToast() const toast = useToast()
const { t } = useTranslation()
const SEGMENTS = [
{ id: 'plan', label: 'Plan' },
{ id: 'orte', label: t('planner.places') },
{ id: 'reservierungen', label: t('planner.bookings') },
{ id: 'packliste', label: t('planner.packingList') },
{ id: 'dokumente', label: t('planner.documents') },
]
const dayNotes = tripStore.dayNotes || {} const dayNotes = tripStore.dayNotes || {}
const placesListRef = useRef(null) const placesListRef = useRef(null)
const [placesListHeight, setPlacesListHeight] = useState(400) const [placesListHeight, setPlacesListHeight] = useState(400)
@@ -135,17 +131,17 @@ export default function PlannerSidebar({
.filter(p => p?.lat && p?.lng) .filter(p => p?.lat && p?.lng)
.map(p => ({ lat: p.lat, lng: p.lng })) .map(p => ({ lat: p.lat, lng: p.lng }))
if (waypoints.length < 2) { if (waypoints.length < 2) {
toast.error('Mindestens 2 Orte mit Koordinaten benötigt') toast.error(t('planner.minTwoPlaces'))
return return
} }
setIsCalculatingRoute(true) setIsCalculatingRoute(true)
try { try {
const result = await calculateRoute(waypoints, transportMode) const result = await calculateRoute(waypoints, 'walking')
setRouteInfo({ distance: result.distanceText, duration: result.durationText }) setRouteInfo({ distance: result.distanceText, duration: result.durationText })
onRouteCalculated?.(result) onRouteCalculated?.(result)
toast.success('Route berechnet') toast.success(t('planner.routeCalculated'))
} catch { } catch {
toast.error('Route konnte nicht berechnet werden') toast.error(t('planner.routeCalcFailed'))
} finally { } finally {
setIsCalculatingRoute(false) setIsCalculatingRoute(false)
} }
@@ -163,14 +159,14 @@ export default function PlannerSidebar({
if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id) if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id)
} }
await onReorder(selectedDayId, reorderedIds) await onReorder(selectedDayId, reorderedIds)
toast.success('Route optimiert') toast.success(t('planner.routeOptimized'))
} }
const handleOpenGoogleMaps = () => { const handleOpenGoogleMaps = () => {
const ps = selectedDayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng) const ps = selectedDayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
const url = generateGoogleMapsUrl(ps) const url = generateGoogleMapsUrl(ps)
if (url) window.open(url, '_blank') if (url) window.open(url, '_blank')
else toast.error('Keine Orte mit Koordinaten vorhanden') else toast.error(t('planner.noGeoPlaces'))
} }
const handleMoveUp = async (dayId, idx) => { const handleMoveUp = async (dayId, idx) => {
@@ -270,10 +266,10 @@ export default function PlannerSidebar({
try { try {
if (editingReservation) { if (editingReservation) {
await tripStore.updateReservation(tripId, editingReservation.id, data) await tripStore.updateReservation(tripId, editingReservation.id, data)
toast.success('Reservierung aktualisiert') toast.success(t('planner.reservationUpdated'))
} else { } else {
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null }) await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
toast.success('Reservierung hinzugefügt') toast.success(t('planner.reservationAdded'))
} }
setShowReservationModal(false) setShowReservationModal(false)
} catch (err) { } catch (err) {
@@ -282,10 +278,10 @@ export default function PlannerSidebar({
} }
const handleDeleteReservation = async (id) => { const handleDeleteReservation = async (id) => {
if (!confirm('Reservierung löschen?')) return if (!confirm(t('planner.confirmDeleteReservation'))) return
try { try {
await tripStore.deleteReservation(tripId, id) await tripStore.deleteReservation(tripId, id)
toast.success('Reservierung gelöscht') toast.success(t('planner.reservationDeleted'))
} catch (err) { } catch (err) {
toast.error(err.message) toast.error(err.message)
} }
@@ -306,7 +302,7 @@ export default function PlannerSidebar({
{trip.start_date && formatShortDate(trip.start_date)} {trip.start_date && formatShortDate(trip.start_date)}
{trip.start_date && trip.end_date && ' '} {trip.start_date && trip.end_date && ' '}
{trip.end_date && formatShortDate(trip.end_date)} {trip.end_date && formatShortDate(trip.end_date)}
{days.length > 0 && ` · ${days.length} Tage`} {days.length > 0 && ` · ${days.length} ${t('planner.days')}`}
</p> </p>
)} )}
</button> </button>
@@ -348,18 +344,18 @@ export default function PlannerSidebar({
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}> <p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
Alle Orte {t('planner.allPlaces')}
</p> </p>
<p className="text-xs text-gray-400">{places.length} Orte gesamt</p> <p className="text-xs text-gray-400">{t('planner.totalPlaces', { n: places.length })}</p>
</div> </div>
</button> </button>
{days.length === 0 ? ( {days.length === 0 ? (
<div className="px-4 py-10 text-center"> <div className="px-4 py-10 text-center">
<CalendarDays className="w-10 h-10 text-gray-200 mx-auto mb-3" /> <CalendarDays className="w-10 h-10 text-gray-200 mx-auto mb-3" />
<p className="text-sm text-gray-400">Noch keine Tage geplant</p> <p className="text-sm text-gray-400">{t('planner.noDaysPlanned')}</p>
<button onClick={onEditTrip} className="mt-2 text-slate-700 text-sm"> <button onClick={onEditTrip} className="mt-2 text-slate-700 text-sm">
Reise bearbeiten {t('planner.editTrip')}
</button> </button>
</div> </div>
) : ( ) : (
@@ -396,7 +392,7 @@ export default function PlannerSidebar({
</p> </p>
{da.length > 0 && ( {da.length > 0 && (
<span className="text-xs text-gray-400 flex-shrink-0"> <span className="text-xs text-gray-400 flex-shrink-0">
{da.length} {da.length === 1 ? 'Ort' : 'Orte'} {da.length === 1 ? t('planner.placeOne') : t('planner.placeN', { n: da.length })}
</span> </span>
)} )}
</div> </div>
@@ -410,7 +406,7 @@ export default function PlannerSidebar({
</div> </div>
<button <button
onClick={e => { e.stopPropagation(); openAddNote(day.id); if (!isExpanded) toggleDay(day.id) }} onClick={e => { e.stopPropagation(); openAddNote(day.id); if (!isExpanded) toggleDay(day.id) }}
title="Notiz hinzufügen" title={t('planner.addNote')}
className="p-1 text-gray-300 hover:text-amber-500 flex-shrink-0 transition-colors" className="p-1 text-gray-300 hover:text-amber-500 flex-shrink-0 transition-colors"
> >
<FileText className="w-4 h-4" /> <FileText className="w-4 h-4" />
@@ -430,12 +426,12 @@ export default function PlannerSidebar({
<div className="bg-gray-50/40"> <div className="bg-gray-50/40">
{merged.length === 0 && !dayNoteUi ? ( {merged.length === 0 && !dayNoteUi ? (
<div className="px-4 py-4 text-center"> <div className="px-4 py-4 text-center">
<p className="text-xs text-gray-400">Keine Einträge für diesen Tag</p> <p className="text-xs text-gray-400">{t('planner.noEntries')}</p>
<button <button
onClick={() => { onSelectDay(day.id); setActiveSegment('orte') }} onClick={() => { onSelectDay(day.id); setActiveSegment('orte') }}
className="mt-1 text-xs text-slate-700" className="mt-1 text-xs text-slate-700"
> >
+ Ort hinzufügen {t('planner.addPlaceShort')}
</button> </button>
</div> </div>
) : ( ) : (
@@ -478,20 +474,11 @@ export default function PlannerSidebar({
)} )}
<div className="flex items-center gap-2 mt-0.5 flex-wrap"> <div className="flex items-center gap-2 mt-0.5 flex-wrap">
{place.place_time && ( {place.place_time && (
<span className="text-[11px] text-slate-600 font-medium">{place.place_time}</span> <span className="text-[11px] text-slate-600 font-medium">{place.place_time}{place.end_time ? ` ${place.end_time}` : ''}</span>
)} )}
{place.price > 0 && ( {place.price > 0 && (
<span className="text-[11px] text-gray-400">{place.price} {place.currency || currency}</span> <span className="text-[11px] text-gray-400">{place.price} {place.currency || currency}</span>
)} )}
{place.reservation_status && place.reservation_status !== 'none' && (
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
place.reservation_status === 'confirmed'
? 'bg-emerald-50 text-emerald-600'
: 'bg-amber-50 text-amber-600'
}`}>
{place.reservation_status === 'confirmed' ? '✓ Bestätigt' : '⏳ Res. ausstehend'}
</span>
)}
</div> </div>
</div> </div>
<div className="flex flex-col gap-0 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex flex-col gap-0 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
@@ -524,7 +511,7 @@ export default function PlannerSidebar({
type="text" type="text"
value={dayNoteUi.time} value={dayNoteUi.time}
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))} onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
placeholder="Zeit (optional)" placeholder={t('planner.noteTimePlaceholder')}
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300" className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
/> />
</div> </div>
@@ -533,16 +520,16 @@ export default function PlannerSidebar({
value={dayNoteUi.text} value={dayNoteUi.text}
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))} onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }}
placeholder="Notiz…" placeholder={t('planner.notePlaceholder')}
rows={2} rows={2}
className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none" className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none"
/> />
<div className="flex gap-1.5 mt-1.5"> <div className="flex gap-1.5 mt-1.5">
<button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600"> <button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600">
<Check className="w-3 h-3" /> Speichern <Check className="w-3 h-3" /> {t('common.save')}
</button> </button>
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100"> <button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
Abbrechen {t('common.cancel')}
</button> </button>
</div> </div>
</div> </div>
@@ -587,7 +574,7 @@ export default function PlannerSidebar({
type="text" type="text"
value={dayNoteUi.time} value={dayNoteUi.time}
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))} onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
placeholder="Zeit (optional)" placeholder={t('planner.noteTimePlaceholder')}
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300" className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
/> />
</div> </div>
@@ -596,16 +583,16 @@ export default function PlannerSidebar({
value={dayNoteUi.text} value={dayNoteUi.text}
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))} onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }}
placeholder="z.B. S3 um 14:30 ab Hauptbahnhof, Fähre ab Pier 7, Mittagspause…" placeholder={t('planner.noteExamplePlaceholder')}
rows={2} rows={2}
className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none" className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none"
/> />
<div className="flex gap-1.5 mt-1.5"> <div className="flex gap-1.5 mt-1.5">
<button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600"> <button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600">
<Check className="w-3 h-3" /> Hinzufügen <Check className="w-3 h-3" /> {t('common.add')}
</button> </button>
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100"> <button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
Abbrechen {t('common.cancel')}
</button> </button>
</div> </div>
</div> </div>
@@ -618,7 +605,7 @@ export default function PlannerSidebar({
className="flex items-center gap-1 text-[11px] text-amber-600 hover:text-amber-700 py-1" className="flex items-center gap-1 text-[11px] text-amber-600 hover:text-amber-700 py-1"
> >
<FileText className="w-3 h-3" /> <FileText className="w-3 h-3" />
Notiz hinzufügen {t('planner.addNote')}
</button> </button>
</div> </div>
)} )}
@@ -626,21 +613,6 @@ export default function PlannerSidebar({
{/* Route tools — only for the selected day */} {/* Route tools — only for the selected day */}
{isSelected && da.length >= 2 && ( {isSelected && da.length >= 2 && (
<div className="px-4 py-3 space-y-2 border-t border-gray-100/60"> <div className="px-4 py-3 space-y-2 border-t border-gray-100/60">
<div className="flex bg-gray-100 rounded-[8px] p-0.5 gap-0.5">
{TRANSPORT_MODES.map(m => (
<button
key={m.value}
onClick={() => setTransportMode(m.value)}
className={`flex-1 py-1 text-[11px] rounded-[6px] transition-all ${
transportMode === m.value
? 'bg-white shadow-sm text-gray-900 font-medium'
: 'text-gray-500'
}`}
>
{m.icon} {m.label}
</button>
))}
</div>
{routeInfo && ( {routeInfo && (
<div className="flex items-center justify-center gap-3 text-xs bg-slate-50 rounded-lg px-3 py-2"> <div className="flex items-center justify-center gap-3 text-xs bg-slate-50 rounded-lg px-3 py-2">
<span className="text-slate-900">🛣 {routeInfo.distance}</span> <span className="text-slate-900">🛣 {routeInfo.distance}</span>
@@ -655,14 +627,14 @@ export default function PlannerSidebar({
className="flex items-center justify-center gap-1.5 bg-slate-900 text-white text-xs py-2 rounded-lg hover:bg-slate-700 disabled:opacity-60 transition-colors" className="flex items-center justify-center gap-1.5 bg-slate-900 text-white text-xs py-2 rounded-lg hover:bg-slate-700 disabled:opacity-60 transition-colors"
> >
<Navigation className="w-3.5 h-3.5" /> <Navigation className="w-3.5 h-3.5" />
{isCalculatingRoute ? 'Berechne...' : 'Route'} {isCalculatingRoute ? t('planner.calculating') : t('planner.route')}
</button> </button>
<button <button
onClick={handleOptimizeRoute} onClick={handleOptimizeRoute}
className="flex items-center justify-center gap-1.5 bg-emerald-600 text-white text-xs py-2 rounded-lg hover:bg-emerald-700 transition-colors" className="flex items-center justify-center gap-1.5 bg-emerald-600 text-white text-xs py-2 rounded-lg hover:bg-emerald-700 transition-colors"
> >
<RotateCcw className="w-3.5 h-3.5" /> <RotateCcw className="w-3.5 h-3.5" />
Optimieren {t('planner.optimize')}
</button> </button>
</div> </div>
<button <button
@@ -670,7 +642,7 @@ export default function PlannerSidebar({
className="w-full flex items-center justify-center gap-1.5 border border-gray-200 text-gray-600 text-xs py-2 rounded-lg hover:bg-gray-50 transition-colors" className="w-full flex items-center justify-center gap-1.5 border border-gray-200 text-gray-600 text-xs py-2 rounded-lg hover:bg-gray-50 transition-colors"
> >
<ExternalLink className="w-3.5 h-3.5" /> <ExternalLink className="w-3.5 h-3.5" />
In Google Maps öffnen {t('planner.openGoogleMaps')}
</button> </button>
</div> </div>
)} )}
@@ -683,7 +655,7 @@ export default function PlannerSidebar({
{totalCost > 0 && ( {totalCost > 0 && (
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between"> <div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
<span className="text-xs text-gray-500">Gesamtkosten</span> <span className="text-xs text-gray-500">{t('planner.totalCost')}</span>
<span className="text-sm font-semibold text-gray-800">{totalCost.toFixed(2)} {currency}</span> <span className="text-sm font-semibold text-gray-800">{totalCost.toFixed(2)} {currency}</span>
</div> </div>
)} )}
@@ -700,7 +672,7 @@ export default function PlannerSidebar({
type="text" type="text"
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
placeholder="Orte suchen…" placeholder={t('planner.searchPlaces')}
className="w-full pl-8 pr-8 py-2 bg-gray-100 rounded-[10px] text-sm focus:outline-none focus:bg-white focus:ring-2 focus:ring-slate-400 transition-colors" className="w-full pl-8 pr-8 py-2 bg-gray-100 rounded-[10px] text-sm focus:outline-none focus:bg-white focus:ring-2 focus:ring-slate-400 transition-colors"
/> />
{search && ( {search && (
@@ -715,7 +687,7 @@ export default function PlannerSidebar({
onChange={e => setCategoryFilter(e.target.value)} onChange={e => setCategoryFilter(e.target.value)}
className="flex-1 bg-gray-100 rounded-lg text-xs py-2 px-2 focus:outline-none text-gray-600" className="flex-1 bg-gray-100 rounded-lg text-xs py-2 px-2 focus:outline-none text-gray-600"
> >
<option value="">Alle Kategorien</option> <option value="">{t('planner.allCategories')}</option>
{categories.map(c => ( {categories.map(c => (
<option key={c.id} value={c.id}>{c.icon} {c.name}</option> <option key={c.id} value={c.id}>{c.icon} {c.name}</option>
))} ))}
@@ -725,7 +697,7 @@ export default function PlannerSidebar({
className="flex items-center gap-1 bg-slate-900 text-white text-xs px-3 py-2 rounded-lg hover:bg-slate-700 whitespace-nowrap transition-colors" className="flex items-center gap-1 bg-slate-900 text-white text-xs px-3 py-2 rounded-lg hover:bg-slate-700 whitespace-nowrap transition-colors"
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
Neu {t('planner.new')}
</button> </button>
</div> </div>
</div> </div>
@@ -733,9 +705,9 @@ export default function PlannerSidebar({
{filteredPlaces.length === 0 ? ( {filteredPlaces.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400"> <div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">📍</span> <span className="text-3xl mb-2">📍</span>
<p className="text-sm">Keine Orte gefunden</p> <p className="text-sm">{t('planner.noPlacesFound')}</p>
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm"> <button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm">
Ersten Ort hinzufügen {t('planner.addFirstPlace')}
</button> </button>
</div> </div>
) : ( ) : (
@@ -782,7 +754,7 @@ export default function PlannerSidebar({
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }} onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
className="text-[11px] text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100 transition-colors" className="text-[11px] text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100 transition-colors"
> >
+ Tag {t('planner.addToDay')}
</button> </button>
) )
} }
@@ -805,7 +777,7 @@ export default function PlannerSidebar({
<div> <div>
<div className="px-4 py-3 flex items-center justify-between border-b border-gray-100"> <div className="px-4 py-3 flex items-center justify-between border-b border-gray-100">
<h3 className="font-medium text-sm text-gray-900"> <h3 className="font-medium text-sm text-gray-900">
Reservierungen {t('planner.reservations')}
{selectedDay && <span className="text-gray-400 font-normal"> · Tag {selectedDay.day_number}</span>} {selectedDay && <span className="text-gray-400 font-normal"> · Tag {selectedDay.day_number}</span>}
</h3> </h3>
<button <button
@@ -813,13 +785,13 @@ export default function PlannerSidebar({
className="flex items-center gap-1 bg-slate-900 text-white text-xs px-2.5 py-1.5 rounded-lg hover:bg-slate-700 transition-colors" className="flex items-center gap-1 bg-slate-900 text-white text-xs px-2.5 py-1.5 rounded-lg hover:bg-slate-700 transition-colors"
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
Hinzufügen {t('common.add')}
</button> </button>
</div> </div>
{filteredReservations.length === 0 ? ( {filteredReservations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400"> <div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">🎫</span> <span className="text-3xl mb-2">🎫</span>
<p className="text-sm">Keine Reservierungen</p> <p className="text-sm">{t('planner.noReservations')}</p>
</div> </div>
) : ( ) : (
<div className="p-3 space-y-2.5"> <div className="p-3 space-y-2.5">
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef, useMemo } from 'react'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink } from 'lucide-react' import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker' import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
@@ -18,19 +18,46 @@ const TYPE_OPTIONS = [
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText }, { value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
] ]
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, selectedDayId, files = [], onFileUpload, onFileDelete }) { function buildAssignmentOptions(days, assignments, t, locale) {
const options = []
for (const day of (days || [])) {
const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
if (da.length === 0) continue
const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number })
const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : ''
// Group header (non-selectable)
options.push({ value: `_header_${day.id}`, label: `${dayLabel}${dateStr}`, disabled: true, isHeader: true })
for (let i = 0; i < da.length; i++) {
const place = da[i].place
if (!place) continue
const timeStr = place.place_time ? ` · ${place.place_time}${place.end_time ? ' ' + place.end_time : ''}` : ''
options.push({
value: da[i].id,
label: ` ${i + 1}. ${place.name}${timeStr}`,
})
}
}
return options
}
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete }) {
const toast = useToast() const toast = useToast()
const { t } = useTranslation() const { t, locale } = useTranslation()
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
const [form, setForm] = useState({ const [form, setForm] = useState({
title: '', type: 'other', status: 'pending', title: '', type: 'other', status: 'pending',
reservation_time: '', location: '', confirmation_number: '', reservation_time: '', location: '', confirmation_number: '',
notes: '', day_id: '', place_id: '', notes: '', assignment_id: '',
}) })
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [uploadingFile, setUploadingFile] = useState(false) const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState([]) // for new reservations const [pendingFiles, setPendingFiles] = useState([])
const assignmentOptions = useMemo(
() => buildAssignmentOptions(days, assignments, t, locale),
[days, assignments, t, locale]
)
useEffect(() => { useEffect(() => {
if (reservation) { if (reservation) {
@@ -42,14 +69,13 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
location: reservation.location || '', location: reservation.location || '',
confirmation_number: reservation.confirmation_number || '', confirmation_number: reservation.confirmation_number || '',
notes: reservation.notes || '', notes: reservation.notes || '',
day_id: reservation.day_id || '', assignment_id: reservation.assignment_id || '',
place_id: reservation.place_id || '',
}) })
} else { } else {
setForm({ setForm({
title: '', type: 'other', status: 'pending', title: '', type: 'other', status: 'pending',
reservation_time: '', location: '', confirmation_number: '', reservation_time: '', location: '', confirmation_number: '',
notes: '', day_id: selectedDayId || '', place_id: '', notes: '', assignment_id: '',
}) })
setPendingFiles([]) setPendingFiles([])
} }
@@ -64,10 +90,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
try { try {
const saved = await onSave({ const saved = await onSave({
...form, ...form,
day_id: form.day_id || null, assignment_id: form.assignment_id || null,
place_id: form.place_id || null,
}) })
// Upload pending files for newly created reservations
if (!reservation?.id && saved?.id && pendingFiles.length > 0) { if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
for (const file of pendingFiles) { for (const file of pendingFiles) {
const fd = new FormData() const fd = new FormData()
@@ -86,7 +110,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
if (reservation?.id) { if (reservation?.id) {
// Existing reservation upload immediately
setUploadingFile(true) setUploadingFile(true)
try { try {
const fd = new FormData() const fd = new FormData()
@@ -102,7 +125,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
e.target.value = '' e.target.value = ''
} }
} else { } else {
// New reservation stage locally
setPendingFiles(prev => [...prev, file]) setPendingFiles(prev => [...prev, file])
e.target.value = '' e.target.value = ''
} }
@@ -112,29 +134,29 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const inputStyle = { const inputStyle = {
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
padding: '8px 14px', fontSize: 13, fontFamily: 'inherit', padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)', outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)',
} }
const labelStyle = { display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 5 } const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' }
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="md"> <Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="2xl">
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Type selector */} {/* Type selector */}
<div> <div>
<label style={labelStyle}>{t('reservations.bookingType')}</label> <label style={labelStyle}>{t('reservations.bookingType')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => ( {TYPE_OPTIONS.map(({ value, labelKey, Icon }) => (
<button key={value} type="button" onClick={() => set('type', value)} style={{ <button key={value} type="button" onClick={() => set('type', value)} style={{
display: 'flex', alignItems: 'center', gap: 5, display: 'flex', alignItems: 'center', gap: 4,
padding: '6px 11px', borderRadius: 99, border: '1px solid', padding: '5px 10px', borderRadius: 99, border: '1px solid',
fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s', fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
background: form.type === value ? 'var(--text-primary)' : 'var(--bg-card)', background: form.type === value ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: form.type === value ? 'var(--text-primary)' : 'var(--border-primary)', borderColor: form.type === value ? 'var(--text-primary)' : 'var(--border-primary)',
color: form.type === value ? 'var(--bg-primary)' : 'var(--text-muted)', color: form.type === value ? 'var(--bg-primary)' : 'var(--text-muted)',
}}> }}>
<Icon size={12} /> {t(labelKey)} <Icon size={11} /> {t(labelKey)}
</button> </button>
))} ))}
</div> </div>
@@ -147,8 +169,29 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} /> placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
</div> </div>
{/* Assignment Picker */}
{assignmentOptions.length > 0 && (
<div>
<label style={labelStyle}>
<Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
{t('reservations.linkAssignment')}
</label>
<CustomSelect
value={form.assignment_id}
onChange={value => set('assignment_id', value)}
placeholder={t('reservations.pickAssignment')}
options={[
{ value: '', label: t('reservations.noAssignment') },
...assignmentOptions,
]}
searchable
size="sm"
/>
</div>
)}
{/* Date/Time + Status */} {/* Date/Time + Status */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 10 }}> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<label style={labelStyle}>{t('reservations.datetime')}</label> <label style={labelStyle}>{t('reservations.datetime')}</label>
<CustomDateTimePicker value={form.reservation_time} onChange={v => set('reservation_time', v)} /> <CustomDateTimePicker value={form.reservation_time} onChange={v => set('reservation_time', v)} />
@@ -167,108 +210,61 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div> </div>
</div> </div>
{/* Location */} {/* Location + Booking Code */}
<div> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label style={labelStyle}>{t('reservations.locationAddress')}</label>
<input type="text" value={form.location} onChange={e => set('location', e.target.value)}
placeholder={t('reservations.locationPlaceholder')} style={inputStyle} />
</div>
{/* Confirmation number */}
<div>
<label style={labelStyle}>{t('reservations.confirmationCode')}</label>
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
</div>
{/* Linked day + place */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div> <div>
<label style={labelStyle}>{t('reservations.day')}</label> <label style={labelStyle}>{t('reservations.locationAddress')}</label>
<CustomSelect <input type="text" value={form.location} onChange={e => set('location', e.target.value)}
value={form.day_id} placeholder={t('reservations.locationPlaceholder')} style={inputStyle} />
onChange={value => set('day_id', value)}
placeholder={t('reservations.noDay')}
options={[
{ value: '', label: t('reservations.noDay') },
...(days || []).map(day => ({
value: day.id,
label: `${t('reservations.day')} ${day.day_number}${day.date ? ` · ${formatDate(day.date)}` : ''}`,
})),
]}
size="sm"
/>
</div> </div>
<div> <div>
<label style={labelStyle}>{t('reservations.place')}</label> <label style={labelStyle}>{t('reservations.confirmationCode')}</label>
<CustomSelect <input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
value={form.place_id} placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
onChange={value => set('place_id', value)}
placeholder={t('reservations.noPlace')}
options={[
{ value: '', label: t('reservations.noPlace') },
...(places || []).map(place => ({
value: place.id,
label: place.name,
})),
]}
searchable
size="sm"
/>
</div> </div>
</div> </div>
{/* Notes */} {/* Notes */}
<div> <div>
<label style={labelStyle}>{t('reservations.notes')}</label> <label style={labelStyle}>{t('reservations.notes')}</label>
<textarea value={form.notes} onChange={e => set('notes', e.target.value)} rows={3} <textarea value={form.notes} onChange={e => set('notes', e.target.value)} rows={2}
placeholder={t('reservations.notesPlaceholder')} placeholder={t('reservations.notesPlaceholder')}
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} /> style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div> </div>
{/* File upload — always visible */} {/* Files */}
<div> <div>
<label style={labelStyle}>{t('files.title')}</label> <label style={labelStyle}>{t('files.title')}</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{attachedFiles.map(f => ( {attachedFiles.map(f => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}> <div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
<FileText size={13} style={{ color: 'var(--text-muted)', flexShrink: 0 }} /> <FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12.5, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span> <span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }} title={t('common.open')}> <a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}><ExternalLink size={11} /></a>
<ExternalLink size={12} />
</a>
{onFileDelete && ( {onFileDelete && (
<button type="button" onClick={() => onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }} <button type="button" onClick={() => onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} <X size={11} />
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
<X size={12} />
</button> </button>
)} )}
</div> </div>
))} ))}
{pendingFiles.map((f, i) => ( {pendingFiles.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}> <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
<FileText size={13} style={{ color: 'var(--text-muted)', flexShrink: 0 }} /> <FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12.5, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span> <span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
<span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{t('reservations.pendingSave')}</span>
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))} <button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} <X size={11} />
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
<X size={12} />
</button> </button>
</div> </div>
))} ))}
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} /> <input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{ <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 12px', display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'var(--bg-card)', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
fontSize: 12.5, color: 'var(--text-muted)', cursor: uploadingFile ? 'default' : 'pointer', fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
fontFamily: 'inherit', transition: 'all 0.12s', }}>
}} <Paperclip size={11} />
onMouseEnter={e => { if (!uploadingFile) { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-secondary)' } }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-muted)' }}>
<Paperclip size={13} />
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')} {uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
</button> </button>
</div> </div>
@@ -276,10 +272,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
{/* Actions */} {/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-secondary)' }}> <button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}> <button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')} {isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button> </button>
</div> </div>
@@ -288,8 +284,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
) )
} }
function formatDate(dateStr) { function formatDate(dateStr, locale) {
if (!dateStr) return '' if (!dateStr) return ''
const d = new Date(dateStr + 'T00:00:00') const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' }) return d.toLocaleDateString(locale || 'de-DE', { day: 'numeric', month: 'short' })
} }
@@ -1,160 +1,52 @@
import React, { useState, useMemo } from 'react' import React, { useState, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
import CustomSelect from '../shared/CustomSelect'
import { import {
Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, MapPin, Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, MapPin,
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, MapPinned, X, Users, Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
ExternalLink, BookMarked, Lightbulb, ExternalLink, BookMarked, Lightbulb, Link2, Clock,
} from 'lucide-react' } from 'lucide-react'
const TYPE_OPTIONS = [ const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane }, { value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane, color: '#3b82f6' },
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel }, { value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel, color: '#8b5cf6' },
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils }, { value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils, color: '#ef4444' },
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train }, { value: 'train', labelKey: 'reservations.type.train', Icon: Train, color: '#06b6d4' },
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car }, { value: 'car', labelKey: 'reservations.type.car', Icon: Car, color: '#6b7280' },
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship }, { value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship, color: '#0ea5e9' },
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket }, { value: 'event', labelKey: 'reservations.type.event', Icon: Ticket, color: '#f59e0b' },
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users }, { value: 'tour', labelKey: 'reservations.type.tour', Icon: Users, color: '#10b981' },
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText }, { value: 'other', labelKey: 'reservations.type.other', Icon: FileText, color: '#6b7280' },
] ]
function typeIcon(type) { function getType(type) {
return (TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]).Icon return TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]
}
function typeLabelKey(type) {
return (TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]).labelKey
} }
function formatDateTimeWithLocale(str, locale, timeFormat) { function buildAssignmentLookup(days, assignments) {
if (!str) return null const map = {}
const d = new Date(str) for (const day of (days || [])) {
if (isNaN(d)) return str const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const datePart = d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'long' }) for (const a of da) {
const h = d.getHours(), m = d.getMinutes() if (!a.place) continue
let timePart map[a.id] = { dayNumber: day.day_number, dayTitle: day.title, dayDate: day.date, placeName: a.place.name, startTime: a.place.place_time, endTime: a.place.end_time }
if (timeFormat === '12h') {
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
timePart = `${h12}:${String(m).padStart(2, '0')} ${period}`
} else {
timePart = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
if (locale?.startsWith('de')) timePart += ' Uhr'
}
return `${datePart} · ${timePart}`
}
const inputStyle = {
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
padding: '8px 12px', fontSize: 13.5, fontFamily: 'inherit',
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-card)',
}
const labelStyle = { display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 5 }
function PlaceReservationEditModal({ item, tripId, onClose }) {
const { updatePlace } = useTripStore()
const toast = useToast()
const { t } = useTranslation()
const [form, setForm] = useState({
reservation_status: item.status === 'confirmed' ? 'confirmed' : 'pending',
reservation_datetime: item.reservation_time ? item.reservation_time.slice(0, 16) : '',
place_time: item.place_time || '',
reservation_notes: item.notes || '',
})
const [saving, setSaving] = useState(false)
const set = (f, v) => setForm(p => ({ ...p, [f]: v }))
const handleSave = async () => {
setSaving(true)
try {
await updatePlace(tripId, item.placeId, {
reservation_status: form.reservation_status,
reservation_datetime: form.reservation_datetime || null,
place_time: form.place_time || null,
reservation_notes: form.reservation_notes || null,
})
toast.success(t('reservations.toast.updated'))
onClose()
} catch {
toast.error(t('reservations.toast.saveError'))
} finally {
setSaving(false)
} }
} }
return map
return ReactDOM.createPortal(
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 9000,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
}} onClick={onClose}>
<div style={{
background: 'var(--bg-card)', borderRadius: 18, padding: 24, width: '100%', maxWidth: 420,
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('reservations.editTitle')}</h3>
<p style={{ margin: '3px 0 0', fontSize: 12, color: 'var(--text-faint)' }}>{item.title}</p>
</div>
<button onClick={onClose} style={{ background: 'var(--bg-tertiary)', border: 'none', borderRadius: '50%', width: 30, height: 30, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<X size={14} />
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.reservation_status}
onChange={v => set('reservation_status', v)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
/>
</div>
<div>
<label style={labelStyle}>{t('reservations.datetime')}</label>
<CustomDateTimePicker value={form.reservation_datetime} onChange={v => set('reservation_datetime', v)} />
</div>
<div>
<label style={labelStyle}>{t('reservations.notes')}</label>
<textarea value={form.reservation_notes} onChange={e => set('reservation_notes', e.target.value)} rows={3} placeholder={t('reservations.notesPlaceholder')} style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
<button onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={handleSave} disabled={saving} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--accent)', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: saving ? 0.6 : 1 }}>
{saving ? t('common.saving') : t('common.save')}
</button>
</div>
</div>
</div>,
document.body
)
} }
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles }) { function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }) {
const { toggleReservationStatus } = useTripStore() const { toggleReservationStatus } = useTripStore()
const toast = useToast() const toast = useToast()
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const TypeIcon = typeIcon(r.type) const typeInfo = getType(r.type)
const TypeIcon = typeInfo.Icon
const confirmed = r.status === 'confirmed' const confirmed = r.status === 'confirmed'
const attachedFiles = files.filter(f => f.reservation_id === r.id) const attachedFiles = files.filter(f => f.reservation_id === r.id)
const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null
const handleToggle = async () => { const handleToggle = async () => {
try { await toggleReservationStatus(tripId, r.id) } try { await toggleReservationStatus(tripId, r.id) }
@@ -165,184 +57,137 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) } try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
} }
return ( const fmtDate = (str) => {
<div style={{ background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-faint)', boxShadow: '0 1px 6px rgba(0,0,0,0.05)', overflow: 'hidden' }}> const d = new Date(str)
<div style={{ display: 'flex', alignItems: 'stretch' }}> return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
<div style={{ }
width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', const fmtTime = (str) => {
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(161,98,7,0.1)', const d = new Date(str)
borderRight: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(161,98,7,0.2)'}`, return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
}}>
<TypeIcon size={16} style={{ color: confirmed ? '#16a34a' : '#a16207' }} />
</div>
<div style={{ flex: 1, padding: '11px 13px', minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13.5, color: 'var(--text-primary)', lineHeight: 1.3 }}>{r.title}</div>
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>{t(typeLabelKey(r.type))}</div>
</div>
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
<button onClick={handleToggle} style={{
display: 'flex', alignItems: 'center', gap: 3, padding: '3px 8px', borderRadius: 99,
border: 'none', cursor: 'pointer', fontSize: 11, fontWeight: 500,
background: confirmed ? 'rgba(22,163,74,0.12)' : 'rgba(161,98,7,0.12)',
color: confirmed ? '#16a34a' : '#a16207',
}}>
{confirmed ? <><CheckCircle2 size={11} /> {t('reservations.confirmed')}</> : <><Circle size={11} /> {t('reservations.pending')}</>}
</button>
<button onClick={() => onEdit(r)} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Pencil size={12} /></button>
<button onClick={handleDelete} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Trash2 size={12} /></button>
</div>
</div>
<div style={{ marginTop: 7, display: 'flex', flexWrap: 'wrap', gap: '3px 10px' }}>
{r.reservation_time && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
<Calendar size={10} style={{ color: 'var(--text-faint)' }} />{formatDateTimeWithLocale(r.reservation_time, locale, timeFormat)}
</div>
)}
{r.location && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
<MapPin size={10} style={{ color: 'var(--text-faint)' }} />
<span style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.location}</span>
</div>
)}
</div>
<div style={{ marginTop: 5, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{r.confirmation_number && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 10.5, color: '#16a34a', background: 'rgba(22,163,74,0.1)', border: '1px solid rgba(22,163,74,0.2)', borderRadius: 99, padding: '1px 7px', fontWeight: 600 }}>
<Hash size={8} />{r.confirmation_number}
</span>
)}
{r.day_number != null && <span style={{ fontSize: 10.5, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '1px 7px' }}>{t('dayplan.dayN', { n: r.day_number })}</span>}
{r.place_name && <span style={{ fontSize: 10.5, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '1px 7px' }}>{r.place_name}</span>}
</div>
{r.notes && <p style={{ margin: '7px 0 0', fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, borderTop: '1px solid var(--border-secondary)', paddingTop: 7 }}>{r.notes}</p>}
{/* Attached files — read-only, upload only via edit modal */}
{attachedFiles.length > 0 && (
<div style={{ marginTop: 8, borderTop: '1px solid var(--border-secondary)', paddingTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{attachedFiles.map(f => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<FileText size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 11.5, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href={f.url} target="_blank" rel="noreferrer" style={{ display: 'flex', color: 'var(--text-faint)', flexShrink: 0 }} title={t('common.open')}>
<ExternalLink size={11} />
</a>
</div>
))}
<button onClick={onNavigateToFiles} style={{ alignSelf: 'flex-start', fontSize: 11, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: 0, textDecoration: 'underline', fontFamily: 'inherit' }}>
{t('reservations.showFiles')}
</button>
</div>
)}
</div>
</div>
</div>
)
}
function PlaceReservationCard({ item, tripId }) {
const { updatePlace } = useTripStore()
const toast = useToast()
const { t, locale } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const [editing, setEditing] = useState(false)
const confirmed = item.status === 'confirmed'
const handleDelete = async () => {
if (!confirm(t('reservations.confirm.remove', { name: item.title }))) return
try {
await updatePlace(tripId, item.placeId, {
reservation_status: 'none',
reservation_datetime: null,
place_time: null,
reservation_notes: null,
})
toast.success(t('reservations.toast.removed'))
} catch { toast.error(t('reservations.toast.deleteError')) }
} }
return ( return (
<> <div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
{editing && <PlaceReservationEditModal item={item} tripId={tripId} onClose={() => setEditing(false)} />} {/* Header bar */}
<div style={{ background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-faint)', boxShadow: '0 1px 6px rgba(0,0,0,0.05)', overflow: 'hidden' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)' }}>
<div style={{ display: 'flex', alignItems: 'stretch' }}> <div style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
<div style={{ <button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', {confirmed ? t('reservations.confirmed') : t('reservations.pending')}
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(161,98,7,0.1)', </button>
borderRight: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(161,98,7,0.2)'}`, <div style={{ width: 1, height: 10, background: 'var(--border-faint)' }} />
}}> <TypeIcon size={11} style={{ color: typeInfo.color, flexShrink: 0 }} />
<MapPinned size={16} style={{ color: confirmed ? '#16a34a' : '#a16207' }} /> <span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{t(typeInfo.labelKey)}</span>
</div> <span style={{ flex: 1 }} />
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={11} />
</button>
<button onClick={handleDelete} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={11} />
</button>
</div>
<div style={{ flex: 1, padding: '11px 13px', minWidth: 0 }}> {/* Details */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}> {(r.reservation_time || r.confirmation_number || r.location || linked) && (
<div style={{ minWidth: 0 }}> <div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontWeight: 600, fontSize: 13.5, color: 'var(--text-primary)', lineHeight: 1.3 }}>{item.title}</div> {/* Row 1: Date, Time, Code */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 2, flexWrap: 'nowrap', overflow: 'hidden' }}> {(r.reservation_time || r.confirmation_number) && (
<span className="hidden sm:inline" style={{ fontSize: 10.5, color: 'var(--text-faint)', flexShrink: 0 }}>{t('reservations.fromPlan')}</span> <div style={{ display: 'flex', gap: 0, borderRadius: 8, overflow: 'hidden', background: 'var(--bg-secondary)', boxShadow: '0 1px 6px rgba(0,0,0,0.08)' }}>
{item.dayLabel && <span style={{ fontSize: 10.5, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '0 6px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.dayLabel}</span>} {r.reservation_time && (
</div> <div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
</div> <div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.date')}</div>
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{fmtDate(r.reservation_time)}</div>
<span style={{
display: 'flex', alignItems: 'center', gap: 3, padding: '3px 8px', borderRadius: 99,
fontSize: 11, fontWeight: 500,
background: confirmed ? 'rgba(22,163,74,0.12)' : 'rgba(161,98,7,0.12)',
color: confirmed ? '#16a34a' : '#a16207',
}}>
{confirmed ? <><CheckCircle2 size={11} /> {t('reservations.confirmed')}</> : <><Circle size={11} /> {t('reservations.pending')}</>}
</span>
<button onClick={() => setEditing(true)} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Pencil size={12} /></button>
<button onClick={handleDelete} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Trash2 size={12} /></button>
</div>
</div>
<div style={{ marginTop: 7, display: 'flex', flexWrap: 'wrap', gap: '3px 10px' }}>
{item.reservation_time && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
<Calendar size={10} style={{ color: 'var(--text-faint)' }} />{formatDateTimeWithLocale(item.reservation_time, locale, timeFormat)}
</div> </div>
)} )}
{item.place_time && !item.reservation_time && ( {r.reservation_time && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}> <div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
<Calendar size={10} style={{ color: 'var(--text-faint)' }} />{item.place_time} <div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{fmtTime(r.reservation_time)}</div>
</div> </div>
)} )}
{item.location && ( {r.confirmation_number && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}> <div style={{ flex: 1, padding: '5px 10px', textAlign: 'center' }}>
<MapPin size={10} style={{ color: 'var(--text-faint)' }} /> <div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.confirmationCode')}</div>
<span style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.location}</span> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{r.confirmation_number}</div>
</div> </div>
)} )}
</div> </div>
)}
{/* Row 2: Location + Assignment */}
{(r.location || linked) && (
<div style={{ display: 'grid', gridTemplateColumns: r.location && linked ? '1fr 1fr' : '1fr', gap: 8, paddingTop: 6, borderTop: '1px solid var(--border-faint)' }}>
{r.location && (
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.locationAddress')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
<MapPin size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.location}</span>
</div>
</div>
)}
{linked && (
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.linkAssignment')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
<Link2 size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{linked.dayTitle || t('dayplan.dayN', { n: linked.dayNumber })} {linked.placeName}
{linked.startTime ? ` · ${linked.startTime}${linked.endTime ? ' ' + linked.endTime : ''}` : ''}
</span>
</div>
</div>
)}
</div>
)}
</div>
)}
{item.notes && <p style={{ margin: '7px 0 0', fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, borderTop: '1px solid var(--border-secondary)', paddingTop: 7 }}>{item.notes}</p>} {/* Notes */}
{r.notes && (
<div style={{ padding: '0 12px 8px' }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
<div style={{ padding: '5px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.5 }}>
{r.notes}
</div> </div>
</div> </div>
</div> )}
</>
{/* Files */}
{attachedFiles.length > 0 && (
<div style={{ padding: '0 12px 8px' }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('files.title')}</div>
<div style={{ padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', display: 'flex', flexDirection: 'column', gap: 3 }}>
{attachedFiles.map(f => (
<a key={f.id} href={f.url} target="_blank" rel="noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
<FileText size={9} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
</a>
))}
</div>
</div>
)}
</div>
) )
} }
function Section({ title, count, children, defaultOpen = true, accent }) { function Section({ title, count, children, defaultOpen = true, accent }) {
const [open, setOpen] = useState(defaultOpen) const [open, setOpen] = useState(defaultOpen)
return ( return (
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 16 }}>
<button onClick={() => setOpen(o => !o)} style={{ <button onClick={() => setOpen(o => !o)} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%', display: 'flex', alignItems: 'center', gap: 8, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 10, background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 8, fontFamily: 'inherit',
}}> }}>
{open ? <ChevronDown size={15} style={{ color: 'var(--text-faint)' }} /> : <ChevronRight size={15} style={{ color: 'var(--text-faint)' }} />} {open ? <ChevronDown size={14} style={{ color: 'var(--text-faint)' }} /> : <ChevronRight size={14} style={{ color: 'var(--text-faint)' }} />}
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>{title}</span> <span style={{ fontWeight: 700, fontSize: 12, color: 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{title}</span>
<span style={{ <span style={{
fontSize: 11, fontWeight: 600, padding: '1px 8px', borderRadius: 99, fontSize: 10, fontWeight: 700, padding: '1px 7px', borderRadius: 99,
background: accent === 'green' ? 'rgba(22,163,74,0.12)' : 'var(--bg-tertiary)', background: accent === 'green' ? 'rgba(22,163,74,0.1)' : 'var(--bg-tertiary)',
color: accent === 'green' ? '#16a34a' : 'var(--text-muted)', color: accent === 'green' ? '#16a34a' : 'var(--text-faint)',
}}>{count}</span> }}>{count}</span>
</button> </button>
{open && <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{children}</div>} {open && <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{children}</div>}
@@ -354,98 +199,66 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint')) const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
const placeReservations = useMemo(() => { const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
const result = []
for (const day of (days || [])) {
const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
for (const assignment of da) {
const place = assignment.place
if (!place || !place.reservation_status || place.reservation_status === 'none') continue
const dayLabel = day.title
? day.title
: day.date
? `${t('dayplan.dayN', { n: day.day_number })} · ${new Date(day.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}`
: t('dayplan.dayN', { n: day.day_number })
result.push({
_placeRes: true,
id: `place_${day.id}_${place.id}`,
placeId: place.id,
title: place.name,
status: place.reservation_status === 'confirmed' ? 'confirmed' : 'pending',
reservation_time: place.reservation_datetime || null,
place_time: place.place_time || null,
location: place.address || null,
notes: place.reservation_notes || null,
dayLabel,
})
}
}
return result
}, [days, assignments, locale])
const allPending = [...reservations.filter(r => r.status !== 'confirmed'), ...placeReservations.filter(r => r.status !== 'confirmed')] const allPending = reservations.filter(r => r.status !== 'confirmed')
const allConfirmed = [...reservations.filter(r => r.status === 'confirmed'), ...placeReservations.filter(r => r.status === 'confirmed')] const allConfirmed = reservations.filter(r => r.status === 'confirmed')
const total = allPending.length + allConfirmed.length const total = reservations.length
function renderCard(r) {
if (r._placeRes) return <PlaceReservationCard key={r.id} item={r} tripId={tripId} />
return <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} />
}
return ( return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}> <div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> {/* Header */}
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid var(--border-faint)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div> <div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('reservations.title')}</h2> <h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('reservations.title')}</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}> <p style={{ margin: '2px 0 0', fontSize: 12, color: 'var(--text-faint)' }}>
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })} {total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
</p> </p>
</div> </div>
<button onClick={onAdd} style={{ <button onClick={onAdd} style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 14px', borderRadius: 99, display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
fontSize: 12.5, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}> }}>
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span> <Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
</button> </button>
</div> </div>
{/* Hinweis — einmalig wegklickbar */} {/* Hint */}
{showHint && ( {showHint && (
<div style={{ margin: '12px 24px 8px', padding: '8px 12px', borderRadius: 10, background: 'var(--bg-hover)', display: 'flex', alignItems: 'flex-start', gap: 8 }}> <div style={{ margin: '12px 24px 4px', padding: '8px 12px', borderRadius: 10, background: 'var(--bg-hover)', display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<Lightbulb size={13} style={{ flexShrink: 0, marginTop: 1, color: 'var(--text-faint)' }} /> <Lightbulb size={12} style={{ flexShrink: 0, marginTop: 1, color: 'var(--text-faint)' }} />
<p style={{ fontSize: 11.5, color: 'var(--text-muted)', margin: 0, lineHeight: 1.5, flex: 1 }}> <p style={{ fontSize: 11, color: 'var(--text-muted)', margin: 0, lineHeight: 1.5, flex: 1 }}>{t('reservations.placeHint')}</p>
{t('reservations.placeHint')} <button onClick={() => { setShowHint(false); localStorage.setItem('hideReservationHint', '1') }}
</p> style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0 4px', color: 'var(--text-faint)', fontSize: 14, lineHeight: 1, flexShrink: 0 }}>×</button>
<button
onClick={() => { setShowHint(false); localStorage.setItem('hideReservationHint', '1') }}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0 4px', color: 'var(--text-faint)', fontSize: 16, lineHeight: 1, flexShrink: 0 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
>×</button>
</div> </div>
)} )}
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px' }}> {/* Content */}
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px' }}>
{total === 0 ? ( {total === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}> <div style={{ textAlign: 'center', padding: '60px 20px' }}>
<BookMarked size={40} style={{ marginBottom: 12, color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} /> <BookMarked size={36} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('reservations.empty')}</p> <p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('reservations.empty')}</p>
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('reservations.emptyHint')}</p> <p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0 }}>{t('reservations.emptyHint')}</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <>
{allPending.length > 0 && ( {allPending.length > 0 && (
<Section title={t('reservations.pending')} count={allPending.length} defaultOpen={true} accent="gray"> <Section title={t('reservations.pending')} count={allPending.length} accent="gray">
{allPending.map(renderCard)} <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
</div>
</Section> </Section>
)} )}
{allConfirmed.length > 0 && ( {allConfirmed.length > 0 && (
<Section title={t('reservations.confirmed')} count={allConfirmed.length} defaultOpen={true} accent="green"> <Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
{allConfirmed.map(renderCard)} <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
</div>
</Section> </Section>
)} )}
</div> </>
)} )}
</div> </div>
</div> </div>
+39 -61
View File
@@ -6,19 +6,7 @@ import { ReservationModal } from './ReservationModal'
import { PlaceDetailPanel } from './PlaceDetailPanel' import { PlaceDetailPanel } from './PlaceDetailPanel'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
const TABS = [
{ id: 'orte', label: 'Orte', icon: '📍' },
{ id: 'tagesplan', label: 'Tagesplan', icon: '📅' },
{ id: 'reservierungen', label: 'Reservierungen', icon: '🎫' },
{ id: 'packliste', label: 'Packliste', icon: '🎒' },
]
const TRANSPORT_MODES = [
{ value: 'driving', label: 'Auto', icon: '🚗' },
{ value: 'walking', label: 'Fuß', icon: '🚶' },
{ value: 'cycling', label: 'Rad', icon: '🚲' },
]
export function RightPanel({ export function RightPanel({
trip, days, places, categories, tags, trip, days, places, categories, tags,
@@ -31,7 +19,6 @@ export function RightPanel({
const [activeTab, setActiveTab] = useState('orte') const [activeTab, setActiveTab] = useState('orte')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState('') const [categoryFilter, setCategoryFilter] = useState('')
const [transportMode, setTransportMode] = useState('driving')
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false) const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
const [showReservationModal, setShowReservationModal] = useState(false) const [showReservationModal, setShowReservationModal] = useState(false)
const [editingReservation, setEditingReservation] = useState(null) const [editingReservation, setEditingReservation] = useState(null)
@@ -39,6 +26,14 @@ export function RightPanel({
const tripStore = useTripStore() const tripStore = useTripStore()
const toast = useToast() const toast = useToast()
const { t } = useTranslation()
const TABS = [
{ id: 'orte', label: t('planner.places'), icon: '📍' },
{ id: 'tagesplan', label: t('planner.dayPlan'), icon: '📅' },
{ id: 'reservierungen', label: t('planner.reservations'), icon: '🎫' },
{ id: 'packliste', label: t('planner.packingList'), icon: '🎒' },
]
// Filtered places for Orte tab // Filtered places for Orte tab
const filteredPlaces = places.filter(p => { const filteredPlaces = places.filter(p => {
@@ -83,22 +78,22 @@ export function RightPanel({
.map(p => ({ lat: p.lat, lng: p.lng })) .map(p => ({ lat: p.lat, lng: p.lng }))
if (waypoints.length < 2) { if (waypoints.length < 2) {
toast.error('Mindestens 2 Orte mit Koordinaten benötigt') toast.error(t('planner.minTwoPlaces'))
return return
} }
setIsCalculatingRoute(true) setIsCalculatingRoute(true)
try { try {
const result = await calculateRoute(waypoints, transportMode) const result = await calculateRoute(waypoints, 'walking')
if (result) { if (result) {
setRouteInfo({ distance: result.distanceText, duration: result.durationText }) setRouteInfo({ distance: result.distanceText, duration: result.durationText })
onRouteCalculated?.(result) onRouteCalculated?.(result)
toast.success('Route berechnet') toast.success(t('planner.routeCalculated'))
} else { } else {
toast.error('Route konnte nicht berechnet werden') toast.error(t('planner.routeCalcFailed'))
} }
} catch (err) { } catch (err) {
toast.error('Fehler bei der Routenberechnung') toast.error(t('planner.routeError'))
} finally { } finally {
setIsCalculatingRoute(false) setIsCalculatingRoute(false)
} }
@@ -113,14 +108,14 @@ export function RightPanel({
return a?.id return a?.id
}).filter(Boolean) }).filter(Boolean)
await onReorder(selectedDayId, optimizedIds) await onReorder(selectedDayId, optimizedIds)
toast.success('Route optimiert') toast.success(t('planner.routeOptimized'))
} }
const handleOpenGoogleMaps = () => { const handleOpenGoogleMaps = () => {
const places = dayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng) const places = dayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
const url = generateGoogleMapsUrl(places) const url = generateGoogleMapsUrl(places)
if (url) window.open(url, '_blank') if (url) window.open(url, '_blank')
else toast.error('Keine Orte mit Koordinaten vorhanden') else toast.error(t('planner.noGeoPlaces'))
} }
const handleMoveUp = async (idx) => { const handleMoveUp = async (idx) => {
@@ -146,10 +141,10 @@ export function RightPanel({
try { try {
if (editingReservation) { if (editingReservation) {
await tripStore.updateReservation(tripId, editingReservation.id, data) await tripStore.updateReservation(tripId, editingReservation.id, data)
toast.success('Reservierung aktualisiert') toast.success(t('planner.reservationUpdated'))
} else { } else {
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null }) await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
toast.success('Reservierung hinzugefügt') toast.success(t('planner.reservationAdded'))
} }
setShowReservationModal(false) setShowReservationModal(false)
} catch (err) { } catch (err) {
@@ -158,10 +153,10 @@ export function RightPanel({
} }
const handleDeleteReservation = async (id) => { const handleDeleteReservation = async (id) => {
if (!confirm('Reservierung löschen?')) return if (!confirm(t('planner.confirmDeleteReservation'))) return
try { try {
await tripStore.deleteReservation(tripId, id) await tripStore.deleteReservation(tripId, id)
toast.success('Reservierung gelöscht') toast.success(t('planner.reservationDeleted'))
} catch (err) { } catch (err) {
toast.error(err.message) toast.error(err.message)
} }
@@ -226,7 +221,7 @@ export function RightPanel({
type="text" type="text"
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
placeholder="Orte suchen..." placeholder={t('planner.searchPlaces')}
className="w-full pl-8 pr-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-900" className="w-full pl-8 pr-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
/> />
{search && ( {search && (
@@ -241,7 +236,7 @@ export function RightPanel({
onChange={e => setCategoryFilter(e.target.value)} onChange={e => setCategoryFilter(e.target.value)}
className="flex-1 border border-gray-200 rounded-lg text-xs py-1.5 px-2 focus:outline-none focus:ring-1 focus:ring-slate-900 text-gray-600" className="flex-1 border border-gray-200 rounded-lg text-xs py-1.5 px-2 focus:outline-none focus:ring-1 focus:ring-slate-900 text-gray-600"
> >
<option value="">Alle Kategorien</option> <option value="">{t('planner.allCategories')}</option>
{categories.map(c => ( {categories.map(c => (
<option key={c.id} value={c.id}>{c.icon} {c.name}</option> <option key={c.id} value={c.id}>{c.icon} {c.name}</option>
))} ))}
@@ -251,7 +246,7 @@ export function RightPanel({
className="flex items-center gap-1 bg-slate-700 text-white text-xs px-3 py-1.5 rounded-lg hover:bg-slate-900 whitespace-nowrap" className="flex items-center gap-1 bg-slate-700 text-white text-xs px-3 py-1.5 rounded-lg hover:bg-slate-900 whitespace-nowrap"
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
Ort hinzufügen {t('planner.addPlace')}
</button> </button>
</div> </div>
</div> </div>
@@ -261,9 +256,9 @@ export function RightPanel({
{filteredPlaces.length === 0 ? ( {filteredPlaces.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400"> <div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">📍</span> <span className="text-3xl mb-2">📍</span>
<p className="text-sm">Keine Orte gefunden</p> <p className="text-sm">{t('planner.noPlacesFound')}</p>
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm hover:underline"> <button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm hover:underline">
Ersten Ort hinzufügen {t('planner.addFirstPlace')}
</button> </button>
</div> </div>
) : ( ) : (
@@ -299,7 +294,7 @@ export function RightPanel({
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }} onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
className="text-xs text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100" className="text-xs text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100"
> >
+ Tag {t('planner.addToDay')}
</button> </button>
)} )}
</div> </div>
@@ -312,7 +307,7 @@ export function RightPanel({
)} )}
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
{place.place_time && ( {place.place_time && (
<span className="text-xs text-gray-500">🕐 {place.place_time}</span> <span className="text-xs text-gray-500">🕐 {place.place_time}{place.end_time ? ` ${place.end_time}` : ''}</span>
)} )}
{place.price > 0 && ( {place.price > 0 && (
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
@@ -337,7 +332,7 @@ export function RightPanel({
{!selectedDayId ? ( {!selectedDayId ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400 px-6"> <div className="flex flex-col items-center justify-center py-16 text-gray-400 px-6">
<span className="text-4xl mb-3">📅</span> <span className="text-4xl mb-3">📅</span>
<p className="text-sm text-center">Wähle einen Tag aus der linken Liste um den Tagesplan zu sehen</p> <p className="text-sm text-center">{t('planner.selectDayHint')}</p>
</div> </div>
) : ( ) : (
<> <>
@@ -352,39 +347,22 @@ export function RightPanel({
)} )}
</h3> </h3>
<p className="text-xs text-slate-700 mt-0.5"> <p className="text-xs text-slate-700 mt-0.5">
{dayAssignments.length} Ort{dayAssignments.length !== 1 ? 'e' : ''} {dayAssignments.length === 1 ? t('planner.placeOne') : t('planner.placeN', { n: dayAssignments.length })}
{dayAssignments.length > 0 && ` · ${dayAssignments.reduce((s, a) => s + (a.place?.duration_minutes || 60), 0)} Min. gesamt`} {dayAssignments.length > 0 && ` · ${dayAssignments.reduce((s, a) => s + (a.place?.duration_minutes || 60), 0)} ${t('planner.minTotal')}`}
</p> </p>
</div> </div>
{/* Transport mode */}
<div className="px-3 py-2 border-b border-gray-100 flex items-center gap-1 flex-shrink-0">
{TRANSPORT_MODES.map(m => (
<button
key={m.value}
onClick={() => setTransportMode(m.value)}
className={`flex-1 py-1.5 text-xs rounded-lg flex items-center justify-center gap-1 transition-colors ${
transportMode === m.value
? 'bg-slate-100 text-slate-900 font-medium'
: 'text-gray-500 hover:bg-gray-100'
}`}
>
{m.icon} {m.label}
</button>
))}
</div>
{/* Places list with order */} {/* Places list with order */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{dayAssignments.length === 0 ? ( {dayAssignments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400"> <div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">🗺</span> <span className="text-3xl mb-2">🗺</span>
<p className="text-sm">Noch keine Orte für diesen Tag</p> <p className="text-sm">{t('planner.noPlacesForDay')}</p>
<button <button
onClick={() => setActiveTab('orte')} onClick={() => setActiveTab('orte')}
className="mt-3 text-slate-700 text-sm hover:underline" className="mt-3 text-slate-700 text-sm hover:underline"
> >
Orte hinzufügen {t('planner.addPlacesLink')}
</button> </button>
</div> </div>
) : ( ) : (
@@ -475,14 +453,14 @@ export function RightPanel({
className="flex items-center justify-center gap-1.5 bg-slate-700 text-white text-xs py-2 rounded-lg hover:bg-slate-900 disabled:opacity-60" className="flex items-center justify-center gap-1.5 bg-slate-700 text-white text-xs py-2 rounded-lg hover:bg-slate-900 disabled:opacity-60"
> >
<Navigation className="w-3.5 h-3.5" /> <Navigation className="w-3.5 h-3.5" />
{isCalculatingRoute ? 'Berechne...' : 'Route berechnen'} {isCalculatingRoute ? t('planner.calculating') : t('planner.route')}
</button> </button>
<button <button
onClick={handleOptimizeRoute} onClick={handleOptimizeRoute}
className="flex items-center justify-center gap-1.5 bg-emerald-600 text-white text-xs py-2 rounded-lg hover:bg-emerald-700" className="flex items-center justify-center gap-1.5 bg-emerald-600 text-white text-xs py-2 rounded-lg hover:bg-emerald-700"
> >
<RotateCcw className="w-3.5 h-3.5" /> <RotateCcw className="w-3.5 h-3.5" />
Optimieren {t('planner.optimize')}
</button> </button>
</div> </div>
<button <button
@@ -490,7 +468,7 @@ export function RightPanel({
className="w-full flex items-center justify-center gap-1.5 bg-white border border-gray-200 text-gray-700 text-xs py-2 rounded-lg hover:bg-gray-50" className="w-full flex items-center justify-center gap-1.5 bg-white border border-gray-200 text-gray-700 text-xs py-2 rounded-lg hover:bg-gray-50"
> >
<ExternalLink className="w-3.5 h-3.5" /> <ExternalLink className="w-3.5 h-3.5" />
In Google Maps öffnen {t('planner.openGoogleMaps')}
</button> </button>
</div> </div>
)} )}
@@ -504,7 +482,7 @@ export function RightPanel({
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="p-3 flex items-center justify-between border-b border-gray-100 flex-shrink-0"> <div className="p-3 flex items-center justify-between border-b border-gray-100 flex-shrink-0">
<h3 className="font-medium text-sm text-gray-900"> <h3 className="font-medium text-sm text-gray-900">
Reservierungen {t('planner.reservations')}
{selectedDay && <span className="text-gray-500 font-normal"> · Tag {selectedDay.day_number}</span>} {selectedDay && <span className="text-gray-500 font-normal"> · Tag {selectedDay.day_number}</span>}
</h3> </h3>
<button <button
@@ -512,7 +490,7 @@ export function RightPanel({
className="flex items-center gap-1 bg-slate-700 text-white text-xs px-2.5 py-1.5 rounded-lg hover:bg-slate-900" className="flex items-center gap-1 bg-slate-700 text-white text-xs px-2.5 py-1.5 rounded-lg hover:bg-slate-900"
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
Hinzufügen {t('common.add')}
</button> </button>
</div> </div>
@@ -520,9 +498,9 @@ export function RightPanel({
{filteredReservations.length === 0 ? ( {filteredReservations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400"> <div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">🎫</span> <span className="text-3xl mb-2">🎫</span>
<p className="text-sm">Keine Reservierungen</p> <p className="text-sm">{t('planner.noReservations')}</p>
<button onClick={handleAddReservation} className="mt-3 text-slate-700 text-sm hover:underline"> <button onClick={handleAddReservation} className="mt-3 text-slate-700 text-sm hover:underline">
Erste Reservierung hinzufügen {t('planner.addFirstReservation')}
</button> </button>
</div> </div>
) : ( ) : (
+79 -38
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import { Calendar, Camera, X } from 'lucide-react' import { Calendar, Camera, X, Clipboard } from 'lucide-react'
import { tripsApi } from '../../api/client' import { tripsApi } from '../../api/client'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
@@ -21,6 +21,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const [error, setError] = useState('') const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [coverPreview, setCoverPreview] = useState(null) const [coverPreview, setCoverPreview] = useState(null)
const [pendingCoverFile, setPendingCoverFile] = useState(null)
const [uploadingCover, setUploadingCover] = useState(false) const [uploadingCover, setUploadingCover] = useState(false)
useEffect(() => { useEffect(() => {
@@ -36,6 +37,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
setFormData({ title: '', description: '', start_date: '', end_date: '' }) setFormData({ title: '', description: '', start_date: '', end_date: '' })
setCoverPreview(null) setCoverPreview(null)
} }
setPendingCoverFile(null)
setError('') setError('')
}, [trip, isOpen]) }, [trip, isOpen])
@@ -48,12 +50,23 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
} }
setIsLoading(true) setIsLoading(true)
try { try {
await onSave({ const result = await onSave({
title: formData.title.trim(), title: formData.title.trim(),
description: formData.description.trim() || null, description: formData.description.trim() || null,
start_date: formData.start_date || null, start_date: formData.start_date || null,
end_date: formData.end_date || null, end_date: formData.end_date || null,
}) })
// Upload pending cover for newly created trips
if (pendingCoverFile && result?.trip?.id) {
try {
const fd = new FormData()
fd.append('cover', pendingCoverFile)
const data = await tripsApi.uploadCover(result.trip.id, fd)
onCoverUpdate?.(result.trip.id, data.cover_image)
} catch {
// Cover upload failed but trip was created
}
}
onClose() onClose()
} catch (err) { } catch (err) {
setError(err.message || t('places.saveError')) setError(err.message || t('places.saveError'))
@@ -62,9 +75,24 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
} }
} }
const handleCoverChange = async (e) => { const handleCoverSelect = (file) => {
const file = e.target.files?.[0] if (!file) return
if (!file || !trip?.id) return if (isEditing && trip?.id) {
// Existing trip: upload immediately
uploadCoverNow(file)
} else {
// New trip: stage for upload after creation
setPendingCoverFile(file)
setCoverPreview(URL.createObjectURL(file))
}
}
const handleCoverChange = (e) => {
handleCoverSelect(e.target.files?.[0])
e.target.value = ''
}
const uploadCoverNow = async (file) => {
setUploadingCover(true) setUploadingCover(true)
try { try {
const fd = new FormData() const fd = new FormData()
@@ -77,11 +105,15 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
toast.error(t('dashboard.coverUploadError')) toast.error(t('dashboard.coverUploadError'))
} finally { } finally {
setUploadingCover(false) setUploadingCover(false)
e.target.value = ''
} }
} }
const handleRemoveCover = async () => { const handleRemoveCover = async () => {
if (pendingCoverFile) {
setPendingCoverFile(null)
setCoverPreview(null)
return
}
if (!trip?.id) return if (!trip?.id) return
try { try {
await tripsApi.update(trip.id, { cover_image: null }) await tripsApi.update(trip.id, { cover_image: null })
@@ -92,15 +124,26 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
} }
} }
// Paste support for cover image
const handlePaste = (e) => {
const items = e.clipboardData?.items
if (!items) return
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault()
const file = item.getAsFile()
if (file) handleCoverSelect(file)
return
}
}
}
const update = (field, value) => setFormData(prev => { const update = (field, value) => setFormData(prev => {
const next = { ...prev, [field]: value } const next = { ...prev, [field]: value }
// Auto-adjust end date when start date changes
if (field === 'start_date' && value) { if (field === 'start_date' && value) {
if (!prev.end_date || prev.end_date < value) { if (!prev.end_date || prev.end_date < value) {
// If no end date or end date is before new start, set end = start
next.end_date = value next.end_date = value
} else if (prev.start_date) { } else if (prev.start_date) {
// Preserve trip duration: shift end date by same delta
const oldStart = new Date(prev.start_date + 'T00:00:00') const oldStart = new Date(prev.start_date + 'T00:00:00')
const oldEnd = new Date(prev.end_date + 'T00:00:00') const oldEnd = new Date(prev.end_date + 'T00:00:00')
const duration = Math.round((oldEnd - oldStart) / 86400000) const duration = Math.round((oldEnd - oldStart) / 86400000)
@@ -135,40 +178,38 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
</div> </div>
} }
> >
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}>
{error && ( {error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">{error}</div> <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">{error}</div>
)} )}
{/* Cover image — only for existing trips */} {/* Cover image — available for both create and edit */}
{isEditing && ( <div>
<div> <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.coverImage')}</label>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.coverImage')}</label> <input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleCoverChange} />
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleCoverChange} /> {coverPreview ? (
{coverPreview ? ( <div style={{ position: 'relative', borderRadius: 10, overflow: 'hidden', height: 130 }}>
<div style={{ position: 'relative', borderRadius: 10, overflow: 'hidden', height: 130 }}> <img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> <div style={{ position: 'absolute', bottom: 8, right: 8, display: 'flex', gap: 6 }}>
<div style={{ position: 'absolute', bottom: 8, right: 8, display: 'flex', gap: 6 }}> <button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '5px 10px', borderRadius: 8, background: 'rgba(0,0,0,0.55)', border: 'none', color: 'white', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', backdropFilter: 'blur(4px)' }}>
style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '5px 10px', borderRadius: 8, background: 'rgba(0,0,0,0.55)', border: 'none', color: 'white', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', backdropFilter: 'blur(4px)' }}> <Camera size={12} /> {uploadingCover ? t('common.uploading') : t('common.change')}
<Camera size={12} /> {uploadingCover ? t('common.uploading') : t('common.change')} </button>
</button> <button type="button" onClick={handleRemoveCover}
<button type="button" onClick={handleRemoveCover} style={{ display: 'flex', alignItems: 'center', padding: '5px 8px', borderRadius: 8, background: 'rgba(0,0,0,0.55)', border: 'none', color: 'white', cursor: 'pointer', backdropFilter: 'blur(4px)' }}>
style={{ display: 'flex', alignItems: 'center', padding: '5px 8px', borderRadius: 8, background: 'rgba(0,0,0,0.55)', border: 'none', color: 'white', cursor: 'pointer', backdropFilter: 'blur(4px)' }}> <X size={12} />
<X size={12} /> </button>
</button>
</div>
</div> </div>
) : ( </div>
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover} ) : (
style={{ width: '100%', padding: '18px', border: '2px dashed #e5e7eb', borderRadius: 10, background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: '#9ca3af', fontFamily: 'inherit' }} <button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.color = '#6b7280' }} style={{ width: '100%', padding: '18px', border: '2px dashed #e5e7eb', borderRadius: 10, background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: '#9ca3af', fontFamily: 'inherit' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}> onMouseEnter={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.color = '#6b7280' }}
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')} onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
</button> <Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
)} </button>
</div> )}
)} </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5"> <label className="block text-sm font-medium text-slate-700 mb-1.5">
@@ -46,21 +46,35 @@ export default function WeatherWidget({ lat, lng, date, compact = false }) {
const cached = getWeatherCache(cacheKey) const cached = getWeatherCache(cacheKey)
if (cached !== undefined) { if (cached !== undefined) {
if (cached === null) setFailed(true) if (cached === null) setFailed(true)
else setWeather(cached) // Climate data: use from cache but re-fetch in background to upgrade to forecast
else if (cached.type === 'climate') {
setWeather(cached)
weatherApi.get(lat, lng, date)
.then(data => {
if (!data.error && data.temp !== undefined && data.type === 'forecast') {
setWeatherCache(cacheKey, data)
setWeather(data)
}
})
.catch(() => {})
return
} else {
setWeather(cached)
return
}
return return
} }
setLoading(true) setLoading(true)
weatherApi.get(lat, lng, date) weatherApi.get(lat, lng, date)
.then(data => { .then(data => {
if (data.error || data.temp === undefined) { if (data.error || data.temp === undefined) {
setWeatherCache(cacheKey, null)
setFailed(true) setFailed(true)
} else { } else {
setWeatherCache(cacheKey, data) setWeatherCache(cacheKey, data)
setWeather(data) setWeather(data)
} }
}) })
.catch(() => { setWeatherCache(cacheKey, null); setFailed(true) }) .catch(() => { setFailed(true) })
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [lat, lng, date]) }, [lat, lng, date])
@@ -83,20 +97,21 @@ export default function WeatherWidget({ lat, lng, date, compact = false }) {
const rawTemp = weather.temp const rawTemp = weather.temp
const temp = rawTemp !== undefined ? Math.round(isFahrenheit ? rawTemp * 9/5 + 32 : rawTemp) : null const temp = rawTemp !== undefined ? Math.round(isFahrenheit ? rawTemp * 9/5 + 32 : rawTemp) : null
const unit = isFahrenheit ? '°F' : '°C' const unit = isFahrenheit ? '°F' : '°C'
const isClimate = weather.type === 'climate'
if (compact) { if (compact) {
return ( return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: '#6b7280', ...fontStyle }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
<WeatherIcon main={weather.main} size={12} /> <WeatherIcon main={weather.main} size={12} />
{temp !== null && <span>{temp}{unit}</span>} {temp !== null && <span>{isClimate ? 'Ø ' : ''}{temp}{unit}</span>}
</span> </span>
) )
} }
return ( return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: '#374151', background: 'rgba(0,0,0,0.04)', borderRadius: 8, padding: '5px 10px', ...fontStyle }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: isClimate ? '#71717a' : '#374151', background: 'rgba(0,0,0,0.04)', borderRadius: 8, padding: '5px 10px', ...fontStyle }}>
<WeatherIcon main={weather.main} size={15} /> <WeatherIcon main={weather.main} size={15} />
{temp !== null && <span style={{ fontWeight: 500 }}>{temp}{unit}</span>} {temp !== null && <span style={{ fontWeight: 500 }}>{isClimate ? 'Ø ' : ''}{temp}{unit}</span>}
{weather.description && <span style={{ fontSize: 11, color: '#9ca3af', textTransform: 'capitalize' }}>{weather.description}</span>} {weather.description && <span style={{ fontSize: 11, color: '#9ca3af', textTransform: 'capitalize' }}>{weather.description}</span>}
</div> </div>
) )
@@ -67,7 +67,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'} onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}> onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}>
<Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} /> <Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span>{displayValue || placeholder || t('common.date')}</span> <span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayValue || placeholder || t('common.date')}</span>
</button> </button>
{open && ReactDOM.createPortal( {open && ReactDOM.createPortal(
+12 -1
View File
@@ -51,7 +51,7 @@ export default function CustomSelect({
background: 'var(--bg-input)', color: 'var(--text-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)',
fontSize: 13, fontWeight: 500, fontFamily: 'inherit', fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
cursor: 'pointer', outline: 'none', textAlign: 'left', cursor: 'pointer', outline: 'none', textAlign: 'left',
transition: 'border-color 0.15s', transition: 'border-color 0.15s', overflow: 'hidden', minWidth: 0,
}} }}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'} onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }} onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
@@ -105,6 +105,17 @@ export default function CustomSelect({
<div style={{ padding: '10px 12px', fontSize: 12, color: 'var(--text-faint)', textAlign: 'center' }}></div> <div style={{ padding: '10px 12px', fontSize: 12, color: 'var(--text-faint)', textAlign: 'center' }}></div>
) : ( ) : (
filtered.map(option => { filtered.map(option => {
if (option.isHeader) {
return (
<div key={option.value} style={{
padding: '5px 10px', fontSize: 10, fontWeight: 700, color: 'var(--text-faint)',
textTransform: 'uppercase', letterSpacing: '0.03em',
background: 'var(--bg-tertiary)', borderRadius: 4, margin: '2px 0',
}}>
{option.label}
</div>
)
}
const isSelected = option.value === value const isSelected = option.value === value
return ( return (
<button <button
+11 -11
View File
@@ -25,17 +25,17 @@ export const CATEGORY_ICON_MAP = {
} }
export const ICON_LABELS = { export const ICON_LABELS = {
MapPin: 'Pin', Building2: 'Gebäude', BedDouble: 'Hotel', UtensilsCrossed: 'Restaurant', MapPin: 'Pin', Building2: 'Building', BedDouble: 'Hotel', UtensilsCrossed: 'Restaurant',
Landmark: 'Sehenswürdigkeit', ShoppingBag: 'Shopping', Bus: 'Bus', Train: 'Zug', Landmark: 'Attraction', ShoppingBag: 'Shopping', Bus: 'Bus', Train: 'Train',
Car: 'Auto', Plane: 'Flugzeug', Ship: 'Schiff', Bike: 'Fahrrad', Car: 'Car', Plane: 'Plane', Ship: 'Ship', Bike: 'Bicycle',
Activity: 'Aktivität', Dumbbell: 'Fitness', Mountain: 'Berg', Tent: 'Camping', Activity: 'Activity', Dumbbell: 'Fitness', Mountain: 'Mountain', Tent: 'Camping',
Anchor: 'Hafen', Coffee: 'Café', Beer: 'Bar', Wine: 'Wein', Utensils: 'Essen', Anchor: 'Harbor', Coffee: 'Cafe', Beer: 'Bar', Wine: 'Wine', Utensils: 'Food',
Camera: 'Foto', Music: 'Musik', Theater: 'Theater', Ticket: 'Events', Camera: 'Photo', Music: 'Music', Theater: 'Theater', Ticket: 'Events',
TreePine: 'Natur', Waves: 'Strand', Leaf: 'Grün', Flower2: 'Garten', Sun: 'Sonne', TreePine: 'Nature', Waves: 'Beach', Leaf: 'Green', Flower2: 'Garden', Sun: 'Sun',
Globe: 'Welt', Compass: 'Erkundung', Flag: 'Flagge', Navigation: 'Navigation', Map: 'Karte', Globe: 'World', Compass: 'Explore', Flag: 'Flag', Navigation: 'Navigation', Map: 'Map',
Church: 'Kirche', Library: 'Museum', Store: 'Markt', Home: 'Unterkunft', Cross: 'Medizin', Church: 'Church', Library: 'Museum', Store: 'Market', Home: 'Accommodation', Cross: 'Medicine',
Heart: 'Favorit', Star: 'Top', CreditCard: 'Bank', Wifi: 'Internet', Heart: 'Favorite', Star: 'Top', CreditCard: 'Bank', Wifi: 'Internet',
Luggage: 'Gepäck', Backpack: 'Rucksack', Zap: 'Abenteuer', Luggage: 'Luggage', Backpack: 'Backpack', Zap: 'Adventure',
} }
export function getCategoryIcon(iconName) { export function getCategoryIcon(iconName) {
+191 -11
View File
@@ -28,6 +28,8 @@ const de = {
'common.update': 'Aktualisieren', 'common.update': 'Aktualisieren',
'common.change': 'Ändern', 'common.change': 'Ändern',
'common.uploading': 'Hochladen…', 'common.uploading': 'Hochladen…',
'common.backToPlanning': 'Zurück zur Planung',
'common.reset': 'Zurücksetzen',
// Navbar // Navbar
'nav.trip': 'Reise', 'nav.trip': 'Reise',
@@ -37,6 +39,7 @@ const de = {
'nav.logout': 'Abmelden', 'nav.logout': 'Abmelden',
'nav.lightMode': 'Heller Modus', 'nav.lightMode': 'Heller Modus',
'nav.darkMode': 'Dunkler Modus', 'nav.darkMode': 'Dunkler Modus',
'nav.autoMode': 'Automatischer Modus',
'nav.administrator': 'Administrator', 'nav.administrator': 'Administrator',
// Dashboard // Dashboard
@@ -120,6 +123,7 @@ const de = {
'settings.colorMode': 'Farbmodus', 'settings.colorMode': 'Farbmodus',
'settings.light': 'Hell', 'settings.light': 'Hell',
'settings.dark': 'Dunkel', 'settings.dark': 'Dunkel',
'settings.auto': 'Automatisch',
'settings.language': 'Sprache', 'settings.language': 'Sprache',
'settings.temperature': 'Temperatureinheit', 'settings.temperature': 'Temperatureinheit',
'settings.timeFormat': 'Zeitformat', 'settings.timeFormat': 'Zeitformat',
@@ -138,14 +142,14 @@ const de = {
'settings.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein', 'settings.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein',
'settings.passwordMismatch': 'Passwörter stimmen nicht überein', 'settings.passwordMismatch': 'Passwörter stimmen nicht überein',
'settings.passwordChanged': 'Passwort erfolgreich geändert', 'settings.passwordChanged': 'Passwort erfolgreich geändert',
'settings.deleteAccount': 'Account löschen', 'settings.deleteAccount': 'Löschen',
'settings.deleteAccountTitle': 'Account wirklich löschen?', 'settings.deleteAccountTitle': 'Account wirklich löschen?',
'settings.deleteAccountWarning': 'Dein Account und alle deine Reisen, Orte und Dateien werden unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.', 'settings.deleteAccountWarning': 'Dein Account und alle deine Reisen, Orte und Dateien werden unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.',
'settings.deleteAccountConfirm': 'Endgültig löschen', 'settings.deleteAccountConfirm': 'Endgültig löschen',
'settings.deleteBlockedTitle': 'Löschung nicht möglich', 'settings.deleteBlockedTitle': 'Löschung nicht möglich',
'settings.deleteBlockedMessage': 'Du bist der einzige Administrator. Ernenne zuerst einen anderen Benutzer zum Admin, bevor du deinen Account löschen kannst.', 'settings.deleteBlockedMessage': 'Du bist der einzige Administrator. Ernenne zuerst einen anderen Benutzer zum Admin, bevor du deinen Account löschen kannst.',
'settings.roleUser': 'Benutzer', 'settings.roleUser': 'Benutzer',
'settings.saveProfile': 'Profil speichern', 'settings.saveProfile': 'Speichern',
'settings.toast.mapSaved': 'Karteneinstellungen gespeichert', 'settings.toast.mapSaved': 'Karteneinstellungen gespeichert',
'settings.toast.keysSaved': 'API-Schlüssel gespeichert', 'settings.toast.keysSaved': 'API-Schlüssel gespeichert',
'settings.toast.displaySaved': 'Anzeigeeinstellungen gespeichert', 'settings.toast.displaySaved': 'Anzeigeeinstellungen gespeichert',
@@ -191,6 +195,35 @@ const de = {
'login.register': 'Registrieren', 'login.register': 'Registrieren',
'login.emailPlaceholder': 'deine@email.de', 'login.emailPlaceholder': 'deine@email.de',
'login.username': 'Benutzername', 'login.username': 'Benutzername',
'login.oidc.registrationDisabled': 'Registrierung ist deaktiviert. Kontaktiere den Administrator.',
'login.oidc.noEmail': 'Keine E-Mail vom Provider erhalten.',
'login.oidc.tokenFailed': 'Authentifizierung fehlgeschlagen.',
'login.oidc.invalidState': 'Ungültige Sitzung. Bitte erneut versuchen.',
'login.demoFailed': 'Demo-Login fehlgeschlagen',
'login.oidcSignIn': 'Anmelden mit {name}',
'login.demoHint': 'Demo ausprobieren — ohne Registrierung',
// Register
'register.passwordMismatch': 'Passwörter stimmen nicht überein',
'register.passwordTooShort': 'Passwort muss mindestens 6 Zeichen lang sein',
'register.failed': 'Registrierung fehlgeschlagen',
'register.getStarted': 'Jetzt starten',
'register.subtitle': 'Erstellen Sie ein Konto und beginnen Sie, Ihre Traumreisen zu planen.',
'register.feature1': 'Unbegrenzte Reisepläne',
'register.feature2': 'Interaktive Kartenansicht',
'register.feature3': 'Orte und Kategorien verwalten',
'register.feature4': 'Reservierungen tracken',
'register.feature5': 'Packlisten erstellen',
'register.feature6': 'Fotos und Dateien speichern',
'register.createAccount': 'Konto erstellen',
'register.startPlanning': 'Beginnen Sie Ihre Reiseplanung',
'register.minChars': 'Mind. 6 Zeichen',
'register.confirmPassword': 'Passwort bestätigen',
'register.repeatPassword': 'Passwort wiederholen',
'register.registering': 'Registrieren...',
'register.register': 'Registrieren',
'register.hasAccount': 'Bereits ein Konto?',
'register.signIn': 'Anmelden',
// Admin // Admin
'admin.title': 'Administration', 'admin.title': 'Administration',
@@ -252,6 +285,8 @@ const de = {
'admin.tabs.addons': 'Addons', 'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons', 'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um NOMAD nach deinen Wünschen anzupassen.', 'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um NOMAD nach deinen Wünschen anzupassen.',
'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ',
'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.',
'admin.addons.enabled': 'Aktiviert', 'admin.addons.enabled': 'Aktiviert',
'admin.addons.disabled': 'Deaktiviert', 'admin.addons.disabled': 'Deaktiviert',
'admin.addons.type.trip': 'Trip', 'admin.addons.type.trip': 'Trip',
@@ -261,6 +296,48 @@ const de = {
'admin.addons.toast.updated': 'Addon aktualisiert', 'admin.addons.toast.updated': 'Addon aktualisiert',
'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden', 'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden',
'admin.addons.noAddons': 'Keine Addons verfügbar', 'admin.addons.noAddons': 'Keine Addons verfügbar',
// Weather info
'admin.weather.title': 'Wetterdaten',
'admin.weather.badge': 'Seit 24. März 2026',
'admin.weather.description': 'NOMAD nutzt Open-Meteo als Wetterdatenquelle. Open-Meteo ist ein kostenloser, quelloffener Wetterdienst — es wird kein API-Schlüssel benötigt.',
'admin.weather.forecast': '16-Tage-Vorhersage',
'admin.weather.forecastDesc': 'Statt bisher 5 Tage (OpenWeatherMap)',
'admin.weather.climate': 'Historische Klimadaten',
'admin.weather.climateDesc': 'Durchschnittswerte der letzten 85 Jahre für Tage jenseits der 16-Tage-Vorhersage',
'admin.weather.requests': '10.000 Anfragen / Tag',
'admin.weather.requestsDesc': 'Kostenlos, kein API-Schlüssel erforderlich',
'admin.weather.locationHint': 'Das Wetter wird anhand des ersten Ortes mit Koordinaten im jeweiligen Tag berechnet. Ist kein Ort am Tag eingeplant, wird ein beliebiger Ort aus der Ortsliste als Referenz verwendet.',
// GitHub
'admin.tabs.github': 'GitHub',
'admin.github.title': 'Update-Verlauf',
'admin.github.subtitle': 'Neueste Updates von {repo}',
'admin.github.latest': 'Aktuell',
'admin.github.prerelease': 'Vorabversion',
'admin.github.showDetails': 'Details anzeigen',
'admin.github.hideDetails': 'Details ausblenden',
'admin.github.loadMore': 'Mehr laden',
'admin.github.loading': 'Wird geladen...',
'admin.github.error': 'Releases konnten nicht geladen werden',
'admin.github.by': 'von',
'admin.update.available': 'Update verfügbar',
'admin.update.text': 'NOMAD {version} ist verfügbar. Du verwendest {current}.',
'admin.update.button': 'Auf GitHub ansehen',
'admin.update.install': 'Update installieren',
'admin.update.confirmTitle': 'Update installieren?',
'admin.update.confirmText': 'NOMAD wird von {current} auf {version} aktualisiert. Der Server startet danach automatisch neu.',
'admin.update.dataInfo': 'Alle Daten (Reisen, Benutzer, API-Schlüssel, Uploads, Vacay, Atlas, Budgets) bleiben erhalten.',
'admin.update.warning': 'Die App ist während des Neustarts kurz nicht erreichbar.',
'admin.update.confirm': 'Jetzt aktualisieren',
'admin.update.installing': 'Wird aktualisiert…',
'admin.update.success': 'Update installiert! Server startet neu…',
'admin.update.failed': 'Update fehlgeschlagen',
'admin.update.backupHint': 'Wir empfehlen, vor dem Update ein Backup zu erstellen und herunterzuladen.',
'admin.update.backupLink': 'Zum Backup',
'admin.update.howTo': 'Update-Anleitung',
'admin.update.dockerText': 'Deine NOMAD-Instanz läuft in Docker. Um auf {version} zu aktualisieren, führe folgende Befehle auf deinem Server aus:',
'admin.update.reloadHint': 'Bitte lade die Seite in wenigen Sekunden neu.',
// Vacay addon // Vacay addon
'vacay.subtitle': 'Urlaubstage planen und verwalten', 'vacay.subtitle': 'Urlaubstage planen und verwalten',
@@ -396,9 +473,6 @@ const de = {
'trip.confirm.deletePlace': 'Möchtest du diesen Ort wirklich löschen?', 'trip.confirm.deletePlace': 'Möchtest du diesen Ort wirklich löschen?',
// Day Plan Sidebar // Day Plan Sidebar
'dayplan.transport.car': 'Auto',
'dayplan.transport.walk': 'Zu Fuß',
'dayplan.transport.bike': 'Fahrrad',
'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant', 'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant',
'dayplan.addNote': 'Notiz hinzufügen', 'dayplan.addNote': 'Notiz hinzufügen',
'dayplan.editNote': 'Notiz bearbeiten', 'dayplan.editNote': 'Notiz bearbeiten',
@@ -414,6 +488,9 @@ const de = {
'dayplan.optimize': 'Optimieren', 'dayplan.optimize': 'Optimieren',
'dayplan.optimized': 'Route optimiert', 'dayplan.optimized': 'Route optimiert',
'dayplan.routeError': 'Fehler bei der Routenberechnung', 'dayplan.routeError': 'Fehler bei der Routenberechnung',
'dayplan.toast.needTwoPlaces': 'Mindestens zwei Orte für Routenoptimierung nötig',
'dayplan.toast.routeOptimized': 'Route optimiert',
'dayplan.toast.noGeoPlaces': 'Keine Orte mit Koordinaten für Routenberechnung gefunden',
'dayplan.confirmed': 'Bestätigt', 'dayplan.confirmed': 'Bestätigt',
'dayplan.pendingRes': 'Ausstehend', 'dayplan.pendingRes': 'Ausstehend',
'dayplan.pdf': 'PDF', 'dayplan.pdf': 'PDF',
@@ -444,6 +521,8 @@ const de = {
'places.noCategory': 'Keine Kategorie', 'places.noCategory': 'Keine Kategorie',
'places.categoryNamePlaceholder': 'Kategoriename', 'places.categoryNamePlaceholder': 'Kategoriename',
'places.formTime': 'Uhrzeit', 'places.formTime': 'Uhrzeit',
'places.startTime': 'Start',
'places.endTime': 'Ende',
'places.formWebsite': 'Website', 'places.formWebsite': 'Website',
'places.formNotesPlaceholder': 'Persönliche Notizen...', 'places.formNotesPlaceholder': 'Persönliche Notizen...',
'places.formReservation': 'Reservierung', 'places.formReservation': 'Reservierung',
@@ -455,11 +534,6 @@ const de = {
'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie', 'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie',
'places.nameRequired': 'Bitte einen Namen eingeben', 'places.nameRequired': 'Bitte einen Namen eingeben',
'places.saveError': 'Fehler beim Speichern', 'places.saveError': 'Fehler beim Speichern',
'places.transport.walking': '🚶 Zu Fuß',
'places.transport.driving': '🚗 Auto',
'places.transport.cycling': '🚲 Fahrrad',
'places.transport.transit': '🚌 ÖPNV',
// Place Inspector // Place Inspector
'inspector.opened': 'Geöffnet', 'inspector.opened': 'Geöffnet',
'inspector.closed': 'Geschlossen', 'inspector.closed': 'Geschlossen',
@@ -473,6 +547,8 @@ const de = {
'inspector.pendingRes': 'Ausstehende Reservierung', 'inspector.pendingRes': 'Ausstehende Reservierung',
'inspector.google': 'In Google Maps öffnen', 'inspector.google': 'In Google Maps öffnen',
'inspector.website': 'Webseite öffnen', 'inspector.website': 'Webseite öffnen',
'inspector.addRes': 'Reservierung',
'inspector.editRes': 'Reservierung bearbeiten',
// Reservations // Reservations
'reservations.title': 'Buchungen', 'reservations.title': 'Buchungen',
@@ -489,6 +565,8 @@ const de = {
'reservations.editTitle': 'Reservierung bearbeiten', 'reservations.editTitle': 'Reservierung bearbeiten',
'reservations.status': 'Status', 'reservations.status': 'Status',
'reservations.datetime': 'Datum & Uhrzeit', 'reservations.datetime': 'Datum & Uhrzeit',
'reservations.date': 'Datum',
'reservations.time': 'Uhrzeit',
'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)', 'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)',
'reservations.notes': 'Notizen', 'reservations.notes': 'Notizen',
'reservations.notesPlaceholder': 'Zusätzliche Notizen...', 'reservations.notesPlaceholder': 'Zusätzliche Notizen...',
@@ -516,7 +594,7 @@ const de = {
'reservations.titlePlaceholder': 'z.B. Lufthansa LH123, Hotel Adlon, ...', 'reservations.titlePlaceholder': 'z.B. Lufthansa LH123, Hotel Adlon, ...',
'reservations.locationAddress': 'Ort / Adresse', 'reservations.locationAddress': 'Ort / Adresse',
'reservations.locationPlaceholder': 'Adresse, Flughafen, Hotel...', 'reservations.locationPlaceholder': 'Adresse, Flughafen, Hotel...',
'reservations.confirmationCode': 'Bestätigungsnummer / Buchungscode', 'reservations.confirmationCode': 'Buchungscode',
'reservations.confirmationPlaceholder': 'z.B. ABC12345', 'reservations.confirmationPlaceholder': 'z.B. ABC12345',
'reservations.day': 'Tag', 'reservations.day': 'Tag',
'reservations.noDay': 'Kein Tag', 'reservations.noDay': 'Kein Tag',
@@ -525,6 +603,9 @@ const de = {
'reservations.pendingSave': 'wird gespeichert…', 'reservations.pendingSave': 'wird gespeichert…',
'reservations.uploading': 'Wird hochgeladen...', 'reservations.uploading': 'Wird hochgeladen...',
'reservations.attachFile': 'Datei anhängen', 'reservations.attachFile': 'Datei anhängen',
'reservations.linkAssignment': 'Mit Tagesplanung verknüpfen',
'reservations.pickAssignment': 'Zuordnung aus dem Plan wählen...',
'reservations.noAssignment': 'Keine Verknüpfung',
// Budget // Budget
'budget.title': 'Budget', 'budget.title': 'Budget',
@@ -573,6 +654,8 @@ const de = {
'files.toast.deleteError': 'Fehler beim Löschen der Datei', 'files.toast.deleteError': 'Fehler beim Löschen der Datei',
'files.sourcePlan': 'Tagesplan', 'files.sourcePlan': 'Tagesplan',
'files.sourceBooking': 'Buchung', 'files.sourceBooking': 'Buchung',
'files.attach': 'Anhängen',
'files.pasteHint': 'Du kannst auch Bilder aus der Zwischenablage einfügen (Strg+V)',
// Packing // Packing
'packing.title': 'Packliste', 'packing.title': 'Packliste',
@@ -725,6 +808,21 @@ const de = {
'backup.keep.30days': '30 Tage', 'backup.keep.30days': '30 Tage',
'backup.keep.forever': 'Immer behalten', 'backup.keep.forever': 'Immer behalten',
// Photos
'photos.allDays': 'Alle Tage',
'photos.noPhotos': 'Noch keine Fotos',
'photos.uploadHint': 'Lade deine Reisefotos hoch',
'photos.clickToSelect': 'oder klicken zum Auswählen',
'photos.linkPlace': 'Ort verknüpfen',
'photos.noPlace': 'Kein Ort',
'photos.uploadN': '{n} Foto(s) hochladen',
// Backup restore modal
'backup.restoreConfirmTitle': 'Backup wiederherstellen?',
'backup.restoreWarning': 'Alle aktuellen Daten (Reisen, Orte, Benutzer, Uploads) werden unwiderruflich durch das Backup ersetzt. Dieser Vorgang kann nicht rückgängig gemacht werden.',
'backup.restoreTip': 'Tipp: Erstelle zuerst ein Backup des aktuellen Stands, bevor du wiederherstellst.',
'backup.restoreConfirm': 'Ja, wiederherstellen',
// PDF // PDF
'pdf.travelPlan': 'Reiseplan', 'pdf.travelPlan': 'Reiseplan',
'pdf.planned': 'Eingeplant', 'pdf.planned': 'Eingeplant',
@@ -732,6 +830,68 @@ const de = {
'pdf.preview': 'PDF Vorschau', 'pdf.preview': 'PDF Vorschau',
'pdf.saveAsPdf': 'Als PDF speichern', 'pdf.saveAsPdf': 'Als PDF speichern',
// Planner
'planner.places': 'Orte',
'planner.bookings': 'Buchungen',
'planner.packingList': 'Packliste',
'planner.documents': 'Dokumente',
'planner.dayPlan': 'Tagesplan',
'planner.reservations': 'Reservierungen',
'planner.minTwoPlaces': 'Mindestens 2 Orte mit Koordinaten benötigt',
'planner.noGeoPlaces': 'Keine Orte mit Koordinaten vorhanden',
'planner.routeCalculated': 'Route berechnet',
'planner.routeCalcFailed': 'Route konnte nicht berechnet werden',
'planner.routeError': 'Fehler bei der Routenberechnung',
'planner.routeOptimized': 'Route optimiert',
'planner.reservationUpdated': 'Reservierung aktualisiert',
'planner.reservationAdded': 'Reservierung hinzugefügt',
'planner.confirmDeleteReservation': 'Reservierung löschen?',
'planner.reservationDeleted': 'Reservierung gelöscht',
'planner.days': 'Tage',
'planner.allPlaces': 'Alle Orte',
'planner.totalPlaces': '{n} Orte gesamt',
'planner.noDaysPlanned': 'Noch keine Tage geplant',
'planner.editTrip': 'Reise bearbeiten \u2192',
'planner.placeOne': '1 Ort',
'planner.placeN': '{n} Orte',
'planner.addNote': 'Notiz hinzufügen',
'planner.noEntries': 'Keine Einträge für diesen Tag',
'planner.addPlace': 'Ort hinzufügen',
'planner.addPlaceShort': '+ Ort hinzufügen',
'planner.resPending': 'Reservierung ausstehend · ',
'planner.resConfirmed': 'Reservierung bestätigt · ',
'planner.notePlaceholder': 'Notiz\u2026',
'planner.noteTimePlaceholder': 'Zeit (optional)',
'planner.noteExamplePlaceholder': 'z.B. S3 um 14:30 ab Hauptbahnhof, Fähre ab Pier 7, Mittagspause\u2026',
'planner.totalCost': 'Gesamtkosten',
'planner.searchPlaces': 'Orte suchen\u2026',
'planner.allCategories': 'Alle Kategorien',
'planner.noPlacesFound': 'Keine Orte gefunden',
'planner.addFirstPlace': 'Ersten Ort hinzufügen',
'planner.noReservations': 'Keine Reservierungen',
'planner.addFirstReservation': 'Erste Reservierung hinzufügen',
'planner.new': 'Neu',
'planner.addToDay': '+ Tag',
'planner.calculating': 'Berechne\u2026',
'planner.route': 'Route',
'planner.optimize': 'Optimieren',
'planner.openGoogleMaps': 'In Google Maps öffnen',
'planner.selectDayHint': 'Wähle einen Tag aus der linken Liste um den Tagesplan zu sehen',
'planner.noPlacesForDay': 'Noch keine Orte für diesen Tag',
'planner.addPlacesLink': 'Orte hinzufügen \u2192',
'planner.minTotal': 'Min. gesamt',
'planner.noReservation': 'Keine Reservierung',
'planner.removeFromDay': 'Aus Tag entfernen',
'planner.addToThisDay': 'Zum Tag hinzufügen',
'planner.overview': 'Gesamtübersicht',
'planner.noDays': 'Noch keine Tage',
'planner.editTripToAddDays': 'Reise bearbeiten um Tage hinzuzufügen',
'planner.dayCount': '{n} Tage',
'planner.clickToUnlock': 'Klicken zum Entsperren',
'planner.keepPosition': 'Position bei Routenoptimierung beibehalten',
'planner.dayDetails': 'Tagesdetails',
'planner.dayN': 'Tag {n}',
// Dashboard Stats // Dashboard Stats
'stats.countries': 'Länder', 'stats.countries': 'Länder',
'stats.cities': 'Städte', 'stats.cities': 'Städte',
@@ -741,6 +901,26 @@ const de = {
'stats.visited': 'besucht', 'stats.visited': 'besucht',
'stats.remaining': 'verbleibend', 'stats.remaining': 'verbleibend',
'stats.visitedCountries': 'Besuchte Länder', 'stats.visitedCountries': 'Besuchte Länder',
// Day Detail Panel
'day.precipProb': 'Regenwahrscheinlichkeit',
'day.precipitation': 'Niederschlag',
'day.wind': 'Wind',
'day.sunrise': 'Sonnenaufgang',
'day.sunset': 'Sonnenuntergang',
'day.hourlyForecast': 'Stündliche Vorhersage',
'day.climateHint': 'Historische Durchschnittswerte — echte Vorhersage verfügbar innerhalb von 16 Tagen vor diesem Datum.',
'day.noWeather': 'Keine Wetterdaten verfügbar. Füge einen Ort mit Koordinaten hinzu.',
'day.accommodation': 'Unterkunft',
'day.addAccommodation': 'Unterkunft hinzufügen',
'day.hotelDayRange': 'Auf Tage anwenden',
'day.noPlacesForHotel': 'Füge zuerst Orte zu deiner Reise hinzu',
'day.allDays': 'Alle',
'day.checkIn': 'Check-in',
'day.checkOut': 'Check-out',
'day.confirmation': 'Bestätigung',
'day.editAccommodation': 'Unterkunft bearbeiten',
'day.reservations': 'Reservierungen',
} }
export default de export default de
+189 -9
View File
@@ -28,6 +28,8 @@ const en = {
'common.update': 'Update', 'common.update': 'Update',
'common.change': 'Change', 'common.change': 'Change',
'common.uploading': 'Uploading…', 'common.uploading': 'Uploading…',
'common.backToPlanning': 'Back to Planning',
'common.reset': 'Reset',
// Navbar // Navbar
'nav.trip': 'Trip', 'nav.trip': 'Trip',
@@ -37,6 +39,7 @@ const en = {
'nav.logout': 'Log out', 'nav.logout': 'Log out',
'nav.lightMode': 'Light Mode', 'nav.lightMode': 'Light Mode',
'nav.darkMode': 'Dark Mode', 'nav.darkMode': 'Dark Mode',
'nav.autoMode': 'Auto Mode',
'nav.administrator': 'Administrator', 'nav.administrator': 'Administrator',
// Dashboard // Dashboard
@@ -120,6 +123,7 @@ const en = {
'settings.colorMode': 'Color Mode', 'settings.colorMode': 'Color Mode',
'settings.light': 'Light', 'settings.light': 'Light',
'settings.dark': 'Dark', 'settings.dark': 'Dark',
'settings.auto': 'Auto',
'settings.language': 'Language', 'settings.language': 'Language',
'settings.temperature': 'Temperature Unit', 'settings.temperature': 'Temperature Unit',
'settings.timeFormat': 'Time Format', 'settings.timeFormat': 'Time Format',
@@ -191,6 +195,35 @@ const en = {
'login.register': 'Register', 'login.register': 'Register',
'login.emailPlaceholder': 'your@email.com', 'login.emailPlaceholder': 'your@email.com',
'login.username': 'Username', 'login.username': 'Username',
'login.oidc.registrationDisabled': 'Registration is disabled. Contact your administrator.',
'login.oidc.noEmail': 'No email received from provider.',
'login.oidc.tokenFailed': 'Authentication failed.',
'login.oidc.invalidState': 'Invalid session. Please try again.',
'login.demoFailed': 'Demo login failed',
'login.oidcSignIn': 'Sign in with {name}',
'login.demoHint': 'Try the demo — no registration needed',
// Register
'register.passwordMismatch': 'Passwords do not match',
'register.passwordTooShort': 'Password must be at least 6 characters',
'register.failed': 'Registration failed',
'register.getStarted': 'Get Started',
'register.subtitle': 'Create an account and start planning your dream trips.',
'register.feature1': 'Unlimited trip plans',
'register.feature2': 'Interactive map view',
'register.feature3': 'Manage places and categories',
'register.feature4': 'Track reservations',
'register.feature5': 'Create packing lists',
'register.feature6': 'Store photos and files',
'register.createAccount': 'Create Account',
'register.startPlanning': 'Start your trip planning',
'register.minChars': 'Min. 6 characters',
'register.confirmPassword': 'Confirm Password',
'register.repeatPassword': 'Repeat password',
'register.registering': 'Registering...',
'register.register': 'Register',
'register.hasAccount': 'Already have an account?',
'register.signIn': 'Sign In',
// Admin // Admin
'admin.title': 'Administration', 'admin.title': 'Administration',
@@ -252,6 +285,8 @@ const en = {
'admin.tabs.addons': 'Addons', 'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons', 'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Enable or disable features to customize your NOMAD experience.', 'admin.addons.subtitle': 'Enable or disable features to customize your NOMAD experience.',
'admin.addons.subtitleBefore': 'Enable or disable features to customize your ',
'admin.addons.subtitleAfter': ' experience.',
'admin.addons.enabled': 'Enabled', 'admin.addons.enabled': 'Enabled',
'admin.addons.disabled': 'Disabled', 'admin.addons.disabled': 'Disabled',
'admin.addons.type.trip': 'Trip', 'admin.addons.type.trip': 'Trip',
@@ -261,6 +296,48 @@ const en = {
'admin.addons.toast.updated': 'Addon updated', 'admin.addons.toast.updated': 'Addon updated',
'admin.addons.toast.error': 'Failed to update addon', 'admin.addons.toast.error': 'Failed to update addon',
'admin.addons.noAddons': 'No addons available', 'admin.addons.noAddons': 'No addons available',
// Weather info
'admin.weather.title': 'Weather Data',
'admin.weather.badge': 'Since March 24, 2026',
'admin.weather.description': 'NOMAD uses Open-Meteo as its weather data source. Open-Meteo is a free, open-source weather service — no API key required.',
'admin.weather.forecast': '16-day forecast',
'admin.weather.forecastDesc': 'Previously 5 days (OpenWeatherMap)',
'admin.weather.climate': 'Historical climate data',
'admin.weather.climateDesc': 'Averages from the last 85 years for days beyond the 16-day forecast',
'admin.weather.requests': '10,000 requests / day',
'admin.weather.requestsDesc': 'Free, no API key required',
'admin.weather.locationHint': 'Weather is based on the first place with coordinates in each day. If no place is assigned to a day, any place from the place list is used as a reference.',
// GitHub
'admin.tabs.github': 'GitHub',
'admin.github.title': 'Release History',
'admin.github.subtitle': 'Latest updates from {repo}',
'admin.github.latest': 'Latest',
'admin.github.prerelease': 'Pre-release',
'admin.github.showDetails': 'Show details',
'admin.github.hideDetails': 'Hide details',
'admin.github.loadMore': 'Load more',
'admin.github.loading': 'Loading...',
'admin.github.error': 'Failed to load releases',
'admin.github.by': 'by',
'admin.update.available': 'Update available',
'admin.update.text': 'NOMAD {version} is available. You are running {current}.',
'admin.update.button': 'View on GitHub',
'admin.update.install': 'Install Update',
'admin.update.confirmTitle': 'Install Update?',
'admin.update.confirmText': 'NOMAD will be updated from {current} to {version}. The server will restart automatically afterwards.',
'admin.update.dataInfo': 'All your data (trips, users, API keys, uploads, Vacay, Atlas, budgets) will be preserved.',
'admin.update.warning': 'The app will be briefly unavailable during the restart.',
'admin.update.confirm': 'Update Now',
'admin.update.installing': 'Updating…',
'admin.update.success': 'Update installed! Server is restarting…',
'admin.update.failed': 'Update failed',
'admin.update.backupHint': 'We recommend creating a backup before updating.',
'admin.update.backupLink': 'Go to Backup',
'admin.update.howTo': 'How to Update',
'admin.update.dockerText': 'Your NOMAD instance runs in Docker. To update to {version}, run the following commands on your server:',
'admin.update.reloadHint': 'Please reload the page in a few seconds.',
// Vacay addon // Vacay addon
'vacay.subtitle': 'Plan and manage vacation days', 'vacay.subtitle': 'Plan and manage vacation days',
@@ -396,9 +473,6 @@ const en = {
'trip.confirm.deletePlace': 'Are you sure you want to delete this place?', 'trip.confirm.deletePlace': 'Are you sure you want to delete this place?',
// Day Plan Sidebar // Day Plan Sidebar
'dayplan.transport.car': 'Car',
'dayplan.transport.walk': 'Walk',
'dayplan.transport.bike': 'Bike',
'dayplan.emptyDay': 'No places planned for this day', 'dayplan.emptyDay': 'No places planned for this day',
'dayplan.addNote': 'Add Note', 'dayplan.addNote': 'Add Note',
'dayplan.editNote': 'Edit Note', 'dayplan.editNote': 'Edit Note',
@@ -414,6 +488,9 @@ const en = {
'dayplan.optimize': 'Optimize', 'dayplan.optimize': 'Optimize',
'dayplan.optimized': 'Route optimized', 'dayplan.optimized': 'Route optimized',
'dayplan.routeError': 'Failed to calculate route', 'dayplan.routeError': 'Failed to calculate route',
'dayplan.toast.needTwoPlaces': 'At least two places needed for route optimization',
'dayplan.toast.routeOptimized': 'Route optimized',
'dayplan.toast.noGeoPlaces': 'No places with coordinates found for route calculation',
'dayplan.confirmed': 'Confirmed', 'dayplan.confirmed': 'Confirmed',
'dayplan.pendingRes': 'Pending', 'dayplan.pendingRes': 'Pending',
'dayplan.pdf': 'PDF', 'dayplan.pdf': 'PDF',
@@ -444,6 +521,8 @@ const en = {
'places.noCategory': 'No Category', 'places.noCategory': 'No Category',
'places.categoryNamePlaceholder': 'Category name', 'places.categoryNamePlaceholder': 'Category name',
'places.formTime': 'Time', 'places.formTime': 'Time',
'places.startTime': 'Start',
'places.endTime': 'End',
'places.formWebsite': 'Website', 'places.formWebsite': 'Website',
'places.formNotesPlaceholder': 'Personal notes...', 'places.formNotesPlaceholder': 'Personal notes...',
'places.formReservation': 'Reservation', 'places.formReservation': 'Reservation',
@@ -455,11 +534,6 @@ const en = {
'places.categoryCreateError': 'Failed to create category', 'places.categoryCreateError': 'Failed to create category',
'places.nameRequired': 'Please enter a name', 'places.nameRequired': 'Please enter a name',
'places.saveError': 'Failed to save', 'places.saveError': 'Failed to save',
'places.transport.walking': '🚶 Walking',
'places.transport.driving': '🚗 Driving',
'places.transport.cycling': '🚲 Cycling',
'places.transport.transit': '🚌 Transit',
// Place Inspector // Place Inspector
'inspector.opened': 'Open', 'inspector.opened': 'Open',
'inspector.closed': 'Closed', 'inspector.closed': 'Closed',
@@ -473,6 +547,8 @@ const en = {
'inspector.pendingRes': 'Pending Reservation', 'inspector.pendingRes': 'Pending Reservation',
'inspector.google': 'Open in Google Maps', 'inspector.google': 'Open in Google Maps',
'inspector.website': 'Open Website', 'inspector.website': 'Open Website',
'inspector.addRes': 'Reservation',
'inspector.editRes': 'Edit Reservation',
// Reservations // Reservations
'reservations.title': 'Bookings', 'reservations.title': 'Bookings',
@@ -489,6 +565,8 @@ const en = {
'reservations.editTitle': 'Edit Reservation', 'reservations.editTitle': 'Edit Reservation',
'reservations.status': 'Status', 'reservations.status': 'Status',
'reservations.datetime': 'Date & Time', 'reservations.datetime': 'Date & Time',
'reservations.date': 'Date',
'reservations.time': 'Time',
'reservations.timeAlt': 'Time (alternative, e.g. 19:30)', 'reservations.timeAlt': 'Time (alternative, e.g. 19:30)',
'reservations.notes': 'Notes', 'reservations.notes': 'Notes',
'reservations.notesPlaceholder': 'Additional notes...', 'reservations.notesPlaceholder': 'Additional notes...',
@@ -512,7 +590,7 @@ const en = {
'reservations.titlePlaceholder': 'e.g. Lufthansa LH123, Hotel Adlon, ...', 'reservations.titlePlaceholder': 'e.g. Lufthansa LH123, Hotel Adlon, ...',
'reservations.locationAddress': 'Location / Address', 'reservations.locationAddress': 'Location / Address',
'reservations.locationPlaceholder': 'Address, Airport, Hotel...', 'reservations.locationPlaceholder': 'Address, Airport, Hotel...',
'reservations.confirmationCode': 'Confirmation Number / Booking Code', 'reservations.confirmationCode': 'Booking Code',
'reservations.confirmationPlaceholder': 'e.g. ABC12345', 'reservations.confirmationPlaceholder': 'e.g. ABC12345',
'reservations.day': 'Day', 'reservations.day': 'Day',
'reservations.noDay': 'No Day', 'reservations.noDay': 'No Day',
@@ -525,6 +603,9 @@ const en = {
'reservations.toast.updateError': 'Failed to update', 'reservations.toast.updateError': 'Failed to update',
'reservations.toast.deleteError': 'Failed to delete', 'reservations.toast.deleteError': 'Failed to delete',
'reservations.confirm.remove': 'Remove reservation for "{name}"?', 'reservations.confirm.remove': 'Remove reservation for "{name}"?',
'reservations.linkAssignment': 'Link to day assignment',
'reservations.pickAssignment': 'Select an assignment from your plan...',
'reservations.noAssignment': 'No link (standalone)',
// Budget // Budget
'budget.title': 'Budget', 'budget.title': 'Budget',
@@ -573,6 +654,8 @@ const en = {
'files.toast.deleteError': 'Failed to delete file', 'files.toast.deleteError': 'Failed to delete file',
'files.sourcePlan': 'Day Plan', 'files.sourcePlan': 'Day Plan',
'files.sourceBooking': 'Booking', 'files.sourceBooking': 'Booking',
'files.attach': 'Attach',
'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)',
// Packing // Packing
'packing.title': 'Packing List', 'packing.title': 'Packing List',
@@ -725,6 +808,21 @@ const en = {
'backup.keep.30days': '30 days', 'backup.keep.30days': '30 days',
'backup.keep.forever': 'Keep forever', 'backup.keep.forever': 'Keep forever',
// Photos
'photos.allDays': 'All Days',
'photos.noPhotos': 'No photos yet',
'photos.uploadHint': 'Upload your travel photos',
'photos.clickToSelect': 'or click to select',
'photos.linkPlace': 'Link Place',
'photos.noPlace': 'No Place',
'photos.uploadN': '{n} photo(s) upload',
// Backup restore modal
'backup.restoreConfirmTitle': 'Restore Backup?',
'backup.restoreWarning': 'All current data (trips, places, users, uploads) will be permanently replaced by the backup. This action cannot be undone.',
'backup.restoreTip': 'Tip: Create a backup of the current state before restoring.',
'backup.restoreConfirm': 'Yes, restore',
// PDF // PDF
'pdf.travelPlan': 'Travel Plan', 'pdf.travelPlan': 'Travel Plan',
'pdf.planned': 'Planned', 'pdf.planned': 'Planned',
@@ -732,6 +830,68 @@ const en = {
'pdf.preview': 'PDF Preview', 'pdf.preview': 'PDF Preview',
'pdf.saveAsPdf': 'Save as PDF', 'pdf.saveAsPdf': 'Save as PDF',
// Planner
'planner.places': 'Places',
'planner.bookings': 'Bookings',
'planner.packingList': 'Packing List',
'planner.documents': 'Documents',
'planner.dayPlan': 'Day Plan',
'planner.reservations': 'Reservations',
'planner.minTwoPlaces': 'At least 2 places with coordinates needed',
'planner.noGeoPlaces': 'No places with coordinates available',
'planner.routeCalculated': 'Route calculated',
'planner.routeCalcFailed': 'Route could not be calculated',
'planner.routeError': 'Error calculating route',
'planner.routeOptimized': 'Route optimized',
'planner.reservationUpdated': 'Reservation updated',
'planner.reservationAdded': 'Reservation added',
'planner.confirmDeleteReservation': 'Delete reservation?',
'planner.reservationDeleted': 'Reservation deleted',
'planner.days': 'Days',
'planner.allPlaces': 'All Places',
'planner.totalPlaces': '{n} places total',
'planner.noDaysPlanned': 'No days planned yet',
'planner.editTrip': 'Edit trip \u2192',
'planner.placeOne': '1 place',
'planner.placeN': '{n} places',
'planner.addNote': 'Add note',
'planner.noEntries': 'No entries for this day',
'planner.addPlace': 'Add place',
'planner.addPlaceShort': '+ Add place',
'planner.resPending': 'Reservation pending · ',
'planner.resConfirmed': 'Reservation confirmed · ',
'planner.notePlaceholder': 'Note\u2026',
'planner.noteTimePlaceholder': 'Time (optional)',
'planner.noteExamplePlaceholder': 'e.g. S3 at 14:30 from central station, ferry from pier 7, lunch break\u2026',
'planner.totalCost': 'Total cost',
'planner.searchPlaces': 'Search places\u2026',
'planner.allCategories': 'All Categories',
'planner.noPlacesFound': 'No places found',
'planner.addFirstPlace': 'Add first place',
'planner.noReservations': 'No reservations',
'planner.addFirstReservation': 'Add first reservation',
'planner.new': 'New',
'planner.addToDay': '+ Day',
'planner.calculating': 'Calculating\u2026',
'planner.route': 'Route',
'planner.optimize': 'Optimize',
'planner.openGoogleMaps': 'Open in Google Maps',
'planner.selectDayHint': 'Select a day from the left list to see the day plan',
'planner.noPlacesForDay': 'No places for this day yet',
'planner.addPlacesLink': 'Add places \u2192',
'planner.minTotal': 'min. total',
'planner.noReservation': 'No reservation',
'planner.removeFromDay': 'Remove from day',
'planner.addToThisDay': 'Add to day',
'planner.overview': 'Overview',
'planner.noDays': 'No days yet',
'planner.editTripToAddDays': 'Edit trip to add days',
'planner.dayCount': '{n} Days',
'planner.clickToUnlock': 'Click to unlock',
'planner.keepPosition': 'Keep position during route optimization',
'planner.dayDetails': 'Day details',
'planner.dayN': 'Day {n}',
// Dashboard Stats // Dashboard Stats
'stats.countries': 'Countries', 'stats.countries': 'Countries',
'stats.cities': 'Cities', 'stats.cities': 'Cities',
@@ -741,6 +901,26 @@ const en = {
'stats.visited': 'visited', 'stats.visited': 'visited',
'stats.remaining': 'remaining', 'stats.remaining': 'remaining',
'stats.visitedCountries': 'Visited Countries', 'stats.visitedCountries': 'Visited Countries',
// Day Detail Panel
'day.precipProb': 'Rain probability',
'day.precipitation': 'Precipitation',
'day.wind': 'Wind',
'day.sunrise': 'Sunrise',
'day.sunset': 'Sunset',
'day.hourlyForecast': 'Hourly Forecast',
'day.climateHint': 'Historical averages — real forecast available within 16 days of this date.',
'day.noWeather': 'No weather data available. Add a place with coordinates.',
'day.accommodation': 'Accommodation',
'day.addAccommodation': 'Add accommodation',
'day.hotelDayRange': 'Apply to days',
'day.noPlacesForHotel': 'Add places to your trip first',
'day.allDays': 'All',
'day.checkIn': 'Check-in',
'day.checkOut': 'Check-out',
'day.confirmation': 'Confirmation',
'day.editAccommodation': 'Edit accommodation',
'day.reservations': 'Reservations',
} }
export default en export default en
+13 -1
View File
@@ -2,9 +2,10 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
html { height: 100%; overflow: hidden; } html { height: 100%; overflow: hidden; background-color: var(--bg-primary); }
body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow-scrolling: touch; } body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow-scrolling: touch; }
.atlas-tooltip { .atlas-tooltip {
background: rgba(10, 10, 20, 0.6) !important; background: rgba(10, 10, 20, 0.6) !important;
backdrop-filter: blur(20px) saturate(180%) !important; backdrop-filter: blur(20px) saturate(180%) !important;
@@ -138,6 +139,8 @@ html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color
/* ── Design tokens ─────────────────────────────── */ /* ── Design tokens ─────────────────────────────── */
:root { :root {
--safe-top: env(safe-area-inset-top, 0px);
--nav-h: calc(56px + var(--safe-top));
--font-system: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif; --font-system: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
--sp-1: 4px; --sp-1: 4px;
--sp-2: 8px; --sp-2: 8px;
@@ -323,6 +326,15 @@ body {
color: var(--text-faint); color: var(--text-faint);
} }
/* Brand images: no save/copy/drag */
img[alt="NOMAD"] {
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-webkit-user-drag: none;
-webkit-touch-callout: none;
}
/* Weiche Übergänge */ /* Weiche Übergänge */
.transition-smooth { .transition-smooth {
transition: all 0.2s ease; transition: all 0.2s ease;
+275 -49
View File
@@ -9,8 +9,9 @@ import Modal from '../components/shared/Modal'
import { useToast } from '../components/shared/Toast' import { useToast } from '../components/shared/Toast'
import CategoryManager from '../components/Admin/CategoryManager' import CategoryManager from '../components/Admin/CategoryManager'
import BackupPanel from '../components/Admin/BackupPanel' import BackupPanel from '../components/Admin/BackupPanel'
import GitHubPanel from '../components/Admin/GitHubPanel'
import AddonManager from '../components/Admin/AddonManager' import AddonManager from '../components/Admin/AddonManager'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus } from 'lucide-react' import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun } from 'lucide-react'
import CustomSelect from '../components/shared/CustomSelect' import CustomSelect from '../components/shared/CustomSelect'
export default function AdminPage() { export default function AdminPage() {
@@ -23,6 +24,7 @@ export default function AdminPage() {
{ id: 'addons', label: t('admin.tabs.addons') }, { id: 'addons', label: t('admin.tabs.addons') },
{ id: 'settings', label: t('admin.tabs.settings') }, { id: 'settings', label: t('admin.tabs.settings') },
{ id: 'backup', label: t('admin.tabs.backup') }, { id: 'backup', label: t('admin.tabs.backup') },
{ id: 'github', label: t('admin.tabs.github') },
] ]
const [activeTab, setActiveTab] = useState('users') const [activeTab, setActiveTab] = useState('users')
@@ -49,6 +51,12 @@ export default function AdminPage() {
const [validating, setValidating] = useState({}) const [validating, setValidating] = useState({})
const [validation, setValidation] = useState({}) const [validation, setValidation] = useState({})
// Version check & update
const [updateInfo, setUpdateInfo] = useState(null)
const [showUpdateModal, setShowUpdateModal] = useState(false)
const [updating, setUpdating] = useState(false)
const [updateResult, setUpdateResult] = useState(null) // 'success' | 'error'
const { user: currentUser, updateApiKeys } = useAuthStore() const { user: currentUser, updateApiKeys } = useAuthStore()
const navigate = useNavigate() const navigate = useNavigate()
const toast = useToast() const toast = useToast()
@@ -58,6 +66,9 @@ export default function AdminPage() {
loadAppConfig() loadAppConfig()
loadApiKeys() loadApiKeys()
adminApi.getOidc().then(setOidcConfig).catch(() => {}) adminApi.getOidc().then(setOidcConfig).catch(() => {})
adminApi.checkVersion().then(data => {
if (data.update_available) setUpdateInfo(data)
}).catch(() => {})
}, []) }, [])
const loadData = async () => { const loadData = async () => {
@@ -95,6 +106,26 @@ export default function AdminPage() {
} }
} }
const handleInstallUpdate = async () => {
setUpdating(true)
setUpdateResult(null)
try {
await adminApi.installUpdate()
setUpdateResult('success')
// Server is restarting poll until it comes back, then reload
const poll = setInterval(async () => {
try {
await authApi.getAppConfig()
clearInterval(poll)
window.location.reload()
} catch { /* still restarting */ }
}, 2000)
} catch {
setUpdateResult('error')
setUpdating(false)
}
}
const handleToggleRegistration = async (value) => { const handleToggleRegistration = async (value) => {
setAllowRegistration(value) setAllowRegistration(value)
try { try {
@@ -209,7 +240,7 @@ export default function AdminPage() {
<div className="min-h-screen" style={{ background: 'var(--bg-secondary)' }}> <div className="min-h-screen" style={{ background: 'var(--bg-secondary)' }}>
<Navbar /> <Navbar />
<div className="pt-14"> <div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="max-w-6xl mx-auto px-4 py-8"> <div className="max-w-6xl mx-auto px-4 py-8">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
@@ -222,6 +253,53 @@ export default function AdminPage() {
</div> </div>
</div> </div>
{/* Update Banner */}
{updateInfo && (
<div className="mb-6 p-4 rounded-xl border flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-amber-50 dark:bg-amber-950/40 border-amber-300 dark:border-amber-700">
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center bg-amber-500 dark:bg-amber-600">
<ArrowUpCircle className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-sm font-semibold text-amber-900 dark:text-amber-200">{t('admin.update.available')}</p>
<p className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
{t('admin.update.text').replace('{version}', `v${updateInfo.latest}`).replace('{current}', `v${updateInfo.current}`)}
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{updateInfo.release_url && (
<a
href={updateInfo.release_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors text-amber-800 dark:text-amber-300 border border-amber-300 dark:border-amber-600 hover:bg-amber-100 dark:hover:bg-amber-900/50"
>
<ExternalLink className="w-3.5 h-3.5" />
{t('admin.update.button')}
</a>
)}
{updateInfo.is_docker ? (
<button
onClick={() => setShowUpdateModal(true)}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
>
<Download className="w-4 h-4" />
{t('admin.update.howTo')}
</button>
) : (
<button
onClick={() => setShowUpdateModal(true)}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
>
<Download className="w-4 h-4" />
{t('admin.update.install')}
</button>
)}
</div>
</div>
)}
{/* Demo Baseline Button */} {/* Demo Baseline Button */}
{demoMode && ( {demoMode && (
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl flex items-center justify-between"> <div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl flex items-center justify-between">
@@ -426,7 +504,7 @@ export default function AdminPage() {
<div> <div>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-1.5"> <label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-1.5">
{t('admin.mapsKey')} {t('admin.mapsKey')}
<span style={{ fontSize: 10, fontWeight: 500, padding: '1px 7px', borderRadius: 99, background: '#dbeafe', color: '#1d4ed8' }}>{t('admin.recommended')}</span> <span className="text-[9px] font-medium px-1.5 py-px rounded-full bg-emerald-200 dark:bg-emerald-800 text-emerald-800 dark:text-emerald-200">{t('admin.recommended')}</span>
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
@@ -475,54 +553,35 @@ export default function AdminPage() {
)} )}
</div> </div>
{/* OpenWeatherMap Key */} {/* Open-Meteo Weather Info */}
<div> <div className="rounded-lg border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/30 dark:border-emerald-800 overflow-hidden">
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.weatherKey')}</label> <div className="px-4 py-3 flex items-center justify-between">
<div className="flex gap-2"> <div className="flex items-center gap-2">
<div className="relative flex-1"> <div className="w-7 h-7 rounded-lg bg-emerald-500 flex items-center justify-center flex-shrink-0">
<input <Sun className="w-3.5 h-3.5 text-white" />
type={showKeys.weather ? 'text' : 'password'} </div>
value={weatherKey} <span className="text-sm font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.title')}</span>
onChange={e => setWeatherKey(e.target.value)} </div>
placeholder={t('settings.keyPlaceholder')} <span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-emerald-200 dark:bg-emerald-800 text-emerald-800 dark:text-emerald-200">{t('admin.weather.badge')}</span>
className="w-full pr-10 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" </div>
/> <div className="px-4 pb-3">
<button <p className="text-xs text-emerald-800 dark:text-emerald-300 leading-relaxed">{t('admin.weather.description')}</p>
type="button" <p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-1.5 leading-relaxed">{t('admin.weather.locationHint')}</p>
onClick={() => toggleKey('weather')} <div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2">
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600" <div className="rounded-md bg-white dark:bg-emerald-900/40 px-3 py-2 border border-emerald-100 dark:border-emerald-800">
> <p className="text-xs font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.forecast')}</p>
{showKeys.weather ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} <p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t('admin.weather.forecastDesc')}</p>
</button> </div>
<div className="rounded-md bg-white dark:bg-emerald-900/40 px-3 py-2 border border-emerald-100 dark:border-emerald-800">
<p className="text-xs font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.climate')}</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t('admin.weather.climateDesc')}</p>
</div>
<div className="rounded-md bg-white dark:bg-emerald-900/40 px-3 py-2 border border-emerald-100 dark:border-emerald-800">
<p className="text-xs font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.requests')}</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t('admin.weather.requestsDesc')}</p>
</div>
</div> </div>
<button
onClick={() => handleValidateKey('weather')}
disabled={!weatherKey || validating.weather}
className="px-3 py-2 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5"
>
{validating.weather ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : validation.weather === true ? (
<CheckCircle className="w-4 h-4 text-emerald-500" />
) : validation.weather === false ? (
<XCircle className="w-4 h-4 text-red-500" />
) : null}
{t('admin.validateKey')}
</button>
</div> </div>
<p className="text-xs text-slate-400 mt-1">{t('admin.weatherKeyHint')}</p>
{validation.weather === true && (
<p className="text-xs text-emerald-600 mt-1 flex items-center gap-1">
<span className="w-2 h-2 bg-emerald-500 rounded-full inline-block"></span>
{t('admin.keyValid')}
</p>
)}
{validation.weather === false && (
<p className="text-xs text-red-500 mt-1 flex items-center gap-1">
<span className="w-2 h-2 bg-red-500 rounded-full inline-block"></span>
{t('admin.keyInvalid')}
</p>
)}
</div> </div>
<button <button
@@ -606,6 +665,8 @@ export default function AdminPage() {
)} )}
{activeTab === 'backup' && <BackupPanel />} {activeTab === 'backup' && <BackupPanel />}
{activeTab === 'github' && <GitHubPanel />}
</div> </div>
</div> </div>
@@ -744,6 +805,171 @@ export default function AdminPage() {
</div> </div>
)} )}
</Modal> </Modal>
{/* Update confirmation popup — matches backup restore style */}
{showUpdateModal && (
<div
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={() => { if (!updating) setShowUpdateModal(false) }}
>
<div
onClick={e => e.stopPropagation()}
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
>
{updateResult === 'success' ? (
<>
<div style={{ background: 'linear-gradient(135deg, #16a34a, #15803d)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<CheckCircle size={20} style={{ color: 'white' }} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.success')}</h3>
</div>
</div>
<div style={{ padding: '20px 24px', textAlign: 'center' }}>
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2" style={{ color: 'var(--text-muted)' }} />
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('admin.update.reloadHint')}</p>
</div>
</>
) : updateResult === 'error' ? (
<>
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<XCircle size={20} style={{ color: 'white' }} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.failed')}</h3>
</div>
</div>
<div style={{ padding: '0 24px 20px', display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
<button
onClick={() => { setShowUpdateModal(false); setUpdateResult(null) }}
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
</div>
</>
) : (
<>
{/* Red header */}
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<AlertTriangle size={20} style={{ color: 'white' }} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.confirmTitle')}</h3>
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
v{updateInfo?.current} v{updateInfo?.latest}
</p>
</div>
</div>
{/* Body */}
<div style={{ padding: '20px 24px' }}>
{updateInfo?.is_docker ? (
<>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{t('admin.update.dockerText').replace('{version}', `v${updateInfo.latest}`)}
</p>
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
>
{`docker pull mauriceboe/nomad:latest
docker stop nomad && docker rm nomad
docker run -d --name nomad \\
-p 3000:3000 \\
-v /opt/nomad/data:/app/data \\
-v /opt/nomad/uploads:/app/uploads \\
--restart unless-stopped \\
mauriceboe/nomad:latest`}
</div>
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
>
<div className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{t('admin.update.dataInfo')}</span>
</div>
</div>
</>
) : (
<>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{updateInfo && t('admin.update.confirmText').replace('{current}', `v${updateInfo.current}`).replace('{version}', `v${updateInfo.latest}`)}
</p>
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
>
<div className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{t('admin.update.dataInfo')}</span>
</div>
</div>
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800"
>
<div className="flex items-start gap-2">
<Download className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>
{t('admin.update.backupHint')}{' '}
<button
onClick={() => { setShowUpdateModal(false); setActiveTab('backup') }}
className="underline font-semibold hover:text-blue-950 dark:hover:text-blue-100"
>{t('admin.update.backupLink')}</button>
</span>
</div>
</div>
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
>
<div className="flex items-start gap-2">
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{t('admin.update.warning')}</span>
</div>
</div>
</>
)}
</div>
{/* Footer */}
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
onClick={() => setShowUpdateModal(false)}
disabled={updating}
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
{!updateInfo?.is_docker && (
<button
onClick={handleInstallUpdate}
disabled={updating}
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200 disabled:opacity-60 flex items-center gap-2"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{updating ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
{updating ? t('admin.update.installing') : t('admin.update.confirm')}
</button>
)}
</div>
</>
)}
</div>
</div>
)}
</div> </div>
) )
} }
+6 -17
View File
@@ -81,7 +81,8 @@ export default function AtlasPage() {
const { settings } = useSettingsStore() const { settings } = useSettingsStore()
const navigate = useNavigate() const navigate = useNavigate()
const resolveName = useCountryNames(language) const resolveName = useCountryNames(language)
const dark = settings.dark_mode const dm = settings.dark_mode
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const mapRef = useRef(null) const mapRef = useRef(null)
const mapInstance = useRef(null) const mapInstance = useRef(null)
const geoLayerRef = useRef(null) const geoLayerRef = useRef(null)
@@ -270,7 +271,7 @@ export default function AtlasPage() {
return ( return (
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}> <div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
<Navbar /> <Navbar />
<div className="pt-14 flex items-center justify-center" style={{ minHeight: 'calc(100vh - 56px)' }}> <div className="flex items-center justify-center" style={{ paddingTop: 'var(--nav-h)', minHeight: 'calc(100vh - var(--nav-h))' }}>
<div className="w-8 h-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} /> <div className="w-8 h-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
</div> </div>
</div> </div>
@@ -280,7 +281,7 @@ export default function AtlasPage() {
return ( return (
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}> <div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
<Navbar /> <Navbar />
<div style={{ position: 'fixed', top: 56, left: 0, right: 0, bottom: 0 }}> <div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0 }}>
{/* Map */} {/* Map */}
<div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} /> <div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} />
@@ -421,23 +422,11 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
</div> </div>
</button> </button>
)} )}
{/* Next trip */}
{nextTrip && (
<button onClick={() => onTripClick(nextTrip.id)} className="flex items-center gap-2.5 text-left transition-opacity hover:opacity-75">
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'rgba(129,140,248,0.12)' }}>
<Calendar size={16} style={{ color: accent }} />
</div>
<div>
<p className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: tf }}>{t('atlas.nextTrip')}</p>
<p className="text-[13px] font-black" style={{ color: accent }}>{nextTrip.daysUntil} {t('atlas.daysLeft')}</p>
</div>
</button>
)}
{/* Streak */} {/* Streak */}
{streak > 0 && ( {streak > 0 && (
<div className="flex flex-col items-center justify-center px-3"> <div className="flex flex-col items-center justify-center px-3">
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{streak}</span> <span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{streak}</span>
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight" style={{ color: tf }}> <span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight whitespace-nowrap" style={{ color: tf }}>
{streak === 1 ? t('atlas.yearInRow') : t('atlas.yearsInRow')} {streak === 1 ? t('atlas.yearInRow') : t('atlas.yearsInRow')}
</span> </span>
</div> </div>
@@ -446,7 +435,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
{tripsThisYear > 0 && ( {tripsThisYear > 0 && (
<div className="flex flex-col items-center justify-center px-3"> <div className="flex flex-col items-center justify-center px-3">
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{tripsThisYear}</span> <span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{tripsThisYear}</span>
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight" style={{ color: tf }}> <span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight whitespace-nowrap" style={{ color: tf }}>
{tripsThisYear === 1 ? t('atlas.tripIn') : t('atlas.tripsIn')} {thisYear} {tripsThisYear === 1 ? t('atlas.tripIn') : t('atlas.tripsIn')} {thisYear}
</span> </span>
</div> </div>
+47 -10
View File
@@ -74,8 +74,42 @@ const GRADIENTS = [
] ]
function tripGradient(id) { return GRADIENTS[id % GRADIENTS.length] } function tripGradient(id) { return GRADIENTS[id % GRADIENTS.length] }
// Liquid Glass hover effect
function LiquidGlass({ children, dark, style, className = '', onClick }) {
const ref = useRef(null)
const glareRef = useRef(null)
const borderRef = useRef(null)
const onMove = (e) => {
if (!ref.current || !glareRef.current || !borderRef.current) return
const rect = ref.current.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
glareRef.current.style.background = `radial-gradient(circle 250px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'} 0%, transparent 70%)`
glareRef.current.style.opacity = '1'
borderRef.current.style.opacity = '1'
borderRef.current.style.maskImage = `radial-gradient(circle 120px at ${x}px ${y}px, black 0%, transparent 100%)`
borderRef.current.style.WebkitMaskImage = `radial-gradient(circle 120px at ${x}px ${y}px, black 0%, transparent 100%)`
}
const onLeave = () => {
if (glareRef.current) glareRef.current.style.opacity = '0'
if (borderRef.current) borderRef.current.style.opacity = '0'
}
return (
<div ref={ref} onMouseMove={onMove} onMouseLeave={onLeave} onClick={onClick} className={className}
style={{ position: 'relative', overflow: 'hidden', ...style }}>
<div ref={glareRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.3s', borderRadius: 'inherit', zIndex: 1 }} />
<div ref={borderRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.3s', borderRadius: 'inherit', zIndex: 1,
border: dark ? '1.5px solid rgba(255,255,255,0.4)' : '1.5px solid rgba(0,0,0,0.12)',
}} />
{children}
</div>
)
}
// Spotlight Card (next upcoming trip) // Spotlight Card (next upcoming trip)
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) { function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark }) {
const status = getTripStatus(trip) const status = getTripStatus(trip)
const coverBg = trip.cover_image const coverBg = trip.cover_image
@@ -83,7 +117,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }
: tripGradient(trip.id) : tripGradient(trip.id)
return ( return (
<div style={{ marginBottom: 32, borderRadius: 20, overflow: 'hidden', boxShadow: '0 8px 40px rgba(0,0,0,0.13)', position: 'relative', cursor: 'pointer' }} <LiquidGlass dark={dark} style={{ marginBottom: 32, borderRadius: 20, boxShadow: '0 8px 40px rgba(0,0,0,0.13)', cursor: 'pointer' }}
onClick={() => onClick(trip)}> onClick={() => onClick(trip)}>
{/* Cover / Background */} {/* Cover / Background */}
<div style={{ height: 300, background: coverBg, position: 'relative' }}> <div style={{ height: 300, background: coverBg, position: 'relative' }}>
@@ -151,7 +185,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }
</div> </div>
</div> </div>
</div> </div>
</div> </LiquidGlass>
) )
} }
@@ -170,9 +204,9 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) {
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
onClick={() => onClick(trip)} onClick={() => onClick(trip)}
style={{ style={{
background: 'var(--bg-card)', borderRadius: 16, overflow: 'hidden', cursor: 'pointer', background: hovered ? 'var(--bg-tertiary)' : 'var(--bg-card)', borderRadius: 16, overflow: 'hidden', cursor: 'pointer',
border: '1px solid var(--border-primary)', transition: 'all 0.18s', border: `1px solid ${hovered ? 'var(--text-faint)' : 'var(--border-primary)'}`, transition: 'all 0.18s',
boxShadow: hovered ? '0 8px 28px rgba(0,0,0,0.10)' : '0 1px 4px rgba(0,0,0,0.04)', boxShadow: hovered ? '0 8px 28px rgba(0,0,0,0.15)' : '0 1px 4px rgba(0,0,0,0.04)',
transform: hovered ? 'translateY(-2px)' : 'none', transform: hovered ? 'translateY(-2px)' : 'none',
}} }}
> >
@@ -354,6 +388,8 @@ export default function DashboardPage() {
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const { demoMode } = useAuthStore() const { demoMode } = useAuthStore()
const { settings, updateSetting } = useSettingsStore() const { settings, updateSetting } = useSettingsStore()
const dm = settings.dark_mode
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const showCurrency = settings.dashboard_currency !== 'off' const showCurrency = settings.dashboard_currency !== 'off'
const showTimezone = settings.dashboard_timezone !== 'off' const showTimezone = settings.dashboard_timezone !== 'off'
const showSidebar = showCurrency || showTimezone const showSidebar = showCurrency || showTimezone
@@ -390,6 +426,7 @@ export default function DashboardPage() {
const data = await tripsApi.create(tripData) const data = await tripsApi.create(tripData)
setTrips(prev => sortTrips([data.trip, ...prev])) setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.created')) toast.success(t('dashboard.toast.created'))
return data
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || t('dashboard.toast.createError')) throw new Error(err.response?.data?.error || t('dashboard.toast.createError'))
} }
@@ -456,7 +493,7 @@ export default function DashboardPage() {
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', background: 'var(--bg-secondary)', ...font }}> <div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', background: 'var(--bg-secondary)', ...font }}>
<Navbar /> <Navbar />
{demoMode && <DemoBanner />} {demoMode && <DemoBanner />}
<div style={{ flex: 1, overflow: 'auto', overscrollBehavior: 'contain', marginTop: 56 }}> <div style={{ flex: 1, overflow: 'auto', overscrollBehavior: 'contain', marginTop: 'var(--nav-h)' }}>
<div style={{ maxWidth: 1300, margin: '0 auto', padding: '32px 20px 60px' }}> <div style={{ maxWidth: 1300, margin: '0 auto', padding: '32px 20px 60px' }}>
{/* Header */} {/* Header */}
@@ -575,7 +612,7 @@ export default function DashboardPage() {
{!isLoading && spotlight && ( {!isLoading && spotlight && (
<SpotlightCard <SpotlightCard
trip={spotlight} trip={spotlight}
t={t} locale={locale} t={t} locale={locale} dark={dark}
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }} onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
onDelete={handleDelete} onDelete={handleDelete}
onArchive={handleArchive} onArchive={handleArchive}
@@ -635,8 +672,8 @@ export default function DashboardPage() {
{/* Widgets sidebar */} {/* Widgets sidebar */}
{showSidebar && ( {showSidebar && (
<div className="hidden lg:flex flex-col gap-4" style={{ position: 'sticky', top: 80, flexShrink: 0, width: 280 }}> <div className="hidden lg:flex flex-col gap-4" style={{ position: 'sticky', top: 80, flexShrink: 0, width: 280 }}>
{showCurrency && <CurrencyWidget />} {showCurrency && <LiquidGlass dark={dark} style={{ borderRadius: 16 }}><CurrencyWidget /></LiquidGlass>}
{showTimezone && <TimezoneWidget />} {showTimezone && <LiquidGlass dark={dark} style={{ borderRadius: 16 }}><TimezoneWidget /></LiquidGlass>}
</div> </div>
)} )}
</div> </div>
+4 -2
View File
@@ -5,8 +5,10 @@ import { tripsApi, placesApi } from '../api/client'
import Navbar from '../components/Layout/Navbar' import Navbar from '../components/Layout/Navbar'
import FileManager from '../components/Files/FileManager' import FileManager from '../components/Files/FileManager'
import { ArrowLeft } from 'lucide-react' import { ArrowLeft } from 'lucide-react'
import { useTranslation } from '../i18n'
export default function FilesPage() { export default function FilesPage() {
const { t } = useTranslation()
const { id: tripId } = useParams() const { id: tripId } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const tripStore = useTripStore() const tripStore = useTripStore()
@@ -61,7 +63,7 @@ export default function FilesPage() {
<div className="min-h-screen bg-slate-50"> <div className="min-h-screen bg-slate-50">
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} /> <Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
<div className="pt-14"> <div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="max-w-5xl mx-auto px-4 py-6"> <div className="max-w-5xl mx-auto px-4 py-6">
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
<Link <Link
@@ -69,7 +71,7 @@ export default function FilesPage() {
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700" className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Zurück zur Planung {t('common.backToPlanning')}
</Link> </Link>
</div> </div>
+176 -25
View File
@@ -29,9 +29,11 @@ export default function LoginPage() {
} }
}) })
// Handle OIDC callback token // Handle OIDC callback token (via URL fragment to avoid logging)
const hash = window.location.hash.substring(1)
const hashParams = new URLSearchParams(hash)
const token = hashParams.get('token')
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const token = params.get('token')
const oidcError = params.get('oidc_error') const oidcError = params.get('oidc_error')
if (token) { if (token) {
localStorage.setItem('auth_token', token) localStorage.setItem('auth_token', token)
@@ -42,10 +44,10 @@ export default function LoginPage() {
} }
if (oidcError) { if (oidcError) {
const errorMessages = { const errorMessages = {
registration_disabled: language === 'de' ? 'Registrierung ist deaktiviert. Kontaktiere den Administrator.' : 'Registration is disabled. Contact your administrator.', registration_disabled: t('login.oidc.registrationDisabled'),
no_email: language === 'de' ? 'Keine E-Mail vom Provider erhalten.' : 'No email received from provider.', no_email: t('login.oidc.noEmail'),
token_failed: language === 'de' ? 'Authentifizierung fehlgeschlagen.' : 'Authentication failed.', token_failed: t('login.oidc.tokenFailed'),
invalid_state: language === 'de' ? 'Ungueltige Sitzung. Bitte erneut versuchen.' : 'Invalid session. Please try again.', invalid_state: t('login.oidc.invalidState'),
} }
setError(errorMessages[oidcError] || oidcError) setError(errorMessages[oidcError] || oidcError)
window.history.replaceState({}, '', '/login') window.history.replaceState({}, '', '/login')
@@ -57,14 +59,17 @@ export default function LoginPage() {
setIsLoading(true) setIsLoading(true)
try { try {
await demoLogin() await demoLogin()
navigate('/dashboard') setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
} catch (err) { } catch (err) {
setError(err.message || 'Demo-Login fehlgeschlagen') setError(err.message || t('login.demoFailed'))
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
} }
const [showTakeoff, setShowTakeoff] = useState(false)
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault()
setError('') setError('')
@@ -77,10 +82,10 @@ export default function LoginPage() {
} else { } else {
await login(email, password) await login(email, password)
} }
navigate('/dashboard') setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
} catch (err) { } catch (err) {
setError(err.message || t('login.error')) setError(err.message || t('login.error'))
} finally {
setIsLoading(false) setIsLoading(false)
} }
} }
@@ -93,6 +98,157 @@ export default function LoginPage() {
color: '#111827', background: 'white', boxSizing: 'border-box', transition: 'border-color 0.15s', color: '#111827', background: 'white', boxSizing: 'border-box', transition: 'border-color 0.15s',
} }
if (showTakeoff) {
return (
<div className="takeoff-overlay" style={{ position: 'fixed', inset: 0, zIndex: 99999, overflow: 'hidden' }}>
{/* Sky gradient */}
<div className="takeoff-sky" style={{ position: 'absolute', inset: 0 }} />
{/* Stars */}
{Array.from({ length: 60 }, (_, i) => (
<div key={i} className="takeoff-star" style={{
position: 'absolute',
width: Math.random() > 0.7 ? 3 : 1.5,
height: Math.random() > 0.7 ? 3 : 1.5,
borderRadius: '50%',
background: 'white',
top: `${Math.random() * 100}%`,
left: `${Math.random() * 100}%`,
animationDelay: `${0.3 + Math.random() * 0.5}s, ${Math.random() * 1}s`,
}} />
))}
{/* Clouds rushing past */}
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="takeoff-cloud" style={{
position: 'absolute',
width: 120 + i * 40,
height: 40 + i * 10,
borderRadius: '50%',
background: 'rgba(255,255,255,0.15)',
filter: 'blur(8px)',
right: -200,
top: `${25 + i * 12}%`,
animationDelay: `${0.3 + i * 0.25}s`,
}} />
))}
{/* Speed lines */}
{Array.from({ length: 12 }, (_, i) => (
<div key={i} className="takeoff-speedline" style={{
position: 'absolute',
width: 80 + Math.random() * 120,
height: 1.5,
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent)',
top: `${10 + Math.random() * 80}%`,
right: -200,
animationDelay: `${0.5 + i * 0.12}s`,
}} />
))}
{/* Plane */}
<div className="takeoff-plane" style={{ position: 'absolute', left: '50%', bottom: '10%', transform: 'translate(-50%, 0)' }}>
<svg viewBox="0 0 480 120" style={{ width: 200, filter: 'drop-shadow(0 0 20px rgba(255,255,255,0.3))' }}>
<g fill="white" transform="translate(240,60) rotate(-12)">
<ellipse cx="0" cy="0" rx="120" ry="12" />
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
<path d="M-100,-5 L-120,-30 L-108,-30 L-90,-8 Z" />
<path d="M-100,5 L-120,30 L-108,30 L-90,8 Z" />
<ellipse cx="60" cy="0" rx="18" ry="8" />
</g>
</svg>
</div>
{/* Contrail */}
<div className="takeoff-trail" style={{
position: 'absolute', left: '50%', bottom: '8%',
width: 3, height: 0, background: 'linear-gradient(to top, transparent, rgba(255,255,255,0.5))',
transformOrigin: 'bottom center',
}} />
{/* Logo fade in + burst */}
<div className="takeoff-logo" style={{
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
}}>
<img src="/logo-light.svg" alt="NOMAD" style={{ height: 72 }} />
<p style={{ margin: 0, fontSize: 20, color: 'rgba(255,255,255,0.6)', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase', whiteSpace: 'nowrap' }}>{t('login.tagline')}</p>
</div>
<style>{`
.takeoff-sky {
background: linear-gradient(to top, #1a1a2e 0%, #16213e 30%, #0f3460 60%, #0a0a23 100%);
animation: skyShift 2.6s ease-in-out forwards;
}
@keyframes skyShift {
0% { background: linear-gradient(to top, #0a0a23 0%, #0f172a 40%, #111827 100%); }
100% { background: linear-gradient(to top, #000011 0%, #000016 50%, #000011 100%); }
}
.takeoff-star {
opacity: 0;
animation: starAppear 0.5s ease-out forwards, starTwinkle 2s ease-in-out infinite alternate;
}
@keyframes starAppear {
0% { opacity: 0; transform: scale(0); }
100% { opacity: 0.7; transform: scale(1); }
}
@keyframes starTwinkle {
0% { opacity: 0.3; }
100% { opacity: 0.9; }
}
.takeoff-cloud {
animation: cloudRush 0.6s ease-in forwards;
}
@keyframes cloudRush {
0% { right: -200px; opacity: 0; }
20% { opacity: 0.4; }
100% { right: 120%; opacity: 0; }
}
.takeoff-speedline {
animation: speedRush 0.4s ease-in forwards;
}
@keyframes speedRush {
0% { right: -200px; opacity: 0; }
30% { opacity: 0.6; }
100% { right: 120%; opacity: 0; }
}
.takeoff-plane {
animation: planeUp 1s ease-in forwards;
}
@keyframes planeUp {
0% { transform: translate(-50%, 0) rotate(0deg) scale(1); bottom: 8%; left: 50%; opacity: 1; }
100% { transform: translate(-50%, 0) rotate(-22deg) scale(0.15); bottom: 120%; left: 58%; opacity: 0; }
}
.takeoff-trail {
animation: trailGrow 0.9s ease-out 0.15s forwards;
}
@keyframes trailGrow {
0% { height: 0; opacity: 0; transform: translateX(-50%) rotate(-5deg); }
30% { height: 150px; opacity: 0.6; }
60% { height: 350px; opacity: 0.4; }
100% { height: 600px; opacity: 0; transform: translateX(-50%) rotate(-8deg); }
}
.takeoff-logo {
opacity: 0;
animation: logoReveal 0.5s ease-out 0.9s forwards;
}
@keyframes logoReveal {
0% { opacity: 0; transform: translate(-50%, -40%) scale(0.9); }
100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
`}</style>
</div>
)
}
return ( return (
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}> <div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}>
@@ -213,14 +369,11 @@ export default function LoginPage() {
<div style={{ position: 'relative', zIndex: 1, maxWidth: 560, textAlign: 'center' }}> <div style={{ position: 'relative', zIndex: 1, maxWidth: 560, textAlign: 'center' }}>
{/* Logo */} {/* Logo */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 48, justifyContent: 'center' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 48 }}>
<div style={{ width: 48, height: 48, background: 'white', borderRadius: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 0 30px rgba(255,255,255,0.1)' }}> <img src="/logo-light.svg" alt="NOMAD" style={{ height: 64 }} />
<Plane size={24} style={{ color: '#0f172a' }} />
</div>
<span style={{ fontSize: 28, fontWeight: 800, color: 'white', letterSpacing: '-0.02em' }}>NOMAD</span>
</div> </div>
<h2 style={{ margin: '0 0 12px', fontSize: 36, fontWeight: 800, color: 'white', lineHeight: 1.15, letterSpacing: '-0.02em' }}> <h2 style={{ margin: '0 0 12px', fontSize: 36, fontWeight: 700, color: 'white', lineHeight: 1.15, letterSpacing: '-0.02em', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase' }}>
{t('login.tagline')} {t('login.tagline')}
</h2> </h2>
<p style={{ margin: '0 0 44px', fontSize: 15, color: 'rgba(255,255,255,0.5)', lineHeight: 1.7 }}> <p style={{ margin: '0 0 44px', fontSize: 15, color: 'rgba(255,255,255,0.5)', lineHeight: 1.7 }}>
@@ -259,13 +412,11 @@ export default function LoginPage() {
<div style={{ width: '100%', maxWidth: 400 }}> <div style={{ width: '100%', maxWidth: 400 }}>
{/* Mobile logo */} {/* Mobile logo */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 36, justifyContent: 'center' }} <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, marginBottom: 36 }}
className="mobile-logo"> className="mobile-logo">
<style>{`@media(min-width:1024px){.mobile-logo{display:none!important}}`}</style> <style>{`@media(min-width:1024px){.mobile-logo{display:none!important}}`}</style>
<div style={{ width: 36, height: 36, background: '#111827', borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <img src="/logo-dark.svg" alt="NOMAD" style={{ height: 48 }} />
<Plane size={18} style={{ color: 'white' }} /> <p style={{ margin: 0, fontSize: 16, color: '#9ca3af', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase', whiteSpace: 'nowrap' }}>{t('login.tagline')}</p>
</div>
<span style={{ fontSize: 22, fontWeight: 800, color: '#111827', letterSpacing: '-0.02em' }}>NOMAD</span>
</div> </div>
<div style={{ background: 'white', borderRadius: 20, border: '1px solid #e5e7eb', padding: '36px 32px', boxShadow: '0 2px 16px rgba(0,0,0,0.06)' }}> <div style={{ background: 'white', borderRadius: 20, border: '1px solid #e5e7eb', padding: '36px 32px', boxShadow: '0 2px 16px rgba(0,0,0,0.06)' }}>
@@ -344,7 +495,7 @@ export default function LoginPage() {
> >
{isLoading {isLoading
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : t('login.signingIn')}</> ? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : t('login.signingIn')}</>
: mode === 'register' ? t('login.createAccount') : t('login.signIn') : <><Plane size={16} />{mode === 'register' ? t('login.createAccount') : t('login.signIn')}</>
} }
</button> </button>
</form> </form>
@@ -366,7 +517,7 @@ export default function LoginPage() {
<> <>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}>
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} /> <div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
<span style={{ fontSize: 12, color: '#9ca3af' }}>{language === 'de' ? 'oder' : 'or'}</span> <span style={{ fontSize: 12, color: '#9ca3af' }}>{t('common.or')}</span>
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} /> <div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
</div> </div>
<a href="/api/auth/oidc/login" <a href="/api/auth/oidc/login"
@@ -383,7 +534,7 @@ export default function LoginPage() {
onMouseLeave={e => { e.currentTarget.style.background = 'white'; e.currentTarget.style.borderColor = '#d1d5db' }} onMouseLeave={e => { e.currentTarget.style.background = 'white'; e.currentTarget.style.borderColor = '#d1d5db' }}
> >
<Shield size={16} /> <Shield size={16} />
{language === 'de' ? `Anmelden mit ${appConfig.oidc_display_name}` : `Sign in with ${appConfig.oidc_display_name}`} {t('login.oidcSignIn', { name: appConfig.oidc_display_name })}
</a> </a>
</> </>
)} )}
@@ -404,7 +555,7 @@ export default function LoginPage() {
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 12px rgba(245, 158, 11, 0.3)' }} onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 12px rgba(245, 158, 11, 0.3)' }}
> >
<Plane size={18} /> <Plane size={18} />
Demo ausprobieren ohne Registrierung {t('login.demoHint')}
</button> </button>
)} )}
</div> </div>
+4 -2
View File
@@ -5,8 +5,10 @@ import { tripsApi, daysApi, placesApi } from '../api/client'
import Navbar from '../components/Layout/Navbar' import Navbar from '../components/Layout/Navbar'
import PhotoGallery from '../components/Photos/PhotoGallery' import PhotoGallery from '../components/Photos/PhotoGallery'
import { ArrowLeft } from 'lucide-react' import { ArrowLeft } from 'lucide-react'
import { useTranslation } from '../i18n'
export default function PhotosPage() { export default function PhotosPage() {
const { t } = useTranslation()
const { id: tripId } = useParams() const { id: tripId } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const tripStore = useTripStore() const tripStore = useTripStore()
@@ -71,7 +73,7 @@ export default function PhotosPage() {
<div className="min-h-screen bg-slate-50"> <div className="min-h-screen bg-slate-50">
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} /> <Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
<div className="pt-14"> <div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="max-w-7xl mx-auto px-4 py-6"> <div className="max-w-7xl mx-auto px-4 py-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
@@ -80,7 +82,7 @@ export default function PhotosPage() {
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700" className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Zurück zur Planung {t('common.backToPlanning')}
</Link> </Link>
</div> </div>
+27 -25
View File
@@ -1,9 +1,11 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../store/authStore' import { useAuthStore } from '../store/authStore'
import { useTranslation } from '../i18n'
import { Map, Eye, EyeOff, Mail, Lock, User } from 'lucide-react' import { Map, Eye, EyeOff, Mail, Lock, User } from 'lucide-react'
export default function RegisterPage() { export default function RegisterPage() {
const { t } = useTranslation()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@@ -20,12 +22,12 @@ export default function RegisterPage() {
setError('') setError('')
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError('Passwörter stimmen nicht überein') setError(t('register.passwordMismatch'))
return return
} }
if (password.length < 6) { if (password.length < 6) {
setError('Passwort muss mindestens 6 Zeichen lang sein') setError(t('register.passwordTooShort'))
return return
} }
@@ -34,7 +36,7 @@ export default function RegisterPage() {
await register(username, email, password) await register(username, email, password)
navigate('/dashboard') navigate('/dashboard')
} catch (err) { } catch (err) {
setError(err.message || 'Registrierung fehlgeschlagen') setError(err.message || t('register.failed'))
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -48,19 +50,19 @@ export default function RegisterPage() {
<div className="w-20 h-20 bg-white/10 rounded-2xl flex items-center justify-center mx-auto mb-6"> <div className="w-20 h-20 bg-white/10 rounded-2xl flex items-center justify-center mx-auto mb-6">
<Map className="w-10 h-10 text-white" /> <Map className="w-10 h-10 text-white" />
</div> </div>
<h1 className="text-4xl font-bold mb-4">Jetzt starten</h1> <h1 className="text-4xl font-bold mb-4">{t('register.getStarted')}</h1>
<p className="text-slate-300 text-lg leading-relaxed"> <p className="text-slate-300 text-lg leading-relaxed">
Erstellen Sie ein Konto und beginnen Sie, Ihre Traumreisen zu planen. {t('register.subtitle')}
</p> </p>
<div className="mt-10 space-y-3 text-left"> <div className="mt-10 space-y-3 text-left">
{[ {[
'✓ Unbegrenzte Reisepläne', `${t('register.feature1')}`,
'✓ Interaktive Kartenansicht', `${t('register.feature2')}`,
'✓ Orte und Kategorien verwalten', `${t('register.feature3')}`,
'✓ Reservierungen tracken', `${t('register.feature4')}`,
'✓ Packlisten erstellen', `${t('register.feature5')}`,
'✓ Fotos und Dateien speichern', `${t('register.feature6')}`,
].map(item => ( ].map(item => (
<p key={item} className="text-slate-200 text-sm">{item}</p> <p key={item} className="text-slate-200 text-sm">{item}</p>
))} ))}
@@ -77,8 +79,8 @@ export default function RegisterPage() {
</div> </div>
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8"> <div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
<h2 className="text-2xl font-bold text-slate-900 mb-1">Konto erstellen</h2> <h2 className="text-2xl font-bold text-slate-900 mb-1">{t('register.createAccount')}</h2>
<p className="text-slate-500 mb-8">Beginnen Sie Ihre Reiseplanung</p> <p className="text-slate-500 mb-8">{t('register.startPlanning')}</p>
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
{error && ( {error && (
@@ -88,7 +90,7 @@ export default function RegisterPage() {
)} )}
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Benutzername</label> <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
<div className="relative"> <div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" /> <User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input <input
@@ -96,7 +98,7 @@ export default function RegisterPage() {
value={username} value={username}
onChange={e => setUsername(e.target.value)} onChange={e => setUsername(e.target.value)}
required required
placeholder="maxmustermann" placeholder="johndoe"
minLength={3} minLength={3}
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all" className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
/> />
@@ -104,7 +106,7 @@ export default function RegisterPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">E-Mail</label> <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.email')}</label>
<div className="relative"> <div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" /> <Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input <input
@@ -112,14 +114,14 @@ export default function RegisterPage() {
value={email} value={email}
onChange={e => setEmail(e.target.value)} onChange={e => setEmail(e.target.value)}
required required
placeholder="ihre@email.de" placeholder="your@email.com"
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all" className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Passwort</label> <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')}</label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" /> <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input <input
@@ -127,7 +129,7 @@ export default function RegisterPage() {
value={password} value={password}
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
required required
placeholder="Mind. 6 Zeichen" placeholder={t('register.minChars')}
className="w-full pl-10 pr-12 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all" className="w-full pl-10 pr-12 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
/> />
<button <button
@@ -141,7 +143,7 @@ export default function RegisterPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Passwort bestätigen</label> <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('register.confirmPassword')}</label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" /> <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input <input
@@ -149,7 +151,7 @@ export default function RegisterPage() {
value={confirmPassword} value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)} onChange={e => setConfirmPassword(e.target.value)}
required required
placeholder="Passwort wiederholen" placeholder={t('register.repeatPassword')}
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all" className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
/> />
</div> </div>
@@ -163,17 +165,17 @@ export default function RegisterPage() {
{isLoading ? ( {isLoading ? (
<> <>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
Registrieren... {t('register.registering')}
</> </>
) : 'Registrieren'} ) : t('register.register')}
</button> </button>
</form> </form>
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
Bereits ein Konto?{' '} {t('register.hasAccount')}{' '}
<Link to="/login" className="text-slate-900 hover:text-slate-700 font-medium"> <Link to="/login" className="text-slate-900 hover:text-slate-700 font-medium">
Anmelden {t('register.signIn')}
</Link> </Link>
</p> </p>
</div> </div>
+31 -26
View File
@@ -6,7 +6,7 @@ import { useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar' import Navbar from '../components/Layout/Navbar'
import CustomSelect from '../components/shared/CustomSelect' import CustomSelect from '../components/shared/CustomSelect'
import { useToast } from '../components/shared/Toast' import { useToast } from '../components/shared/Toast'
import { Save, Map, Palette, User, Moon, Sun, Shield, Camera, Trash2, Lock } from 'lucide-react' import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock } from 'lucide-react'
import { authApi, adminApi } from '../api/client' import { authApi, adminApi } from '../api/client'
const MAP_PRESETS = [ const MAP_PRESETS = [
@@ -136,7 +136,7 @@ export default function SettingsPage() {
<div className="min-h-screen" style={{ background: 'var(--bg-secondary)' }}> <div className="min-h-screen" style={{ background: 'var(--bg-secondary)' }}>
<Navbar /> <Navbar />
<div className="pt-14"> <div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6"> <div className="max-w-2xl mx-auto px-4 py-8 space-y-6">
<div> <div>
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('settings.title')}</h1> <h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('settings.title')}</h1>
@@ -208,30 +208,35 @@ export default function SettingsPage() {
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.colorMode')}</label> <label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.colorMode')}</label>
<div className="flex gap-3"> <div className="flex gap-3">
{[ {[
{ value: false, label: t('settings.light'), icon: Sun }, { value: 'light', label: t('settings.light'), icon: Sun },
{ value: true, label: t('settings.dark'), icon: Moon }, { value: 'dark', label: t('settings.dark'), icon: Moon },
].map(opt => ( { value: 'auto', label: t('settings.auto'), icon: Monitor },
<button ].map(opt => {
key={String(opt.value)} const current = settings.dark_mode
onClick={async () => { const isActive = current === opt.value || (opt.value === 'light' && current === false) || (opt.value === 'dark' && current === true)
try { return (
await updateSetting('dark_mode', opt.value) <button
} catch (e) { toast.error(e.message) } key={opt.value}
}} onClick={async () => {
style={{ try {
display: 'flex', alignItems: 'center', gap: 8, await updateSetting('dark_mode', opt.value)
padding: '10px 20px', borderRadius: 10, cursor: 'pointer', } catch (e) { toast.error(e.message) }
fontFamily: 'inherit', fontSize: 14, fontWeight: 500, }}
border: settings.dark_mode === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)', style={{
background: settings.dark_mode === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)', display: 'flex', alignItems: 'center', gap: 8,
color: 'var(--text-primary)', padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
transition: 'all 0.15s', fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
}} border: isActive ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
> background: isActive ? 'var(--bg-hover)' : 'var(--bg-card)',
<opt.icon size={16} /> color: 'var(--text-primary)',
{opt.label} transition: 'all 0.15s',
</button> }}
))} >
<opt.icon size={16} />
{opt.label}
</button>
)
})}
</div> </div>
</div> </div>
+133 -30
View File
@@ -7,6 +7,7 @@ import { MapView } from '../components/Map/MapView'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar' import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
import PlacesSidebar from '../components/Planner/PlacesSidebar' import PlacesSidebar from '../components/Planner/PlacesSidebar'
import PlaceInspector from '../components/Planner/PlaceInspector' import PlaceInspector from '../components/Planner/PlaceInspector'
import DayDetailPanel from '../components/Planner/DayDetailPanel'
import PlaceFormModal from '../components/Planner/PlaceFormModal' import PlaceFormModal from '../components/Planner/PlaceFormModal'
import TripFormModal from '../components/Trips/TripFormModal' import TripFormModal from '../components/Trips/TripFormModal'
import TripMembersModal from '../components/Trips/TripMembersModal' import TripMembersModal from '../components/Trips/TripMembersModal'
@@ -20,7 +21,7 @@ import { useToast } from '../components/shared/Toast'
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react' import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
import { useTranslation } from '../i18n' import { useTranslation } from '../i18n'
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket' import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
import { addonsApi } from '../api/client' import { addonsApi, accommodationsApi } from '../api/client'
const MIN_SIDEBAR = 200 const MIN_SIDEBAR = 200
const MAX_SIDEBAR = 520 const MAX_SIDEBAR = 520
@@ -35,6 +36,11 @@ export default function TripPlannerPage() {
const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore
const [enabledAddons, setEnabledAddons] = useState({ packing: true, budget: true, documents: true }) const [enabledAddons, setEnabledAddons] = useState({ packing: true, budget: true, documents: true })
const [tripAccommodations, setTripAccommodations] = useState([])
const loadAccommodations = useCallback(() => {
if (tripId) accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
}, [tripId])
useEffect(() => { useEffect(() => {
addonsApi.enabled().then(data => { addonsApi.enabled().then(data => {
@@ -63,10 +69,24 @@ export default function TripPlannerPage() {
const [rightWidth, setRightWidth] = useState(() => parseInt(localStorage.getItem('sidebarRightWidth')) || 300) const [rightWidth, setRightWidth] = useState(() => parseInt(localStorage.getItem('sidebarRightWidth')) || 300)
const [leftCollapsed, setLeftCollapsed] = useState(false) const [leftCollapsed, setLeftCollapsed] = useState(false)
const [rightCollapsed, setRightCollapsed] = useState(false) const [rightCollapsed, setRightCollapsed] = useState(false)
const [showDayDetail, setShowDayDetail] = useState(null) // day object or null
const isResizingLeft = useRef(false) const isResizingLeft = useRef(false)
const isResizingRight = useRef(false) const isResizingRight = useRef(false)
const [selectedPlaceId, setSelectedPlaceId] = useState(null) const [selectedPlaceId, _setSelectedPlaceId] = useState(null)
const [selectedAssignmentId, setSelectedAssignmentId] = useState(null)
// Set place selection - from PlacesSidebar/Map (no assignment context)
const setSelectedPlaceId = useCallback((placeId) => {
_setSelectedPlaceId(placeId)
setSelectedAssignmentId(null)
}, [])
// Set assignment selection - from DayPlanSidebar (specific assignment)
const selectAssignment = useCallback((assignmentId, placeId) => {
setSelectedAssignmentId(assignmentId)
_setSelectedPlaceId(placeId)
}, [])
const [showPlaceForm, setShowPlaceForm] = useState(false) const [showPlaceForm, setShowPlaceForm] = useState(false)
const [editingPlace, setEditingPlace] = useState(null) const [editingPlace, setEditingPlace] = useState(null)
const [showTripForm, setShowTripForm] = useState(false) const [showTripForm, setShowTripForm] = useState(false)
@@ -83,6 +103,7 @@ export default function TripPlannerPage() {
if (tripId) { if (tripId) {
tripStore.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') }) tripStore.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
tripStore.loadFiles(tripId) tripStore.loadFiles(tripId)
loadAccommodations()
} }
}, [tripId]) }, [tripId])
@@ -133,13 +154,8 @@ export default function TripPlannerPage() {
return places.filter(p => p.lat && p.lng) return places.filter(p => p.lat && p.lng)
}, [places]) }, [places])
const handleSelectDay = useCallback((dayId) => { const updateRouteForDay = useCallback((dayId) => {
tripStore.setSelectedDay(dayId) if (!dayId) { setRoute(null); setRouteInfo(null); return }
setRouteInfo(null)
setFitKey(k => k + 1)
setMobileSidebarOpen(null)
// Auto-show Luftlinien for the selected day
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index) const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng) const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng)
if (waypoints.length >= 2) { if (waypoints.length >= 2) {
@@ -147,12 +163,26 @@ export default function TripPlannerPage() {
} else { } else {
setRoute(null) setRoute(null)
} }
setRouteInfo(null)
}, [tripStore]) }, [tripStore])
const handlePlaceClick = useCallback((placeId) => { const handleSelectDay = useCallback((dayId, skipFit) => {
setSelectedPlaceId(placeId) const changed = dayId !== selectedDayId
if (placeId) { setLeftCollapsed(false); setRightCollapsed(false) } tripStore.setSelectedDay(dayId)
}, []) if (changed && !skipFit) setFitKey(k => k + 1)
setMobileSidebarOpen(null)
updateRouteForDay(dayId)
}, [tripStore, updateRouteForDay, selectedDayId])
const handlePlaceClick = useCallback((placeId, assignmentId) => {
if (assignmentId) {
selectAssignment(assignmentId, placeId)
} else {
setSelectedPlaceId(placeId)
}
if (placeId) { setShowDayDetail(null); setLeftCollapsed(false); setRightCollapsed(false) }
updateRouteForDay(selectedDayId)
}, [selectedDayId, updateRouteForDay, selectAssignment, setSelectedPlaceId])
const handleMarkerClick = useCallback((placeId) => { const handleMarkerClick = useCallback((placeId) => {
const opening = placeId !== undefined const opening = placeId !== undefined
@@ -165,11 +195,30 @@ export default function TripPlannerPage() {
}, []) }, [])
const handleSavePlace = useCallback(async (data) => { const handleSavePlace = useCallback(async (data) => {
const pendingFiles = data._pendingFiles
delete data._pendingFiles
if (editingPlace) { if (editingPlace) {
await tripStore.updatePlace(tripId, editingPlace.id, data) await tripStore.updatePlace(tripId, editingPlace.id, data)
// Upload pending files with place_id
if (pendingFiles?.length > 0) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('place_id', editingPlace.id)
try { await tripStore.addFile(tripId, fd) } catch {}
}
}
toast.success(t('trip.toast.placeUpdated')) toast.success(t('trip.toast.placeUpdated'))
} else { } else {
await tripStore.addPlace(tripId, data) const place = await tripStore.addPlace(tripId, data)
if (pendingFiles?.length > 0 && place?.id) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('place_id', place.id)
try { await tripStore.addFile(tripId, fd) } catch {}
}
}
toast.success(t('trip.toast.placeAdded')) toast.success(t('trip.toast.placeAdded'))
} }
}, [editingPlace, tripId, tripStore, toast]) }, [editingPlace, tripId, tripStore, toast])
@@ -189,16 +238,29 @@ export default function TripPlannerPage() {
try { try {
await tripStore.assignPlaceToDay(tripId, target, placeId, position) await tripStore.assignPlaceToDay(tripId, target, placeId, position)
toast.success(t('trip.toast.assignedToDay')) toast.success(t('trip.toast.assignedToDay'))
updateRouteForDay(target)
} catch (err) { toast.error(err.message) } } catch (err) { toast.error(err.message) }
}, [selectedDayId, tripId, tripStore, toast]) }, [selectedDayId, tripId, tripStore, toast, updateRouteForDay])
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => { const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
try { await tripStore.removeAssignment(tripId, dayId, assignmentId) } try {
await tripStore.removeAssignment(tripId, dayId, assignmentId)
updateRouteForDay(dayId)
}
catch (err) { toast.error(err.message) } catch (err) { toast.error(err.message) }
}, [tripId, tripStore, toast]) }, [tripId, tripStore, toast, updateRouteForDay])
const handleReorder = useCallback(async (dayId, orderedIds) => { const handleReorder = useCallback((dayId, orderedIds) => {
try { await tripStore.reorderAssignments(tripId, dayId, orderedIds) } try {
tripStore.reorderAssignments(tripId, dayId, orderedIds).catch(() => {})
// Update route immediately from orderedIds
const dayItems = tripStore.assignments[String(dayId)] || []
const ordered = orderedIds.map(id => dayItems.find(a => a.id === id)).filter(Boolean)
const waypoints = ordered.map(a => a.place).filter(p => p?.lat && p?.lng)
if (waypoints.length >= 2) setRoute(waypoints.map(p => [p.lat, p.lng]))
else setRoute(null)
setRouteInfo(null)
}
catch { toast.error(t('trip.toast.reorderError')) } catch { toast.error(t('trip.toast.reorderError')) }
}, [tripId, tripStore, toast]) }, [tripId, tripStore, toast])
@@ -236,10 +298,21 @@ export default function TripPlannerPage() {
const da = assignments[String(selectedDayId)] || [] const da = assignments[String(selectedDayId)] || []
const sorted = [...da].sort((a, b) => a.order_index - b.order_index) const sorted = [...da].sort((a, b) => a.order_index - b.order_index)
const map = {} const map = {}
sorted.forEach((a, i) => { if (a.place?.id) map[a.place.id] = i + 1 }) sorted.forEach((a, i) => {
if (!a.place?.id) return
if (!map[a.place.id]) map[a.place.id] = []
map[a.place.id].push(i + 1)
})
return map return map
}, [selectedDayId, assignments]) }, [selectedDayId, assignments])
// Places assigned to selected day (with coords) used for map fitting
const dayPlaces = useMemo(() => {
if (!selectedDayId) return []
const da = assignments[String(selectedDayId)] || []
return da.map(a => a.place).filter(p => p?.lat && p?.lng)
}, [selectedDayId, assignments])
const mapTileUrl = settings.map_tile_url || 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' const mapTileUrl = settings.map_tile_url || 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
const defaultCenter = [settings.default_lat || 48.8566, settings.default_lng || 2.3522] const defaultCenter = [settings.default_lat || 48.8566, settings.default_lng || 2.3522]
const defaultZoom = settings.default_zoom || 10 const defaultZoom = settings.default_zoom || 10
@@ -259,11 +332,11 @@ export default function TripPlannerPage() {
if (!trip) return null if (!trip) return null
return ( return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}> <div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}>
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} /> <Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
<div style={{ <div style={{
position: 'fixed', top: 56, left: 0, right: 0, zIndex: 40, position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, zIndex: 40,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '0 12px', padding: '0 12px',
background: 'var(--bg-elevated)', background: 'var(--bg-elevated)',
@@ -298,13 +371,14 @@ export default function TripPlannerPage() {
})} })}
</div> </div>
{/* Offset by navbar (56px) + tab bar (44px) */} {/* Offset by navbar + tab bar (44px) */}
<div style={{ position: 'fixed', top: 100, left: 0, right: 0, bottom: 0, overflow: 'hidden', overscrollBehavior: 'contain' }}> <div style={{ position: 'fixed', top: 'calc(var(--nav-h) + 44px)', left: 0, right: 0, bottom: 0, overflow: 'hidden', overscrollBehavior: 'contain' }}>
{activeTab === 'plan' && ( {activeTab === 'plan' && (
<div style={{ position: 'absolute', inset: 0 }}> <div style={{ position: 'absolute', inset: 0 }}>
<MapView <MapView
places={mapPlaces()} places={mapPlaces()}
dayPlaces={dayPlaces}
route={route} route={route}
selectedPlaceId={selectedPlaceId} selectedPlaceId={selectedPlaceId}
onMarkerClick={handleMarkerClick} onMarkerClick={handleMarkerClick}
@@ -314,6 +388,9 @@ export default function TripPlannerPage() {
tileUrl={mapTileUrl} tileUrl={mapTileUrl}
fitKey={fitKey} fitKey={fitKey}
dayOrderMap={dayOrderMap} dayOrderMap={dayOrderMap}
leftWidth={leftCollapsed ? 0 : leftWidth}
rightWidth={rightCollapsed ? 0 : rightWidth}
hasInspector={!!selectedPlace}
/> />
{routeInfo && ( {routeInfo && (
@@ -333,7 +410,7 @@ export default function TripPlannerPage() {
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}> <div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
<button onClick={() => setLeftCollapsed(c => !c)} <button onClick={() => setLeftCollapsed(c => !c)}
style={{ style={{
position: leftCollapsed ? 'fixed' : 'absolute', top: leftCollapsed ? 'calc(56px + 44px + 14px)' : 14, left: leftCollapsed ? 10 : undefined, right: leftCollapsed ? undefined : -28, zIndex: 25, position: leftCollapsed ? 'fixed' : 'absolute', top: leftCollapsed ? 'calc(var(--nav-h) + 44px + 14px)' : 14, left: leftCollapsed ? 10 : undefined, right: leftCollapsed ? undefined : -28, zIndex: -1,
width: 36, height: 36, borderRadius: leftCollapsed ? 10 : '0 10px 10px 0', width: 36, height: 36, borderRadius: leftCollapsed ? 10 : '0 10px 10px 0',
background: leftCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', background: leftCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
boxShadow: leftCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none', boxShadow: leftCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
@@ -365,6 +442,7 @@ export default function TripPlannerPage() {
assignments={assignments} assignments={assignments}
selectedDayId={selectedDayId} selectedDayId={selectedDayId}
selectedPlaceId={selectedPlaceId} selectedPlaceId={selectedPlaceId}
selectedAssignmentId={selectedAssignmentId}
onSelectDay={handleSelectDay} onSelectDay={handleSelectDay}
onPlaceClick={handlePlaceClick} onPlaceClick={handlePlaceClick}
onReorder={handleReorder} onReorder={handleReorder}
@@ -373,6 +451,8 @@ export default function TripPlannerPage() {
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } else { setRoute(null); setRouteInfo(null) } }} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } else { setRoute(null); setRouteInfo(null) } }}
reservations={reservations} reservations={reservations}
onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }}
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null) }}
accommodations={tripAccommodations}
/> />
{!leftCollapsed && ( {!leftCollapsed && (
<div <div
@@ -388,7 +468,7 @@ export default function TripPlannerPage() {
<div className="hidden md:block" style={{ position: 'absolute', right: 10, top: 10, bottom: 10, zIndex: 20 }}> <div className="hidden md:block" style={{ position: 'absolute', right: 10, top: 10, bottom: 10, zIndex: 20 }}>
<button onClick={() => setRightCollapsed(c => !c)} <button onClick={() => setRightCollapsed(c => !c)}
style={{ style={{
position: rightCollapsed ? 'fixed' : 'absolute', top: rightCollapsed ? 'calc(56px + 44px + 14px)' : 14, right: rightCollapsed ? 10 : undefined, left: rightCollapsed ? undefined : -28, zIndex: 25, position: rightCollapsed ? 'fixed' : 'absolute', top: rightCollapsed ? 'calc(var(--nav-h) + 44px + 14px)' : 14, right: rightCollapsed ? 10 : undefined, left: rightCollapsed ? undefined : -28, zIndex: -1,
width: 36, height: 36, borderRadius: rightCollapsed ? 10 : '10px 0 0 10px', width: 36, height: 36, borderRadius: rightCollapsed ? 10 : '10px 0 0 10px',
background: rightCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', background: rightCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
boxShadow: rightCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none', boxShadow: rightCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
@@ -436,7 +516,7 @@ export default function TripPlannerPage() {
{/* Mobile sidebar buttons — portal to body to escape Leaflet touch handling */} {/* Mobile sidebar buttons — portal to body to escape Leaflet touch handling */}
{activeTab === 'plan' && !mobileSidebarOpen && !showPlaceForm && !showMembersModal && !showReservationModal && ReactDOM.createPortal( {activeTab === 'plan' && !mobileSidebarOpen && !showPlaceForm && !showMembersModal && !showReservationModal && ReactDOM.createPortal(
<div className="flex md:hidden" style={{ position: 'fixed', top: 112, left: 12, right: 12, justifyContent: 'space-between', zIndex: 100, pointerEvents: 'none' }}> <div className="flex md:hidden" style={{ position: 'fixed', top: 'calc(var(--nav-h) + 44px + 12px)', left: 12, right: 12, justifyContent: 'space-between', zIndex: 100, pointerEvents: 'none' }}>
<button onClick={() => setMobileSidebarOpen('left')} <button onClick={() => setMobileSidebarOpen('left')}
style={{ pointerEvents: 'auto', background: 'var(--bg-card)', color: 'var(--text-primary)', backdropFilter: 'blur(12px)', border: '1px solid var(--border-primary)', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit', touchAction: 'manipulation' }}> style={{ pointerEvents: 'auto', background: 'var(--bg-card)', color: 'var(--text-primary)', backdropFilter: 'blur(12px)', border: '1px solid var(--border-primary)', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit', touchAction: 'manipulation' }}>
{t('trip.mobilePlan')} {t('trip.mobilePlan')}
@@ -449,13 +529,36 @@ export default function TripPlannerPage() {
document.body document.body
)} )}
{showDayDetail && !selectedPlace && (() => {
const currentDay = days.find(d => d.id === showDayDetail.id) || showDayDetail
const dayAssignments = assignments[String(currentDay.id)] || []
const geoPlace = dayAssignments.find(a => a.place?.lat && a.place?.lng)?.place || places.find(p => p.lat && p.lng)
return (
<DayDetailPanel
day={currentDay}
days={days}
places={places}
categories={categories}
tripId={tripId}
assignments={assignments}
reservations={reservations}
lat={geoPlace?.lat}
lng={geoPlace?.lng}
onClose={() => setShowDayDetail(null)}
onAccommodationChange={loadAccommodations}
/>
)
})()}
{selectedPlace && ( {selectedPlace && (
<PlaceInspector <PlaceInspector
place={selectedPlace} place={selectedPlace}
categories={categories} categories={categories}
days={days} days={days}
selectedDayId={selectedDayId} selectedDayId={selectedDayId}
selectedAssignmentId={selectedAssignmentId}
assignments={assignments} assignments={assignments}
reservations={reservations}
onClose={() => setSelectedPlaceId(null)} onClose={() => setSelectedPlaceId(null)}
onEdit={() => { setEditingPlace(selectedPlace); setShowPlaceForm(true) }} onEdit={() => { setEditingPlace(selectedPlace); setShowPlaceForm(true) }}
onDelete={() => handleDeletePlace(selectedPlace.id)} onDelete={() => handleDeletePlace(selectedPlace.id)}
@@ -468,7 +571,7 @@ export default function TripPlannerPage() {
{mobileSidebarOpen && ReactDOM.createPortal( {mobileSidebarOpen && ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.3)', zIndex: 9999 }} onClick={() => setMobileSidebarOpen(null)}> <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.3)', zIndex: 9999 }} onClick={() => setMobileSidebarOpen(null)}>
<div style={{ position: 'absolute', top: 56, left: 0, right: 0, bottom: 0, background: 'var(--bg-card)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }} onClick={e => e.stopPropagation()}> <div style={{ position: 'absolute', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0, background: 'var(--bg-card)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid var(--border-secondary)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid var(--border-secondary)' }}>
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{mobileSidebarOpen === 'left' ? t('trip.mobilePlan') : t('trip.mobilePlaces')}</span> <span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{mobileSidebarOpen === 'left' ? t('trip.mobilePlan') : t('trip.mobilePlaces')}</span>
<button onClick={() => setMobileSidebarOpen(null)} style={{ background: 'var(--bg-tertiary)', border: 'none', borderRadius: '50%', width: 28, height: 28, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-primary)' }}> <button onClick={() => setMobileSidebarOpen(null)} style={{ background: 'var(--bg-tertiary)', border: 'none', borderRadius: '50%', width: 28, height: 28, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-primary)' }}>
@@ -477,7 +580,7 @@ export default function TripPlannerPage() {
</div> </div>
<div style={{ flex: 1, overflow: 'auto' }}> <div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left' {mobileSidebarOpen === 'left'
? <DayPlanSidebar trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} /> ? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} />
: <PlacesSidebar places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile /> : <PlacesSidebar places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile />
} }
</div> </div>
@@ -534,7 +637,7 @@ export default function TripPlannerPage() {
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null) }} onSave={handleSavePlace} place={editingPlace} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} /> <PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null) }} onSave={handleSavePlace} place={editingPlace} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} /> <TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} /> <TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} /> <ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} />
</div> </div>
) )
} }
+2 -2
View File
@@ -50,7 +50,7 @@ export default function VacayPage() {
return ( return (
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}> <div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
<Navbar /> <Navbar />
<div className="pt-14 flex items-center justify-center" style={{ minHeight: 'calc(100vh - 56px)' }}> <div className="flex items-center justify-center" style={{ paddingTop: 'var(--nav-h)', minHeight: 'calc(100vh - var(--nav-h))' }}>
<div className="w-8 h-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} /> <div className="w-8 h-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
</div> </div>
</div> </div>
@@ -119,7 +119,7 @@ export default function VacayPage() {
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}> <div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
<Navbar /> <Navbar />
<div className="pt-14"> <div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="max-w-[1800px] mx-auto px-3 sm:px-4 py-4 sm:py-6"> <div className="max-w-[1800px] mx-auto px-3 sm:px-4 py-4 sm:py-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-4 sm:mb-5"> <div className="flex items-center justify-between mb-4 sm:mb-5">
+6 -6
View File
@@ -26,7 +26,7 @@ export const useAuthStore = create((set, get) => ({
connect(data.token) connect(data.token)
return data return data
} catch (err) { } catch (err) {
const error = err.response?.data?.error || 'Anmeldung fehlgeschlagen' const error = err.response?.data?.error || 'Login failed'
set({ isLoading: false, error }) set({ isLoading: false, error })
throw new Error(error) throw new Error(error)
} }
@@ -47,7 +47,7 @@ export const useAuthStore = create((set, get) => ({
connect(data.token) connect(data.token)
return data return data
} catch (err) { } catch (err) {
const error = err.response?.data?.error || 'Registrierung fehlgeschlagen' const error = err.response?.data?.error || 'Registration failed'
set({ isLoading: false, error }) set({ isLoading: false, error })
throw new Error(error) throw new Error(error)
} }
@@ -97,7 +97,7 @@ export const useAuthStore = create((set, get) => ({
user: { ...state.user, maps_api_key: key || null } user: { ...state.user, maps_api_key: key || null }
})) }))
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Speichern des API-Schlüssels') throw new Error(err.response?.data?.error || 'Error saving API key')
} }
}, },
@@ -106,7 +106,7 @@ export const useAuthStore = create((set, get) => ({
const data = await authApi.updateApiKeys(keys) const data = await authApi.updateApiKeys(keys)
set({ user: data.user }) set({ user: data.user })
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Speichern der API-Schlüssel') throw new Error(err.response?.data?.error || 'Error saving API keys')
} }
}, },
@@ -115,7 +115,7 @@ export const useAuthStore = create((set, get) => ({
const data = await authApi.updateSettings(profileData) const data = await authApi.updateSettings(profileData)
set({ user: data.user }) set({ user: data.user })
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Profils') throw new Error(err.response?.data?.error || 'Error updating profile')
} }
}, },
@@ -156,7 +156,7 @@ export const useAuthStore = create((set, get) => ({
connect(data.token) connect(data.token)
return data return data
} catch (err) { } catch (err) {
const error = err.response?.data?.error || 'Demo-Login fehlgeschlagen' const error = err.response?.data?.error || 'Demo login failed'
set({ isLoading: false, error }) set({ isLoading: false, error })
throw new Error(error) throw new Error(error)
} }
+2 -2
View File
@@ -38,7 +38,7 @@ export const useSettingsStore = create((set, get) => ({
await settingsApi.set(key, value) await settingsApi.set(key, value)
} catch (err) { } catch (err) {
console.error('Failed to save setting:', err) console.error('Failed to save setting:', err)
throw new Error(err.response?.data?.error || 'Fehler beim Speichern der Einstellung') throw new Error(err.response?.data?.error || 'Error saving setting')
} }
}, },
@@ -55,7 +55,7 @@ export const useSettingsStore = create((set, get) => ({
await settingsApi.setBulk(settingsObj) await settingsApi.setBulk(settingsObj)
} catch (err) { } catch (err) {
console.error('Failed to save settings:', err) console.error('Failed to save settings:', err)
throw new Error(err.response?.data?.error || 'Fehler beim Speichern der Einstellungen') throw new Error(err.response?.data?.error || 'Error saving settings')
} }
}, },
})) }))
+58 -34
View File
@@ -76,6 +76,17 @@ export const useTripStore = create((set, get) => ({
} }
} }
} }
case 'assignment:updated': {
const dayKey = String(payload.assignment.day_id)
return {
assignments: {
...state.assignments,
[dayKey]: (state.assignments[dayKey] || []).map(a =>
a.id === payload.assignment.id ? { ...a, ...payload.assignment } : a
),
}
}
}
case 'assignment:deleted': { case 'assignment:deleted': {
const dayKey = String(payload.dayId) const dayKey = String(payload.dayId)
return { return {
@@ -279,7 +290,7 @@ export const useTripStore = create((set, get) => ({
set(state => ({ places: [data.place, ...state.places] })) set(state => ({ places: [data.place, ...state.places] }))
return data.place return data.place
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen des Ortes') throw new Error(err.response?.data?.error || 'Error adding place')
} }
}, },
@@ -297,7 +308,7 @@ export const useTripStore = create((set, get) => ({
})) }))
return data.place return data.place
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Ortes') throw new Error(err.response?.data?.error || 'Error updating place')
} }
}, },
@@ -314,7 +325,7 @@ export const useTripStore = create((set, get) => ({
), ),
})) }))
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Löschen des Ortes') throw new Error(err.response?.data?.error || 'Error deleting place')
} }
}, },
@@ -323,9 +334,6 @@ export const useTripStore = create((set, get) => ({
const place = state.places.find(p => p.id === parseInt(placeId)) const place = state.places.find(p => p.id === parseInt(placeId))
if (!place) return if (!place) return
const existing = (state.assignments[String(dayId)] || []).find(a => a.place?.id === parseInt(placeId))
if (existing) return
const tempId = Date.now() * -1 const tempId = Date.now() * -1
const current = [...(state.assignments[String(dayId)] || [])] const current = [...(state.assignments[String(dayId)] || [])]
const insertIdx = position != null ? position : current.length const insertIdx = position != null ? position : current.length
@@ -347,9 +355,11 @@ export const useTripStore = create((set, get) => ({
try { try {
const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId }) const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId })
const newAssignment = position != null const newAssignment = {
? { ...data.assignment, order_index: insertIdx } ...data.assignment,
: data.assignment place: data.assignment.place || place,
order_index: position != null ? insertIdx : data.assignment.order_index,
}
set(state => ({ set(state => ({
assignments: { assignments: {
...state.assignments, ...state.assignments,
@@ -390,7 +400,7 @@ export const useTripStore = create((set, get) => ({
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== tempId), [String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== tempId),
} }
})) }))
throw new Error(err.response?.data?.error || 'Fehler beim Zuweisen des Ortes') throw new Error(err.response?.data?.error || 'Error assigning place')
} }
}, },
@@ -408,7 +418,7 @@ export const useTripStore = create((set, get) => ({
await assignmentsApi.delete(tripId, dayId, assignmentId) await assignmentsApi.delete(tripId, dayId, assignmentId)
} catch (err) { } catch (err) {
set({ assignments: prevAssignments }) set({ assignments: prevAssignments })
throw new Error(err.response?.data?.error || 'Fehler beim Entfernen der Zuweisung') throw new Error(err.response?.data?.error || 'Error removing assignment')
} }
}, },
@@ -431,7 +441,7 @@ export const useTripStore = create((set, get) => ({
await assignmentsApi.reorder(tripId, dayId, orderedIds) await assignmentsApi.reorder(tripId, dayId, orderedIds)
} catch (err) { } catch (err) {
set({ assignments: prevAssignments }) set({ assignments: prevAssignments })
throw new Error(err.response?.data?.error || 'Fehler beim Neuanordnen') throw new Error(err.response?.data?.error || 'Error reordering')
} }
}, },
@@ -464,7 +474,7 @@ export const useTripStore = create((set, get) => ({
} }
} catch (err) { } catch (err) {
set({ assignments: prevAssignments }) set({ assignments: prevAssignments })
throw new Error(err.response?.data?.error || 'Fehler beim Verschieben der Zuweisung') throw new Error(err.response?.data?.error || 'Error moving assignment')
} }
}, },
@@ -498,7 +508,7 @@ export const useTripStore = create((set, get) => ({
[String(fromDayId)]: [...(s.dayNotes[String(fromDayId)] || []), note], [String(fromDayId)]: [...(s.dayNotes[String(fromDayId)] || []), note],
} }
})) }))
throw new Error(err.response?.data?.error || 'Fehler beim Verschieben der Notiz') throw new Error(err.response?.data?.error || 'Error moving note')
} }
}, },
@@ -512,7 +522,7 @@ export const useTripStore = create((set, get) => ({
set(state => ({ packingItems: [...state.packingItems, result.item] })) set(state => ({ packingItems: [...state.packingItems, result.item] }))
return result.item return result.item
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen des Artikels') throw new Error(err.response?.data?.error || 'Error adding item')
} }
}, },
@@ -524,7 +534,7 @@ export const useTripStore = create((set, get) => ({
})) }))
return result.item return result.item
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Artikels') throw new Error(err.response?.data?.error || 'Error updating item')
} }
}, },
@@ -535,7 +545,7 @@ export const useTripStore = create((set, get) => ({
await packingApi.delete(tripId, id) await packingApi.delete(tripId, id)
} catch (err) { } catch (err) {
set({ packingItems: prev }) set({ packingItems: prev })
throw new Error(err.response?.data?.error || 'Fehler beim Löschen des Artikels') throw new Error(err.response?.data?.error || 'Error deleting item')
} }
}, },
@@ -563,7 +573,7 @@ export const useTripStore = create((set, get) => ({
days: state.days.map(d => d.id === parseInt(dayId) ? { ...d, notes } : d) days: state.days.map(d => d.id === parseInt(dayId) ? { ...d, notes } : d)
})) }))
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Notizen') throw new Error(err.response?.data?.error || 'Error updating notes')
} }
}, },
@@ -574,7 +584,7 @@ export const useTripStore = create((set, get) => ({
days: state.days.map(d => d.id === parseInt(dayId) ? { ...d, title } : d) days: state.days.map(d => d.id === parseInt(dayId) ? { ...d, title } : d)
})) }))
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Tagesnamens') throw new Error(err.response?.data?.error || 'Error updating day name')
} }
}, },
@@ -584,7 +594,7 @@ export const useTripStore = create((set, get) => ({
set(state => ({ tags: [...state.tags, result.tag] })) set(state => ({ tags: [...state.tags, result.tag] }))
return result.tag return result.tag
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Erstellen des Tags') throw new Error(err.response?.data?.error || 'Error creating tag')
} }
}, },
@@ -594,7 +604,7 @@ export const useTripStore = create((set, get) => ({
set(state => ({ categories: [...state.categories, result.category] })) set(state => ({ categories: [...state.categories, result.category] }))
return result.category return result.category
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Erstellen der Kategorie') throw new Error(err.response?.data?.error || 'Error creating category')
} }
}, },
@@ -612,7 +622,7 @@ export const useTripStore = create((set, get) => ({
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap }) set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
return result.trip return result.trip
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Reise') throw new Error(err.response?.data?.error || 'Error updating trip')
} }
}, },
@@ -631,7 +641,7 @@ export const useTripStore = create((set, get) => ({
set(state => ({ budgetItems: [...state.budgetItems, result.item] })) set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
return result.item return result.item
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen des Budget-Eintrags') throw new Error(err.response?.data?.error || 'Error adding budget item')
} }
}, },
@@ -643,7 +653,7 @@ export const useTripStore = create((set, get) => ({
})) }))
return result.item return result.item
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Budget-Eintrags') throw new Error(err.response?.data?.error || 'Error updating budget item')
} }
}, },
@@ -654,7 +664,7 @@ export const useTripStore = create((set, get) => ({
await budgetApi.delete(tripId, id) await budgetApi.delete(tripId, id)
} catch (err) { } catch (err) {
set({ budgetItems: prev }) set({ budgetItems: prev })
throw new Error(err.response?.data?.error || 'Fehler beim Löschen des Budget-Eintrags') throw new Error(err.response?.data?.error || 'Error deleting budget item')
} }
}, },
@@ -673,7 +683,7 @@ export const useTripStore = create((set, get) => ({
set(state => ({ files: [data.file, ...state.files] })) set(state => ({ files: [data.file, ...state.files] }))
return data.file return data.file
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Hochladen der Datei') throw new Error(err.response?.data?.error || 'Error uploading file')
} }
}, },
@@ -682,7 +692,7 @@ export const useTripStore = create((set, get) => ({
await filesApi.delete(tripId, id) await filesApi.delete(tripId, id)
set(state => ({ files: state.files.filter(f => f.id !== id) })) set(state => ({ files: state.files.filter(f => f.id !== id) }))
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Löschen der Datei') throw new Error(err.response?.data?.error || 'Error deleting file')
} }
}, },
@@ -701,7 +711,7 @@ export const useTripStore = create((set, get) => ({
set(state => ({ reservations: [result.reservation, ...state.reservations] })) set(state => ({ reservations: [result.reservation, ...state.reservations] }))
return result.reservation return result.reservation
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Erstellen der Reservierung') throw new Error(err.response?.data?.error || 'Error creating reservation')
} }
}, },
@@ -713,7 +723,7 @@ export const useTripStore = create((set, get) => ({
})) }))
return result.reservation return result.reservation
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Reservierung') throw new Error(err.response?.data?.error || 'Error updating reservation')
} }
}, },
@@ -737,22 +747,36 @@ export const useTripStore = create((set, get) => ({
await reservationsApi.delete(tripId, id) await reservationsApi.delete(tripId, id)
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) })) set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Löschen der Reservierung') throw new Error(err.response?.data?.error || 'Error deleting reservation')
} }
}, },
addDayNote: async (tripId, dayId, data) => { addDayNote: async (tripId, dayId, data) => {
const tempId = Date.now() * -1
const tempNote = { id: tempId, day_id: dayId, ...data, created_at: new Date().toISOString() }
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: [...(state.dayNotes[String(dayId)] || []), tempNote],
}
}))
try { try {
const result = await dayNotesApi.create(tripId, dayId, data) const result = await dayNotesApi.create(tripId, dayId, data)
set(state => ({ set(state => ({
dayNotes: { dayNotes: {
...state.dayNotes, ...state.dayNotes,
[String(dayId)]: [...(state.dayNotes[String(dayId)] || []), result.note], [String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === tempId ? result.note : n),
} }
})) }))
return result.note return result.note
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen der Notiz') set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== tempId),
}
}))
throw new Error(err.response?.data?.error || 'Error adding note')
} }
}, },
@@ -767,7 +791,7 @@ export const useTripStore = create((set, get) => ({
})) }))
return result.note return result.note
} catch (err) { } catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Notiz') throw new Error(err.response?.data?.error || 'Error updating note')
} }
}, },
@@ -783,7 +807,7 @@ export const useTripStore = create((set, get) => ({
await dayNotesApi.delete(tripId, dayId, id) await dayNotesApi.delete(tripId, dayId, id)
} catch (err) { } catch (err) {
set({ dayNotes: prev }) set({ dayNotes: prev })
throw new Error(err.response?.data?.error || 'Fehler beim Löschen der Notiz') throw new Error(err.response?.data?.error || 'Error deleting note')
} }
}, },
})) }))
+84 -1
View File
@@ -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: {
+2 -2
View File
@@ -1,12 +1,12 @@
services: services:
app: app:
image: mauriceboe/nomad:latest image: mauriceboe/nomad:2.5.5
container_name: nomad container_name: nomad
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- JWT_SECRET=${JWT_SECRET:-change-me-to-a-long-random-string} - JWT_SECRET=${JWT_SECRET:-}
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins # - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
- PORT=3000 - PORT=3000
volumes: volumes:
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

+374 -2
View File
@@ -1,18 +1,20 @@
{ {
"name": "nomad-server", "name": "nomad-server",
"version": "2.4.1", "version": "2.5.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nomad-server", "name": "nomad-server",
"version": "2.4.1", "version": "2.5.5",
"dependencies": { "dependencies": {
"archiver": "^6.0.1", "archiver": "^6.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.1", "dotenv": "^16.4.1",
"express": "^4.18.3", "express": "^4.18.3",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
@@ -216,12 +218,46 @@
"bare-path": "^3.0.0" "bare-path": "^3.0.0"
} }
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bcryptjs": { "node_modules/bcryptjs": {
"version": "2.4.3", "version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/better-sqlite3": {
"version": "12.8.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
"integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -235,6 +271,26 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/bluebird": { "node_modules/bluebird": {
"version": "3.7.2", "version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
@@ -291,6 +347,30 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-crc32": { "node_modules/buffer-crc32": {
"version": "0.2.13", "version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
@@ -386,6 +466,12 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/compress-commons": { "node_modules/compress-commons": {
"version": "5.0.3", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.3.tgz", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.3.tgz",
@@ -539,6 +625,30 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -558,6 +668,15 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.6.1", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -647,6 +766,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -701,6 +829,15 @@
"bare-events": "^2.7.0" "bare-events": "^2.7.0"
} }
}, },
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/express": { "node_modules/express": {
"version": "4.22.1", "version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
@@ -753,6 +890,12 @@
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -802,6 +945,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fs-extra": { "node_modules/fs-extra": {
"version": "11.3.4", "version": "11.3.4",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz",
@@ -883,6 +1032,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob": { "node_modules/glob": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
@@ -995,6 +1150,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1027,6 +1191,26 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore-by-default": { "node_modules/ignore-by-default": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
@@ -1051,6 +1235,12 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1332,6 +1522,18 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "10.2.4", "version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
@@ -1369,6 +1571,12 @@
"mkdirp": "bin/cmd.js" "mkdirp": "bin/cmd.js"
} }
}, },
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -1394,6 +1602,12 @@
"node": ">= 6.0.0" "node": ">= 6.0.0"
} }
}, },
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1403,6 +1617,18 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-abi": {
"version": "3.89.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-cron": { "node_modules/node-cron": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
@@ -1571,6 +1797,33 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -1597,6 +1850,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.2", "version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
@@ -1636,6 +1899,21 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": { "node_modules/readable-stream": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -1860,6 +2138,51 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/simple-update-notifier": { "node_modules/simple-update-notifier": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -1910,6 +2233,15 @@
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
} }
}, },
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -1923,6 +2255,34 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-fs/node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tar-stream": { "node_modules/tar-stream": {
"version": "3.1.8", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz",
@@ -1991,6 +2351,18 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+4 -2
View File
@@ -1,17 +1,19 @@
{ {
"name": "nomad-server", "name": "nomad-server",
"version": "2.5.0", "version": "2.5.7",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"start": "node --experimental-sqlite src/index.js", "start": "node src/index.js",
"dev": "nodemon src/index.js" "dev": "nodemon src/index.js"
}, },
"dependencies": { "dependencies": {
"archiver": "^6.0.1", "archiver": "^6.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.1", "dotenv": "^16.4.1",
"express": "^4.18.3", "express": "^4.18.3",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
+2 -2
View File
@@ -1,9 +1,9 @@
const path = require('path'); const path = require('path');
const { DatabaseSync } = require('node:sqlite'); const Database = require('better-sqlite3');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const dbPath = path.join(__dirname, 'data/travel.db'); const dbPath = path.join(__dirname, 'data/travel.db');
const db = new DatabaseSync(dbPath); const db = new Database(dbPath);
const hash = bcrypt.hashSync('admin123', 10); const hash = bcrypt.hashSync('admin123', 10);
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get('admin@admin.com'); const existing = db.prepare('SELECT id FROM users WHERE email = ?').get('admin@admin.com');
+145 -52
View File
@@ -1,4 +1,4 @@
const { DatabaseSync } = require('node:sqlite'); const Database = require('better-sqlite3');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
@@ -19,7 +19,7 @@ function initDb() {
_db = null; _db = null;
} }
_db = new DatabaseSync(dbPath); _db = new Database(dbPath);
_db.exec('PRAGMA journal_mode = WAL'); _db.exec('PRAGMA journal_mode = WAL');
_db.exec('PRAGMA busy_timeout = 5000'); _db.exec('PRAGMA busy_timeout = 5000');
_db.exec('PRAGMA foreign_keys = ON'); _db.exec('PRAGMA foreign_keys = ON');
@@ -35,6 +35,10 @@ function initDb() {
maps_api_key TEXT, maps_api_key TEXT,
unsplash_api_key TEXT, unsplash_api_key TEXT,
openweather_api_key TEXT, openweather_api_key TEXT,
avatar TEXT,
oidc_sub TEXT,
oidc_issuer TEXT,
last_login DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@@ -55,6 +59,8 @@ function initDb() {
start_date TEXT, start_date TEXT,
end_date TEXT, end_date TEXT,
currency TEXT DEFAULT 'EUR', currency TEXT DEFAULT 'EUR',
cover_image TEXT,
is_archived INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@@ -65,6 +71,7 @@ function initDb() {
day_number INTEGER NOT NULL, day_number INTEGER NOT NULL,
date TEXT, date TEXT,
notes TEXT, notes TEXT,
title TEXT,
UNIQUE(trip_id, day_number) UNIQUE(trip_id, day_number)
); );
@@ -73,6 +80,7 @@ function initDb() {
name TEXT NOT NULL, name TEXT NOT NULL,
color TEXT DEFAULT '#6366f1', color TEXT DEFAULT '#6366f1',
icon TEXT DEFAULT '📍', icon TEXT DEFAULT '📍',
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@@ -99,6 +107,7 @@ function initDb() {
reservation_notes TEXT, reservation_notes TEXT,
reservation_datetime TEXT, reservation_datetime TEXT,
place_time TEXT, place_time TEXT,
end_time TEXT,
duration_minutes INTEGER DEFAULT 60, duration_minutes INTEGER DEFAULT 60,
notes TEXT, notes TEXT,
image_url TEXT, image_url TEXT,
@@ -122,6 +131,9 @@ function initDb() {
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
order_index INTEGER DEFAULT 0, order_index INTEGER DEFAULT 0,
notes TEXT, notes TEXT,
reservation_status TEXT DEFAULT 'none',
reservation_notes TEXT,
reservation_datetime TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@@ -153,6 +165,7 @@ function initDb() {
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL,
filename TEXT NOT NULL, filename TEXT NOT NULL,
original_name TEXT NOT NULL, original_name TEXT NOT NULL,
file_size INTEGER, file_size INTEGER,
@@ -166,11 +179,14 @@ function initDb() {
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
day_id INTEGER REFERENCES days(id) ON DELETE SET NULL, day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
reservation_time TEXT, reservation_time TEXT,
location TEXT, location TEXT,
confirmation_number TEXT, confirmation_number TEXT,
notes TEXT, notes TEXT,
status TEXT DEFAULT 'pending',
type TEXT DEFAULT 'other',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@@ -202,7 +218,7 @@ function initDb() {
CREATE TABLE IF NOT EXISTS budget_items ( CREATE TABLE IF NOT EXISTS budget_items (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
category TEXT NOT NULL DEFAULT 'Sonstiges', category TEXT NOT NULL DEFAULT 'Other',
name TEXT NOT NULL, name TEXT NOT NULL,
total_price REAL NOT NULL DEFAULT 0, total_price REAL NOT NULL DEFAULT 0,
persons INTEGER DEFAULT NULL, persons INTEGER DEFAULT NULL,
@@ -287,6 +303,19 @@ function initDb() {
note TEXT DEFAULT '', note TEXT DEFAULT '',
UNIQUE(plan_id, date) UNIQUE(plan_id, date)
); );
CREATE TABLE IF NOT EXISTS day_accommodations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
check_in TEXT,
check_out TEXT,
confirmation TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`); `);
// Create indexes for performance // Create indexes for performance
@@ -307,55 +336,118 @@ function initDb() {
CREATE INDEX IF NOT EXISTS idx_day_notes_day_id ON day_notes(day_id); CREATE INDEX IF NOT EXISTS idx_day_notes_day_id ON day_notes(day_id);
CREATE INDEX IF NOT EXISTS idx_photos_trip_id ON photos(trip_id); CREATE INDEX IF NOT EXISTS idx_photos_trip_id ON photos(trip_id);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id);
`); `);
// Migrations // Versioned migrations — each runs exactly once
_db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)');
const versionRow = _db.prepare('SELECT version FROM schema_version').get();
let currentVersion = versionRow?.version ?? 0;
// Existing or fresh DBs may already have columns the migrations add.
// Detect by checking for a column from migration 1 (unsplash_api_key).
if (currentVersion === 0) {
const hasUnsplash = _db.prepare(
"SELECT 1 FROM pragma_table_info('users') WHERE name = 'unsplash_api_key'"
).get();
if (hasUnsplash) {
// All columns from CREATE TABLE already exist — skip ALTER migrations
currentVersion = 19;
_db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(currentVersion);
console.log('[DB] Schema already up-to-date, setting version to', currentVersion);
} else {
_db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(0);
}
}
const migrations = [ const migrations = [
`ALTER TABLE users ADD COLUMN unsplash_api_key TEXT`, // 118: ALTER TABLE additions
`ALTER TABLE users ADD COLUMN openweather_api_key TEXT`, () => _db.exec('ALTER TABLE users ADD COLUMN unsplash_api_key TEXT'),
`ALTER TABLE places ADD COLUMN duration_minutes INTEGER DEFAULT 60`, () => _db.exec('ALTER TABLE users ADD COLUMN openweather_api_key TEXT'),
`ALTER TABLE places ADD COLUMN notes TEXT`, () => _db.exec('ALTER TABLE places ADD COLUMN duration_minutes INTEGER DEFAULT 60'),
`ALTER TABLE places ADD COLUMN image_url TEXT`, () => _db.exec('ALTER TABLE places ADD COLUMN notes TEXT'),
`ALTER TABLE places ADD COLUMN transport_mode TEXT DEFAULT 'walking'`, () => _db.exec('ALTER TABLE places ADD COLUMN image_url TEXT'),
`ALTER TABLE days ADD COLUMN title TEXT`, () => _db.exec("ALTER TABLE places ADD COLUMN transport_mode TEXT DEFAULT 'walking'"),
`ALTER TABLE reservations ADD COLUMN status TEXT DEFAULT 'pending'`, () => _db.exec('ALTER TABLE days ADD COLUMN title TEXT'),
`ALTER TABLE trip_files ADD COLUMN reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL`, () => _db.exec("ALTER TABLE reservations ADD COLUMN status TEXT DEFAULT 'pending'"),
`ALTER TABLE reservations ADD COLUMN type TEXT DEFAULT 'other'`, () => _db.exec('ALTER TABLE trip_files ADD COLUMN reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL'),
`ALTER TABLE trips ADD COLUMN cover_image TEXT`, () => _db.exec("ALTER TABLE reservations ADD COLUMN type TEXT DEFAULT 'other'"),
`ALTER TABLE day_notes ADD COLUMN icon TEXT DEFAULT '📝'`, () => _db.exec('ALTER TABLE trips ADD COLUMN cover_image TEXT'),
`ALTER TABLE trips ADD COLUMN is_archived INTEGER DEFAULT 0`, () => _db.exec("ALTER TABLE day_notes ADD COLUMN icon TEXT DEFAULT '📝'"),
`ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL`, () => _db.exec('ALTER TABLE trips ADD COLUMN is_archived INTEGER DEFAULT 0'),
`ALTER TABLE users ADD COLUMN avatar TEXT`, () => _db.exec('ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'),
`ALTER TABLE users ADD COLUMN oidc_sub TEXT`, () => _db.exec('ALTER TABLE users ADD COLUMN avatar TEXT'),
`ALTER TABLE users ADD COLUMN oidc_issuer TEXT`, () => _db.exec('ALTER TABLE users ADD COLUMN oidc_sub TEXT'),
`ALTER TABLE users ADD COLUMN last_login DATETIME`, () => _db.exec('ALTER TABLE users ADD COLUMN oidc_issuer TEXT'),
() => _db.exec('ALTER TABLE users ADD COLUMN last_login DATETIME'),
// 19: budget_items table rebuild (NOT NULL → nullable persons)
() => {
const schema = _db.prepare("SELECT sql FROM sqlite_master WHERE name = 'budget_items'").get();
if (schema?.sql?.includes('NOT NULL DEFAULT 1')) {
_db.exec(`
CREATE TABLE budget_items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
category TEXT NOT NULL DEFAULT 'Other',
name TEXT NOT NULL,
total_price REAL NOT NULL DEFAULT 0,
persons INTEGER DEFAULT NULL,
days INTEGER DEFAULT NULL,
note TEXT,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO budget_items_new SELECT * FROM budget_items;
DROP TABLE budget_items;
ALTER TABLE budget_items_new RENAME TO budget_items;
`);
}
},
// 20: accommodation check-in/check-out/confirmation fields
() => {
try { _db.exec('ALTER TABLE day_accommodations ADD COLUMN check_in TEXT'); } catch {}
try { _db.exec('ALTER TABLE day_accommodations ADD COLUMN check_out TEXT'); } catch {}
try { _db.exec('ALTER TABLE day_accommodations ADD COLUMN confirmation TEXT'); } catch {}
},
// 21: places end_time field (place_time becomes start_time conceptually, end_time is new)
() => {
try { _db.exec('ALTER TABLE places ADD COLUMN end_time TEXT'); } catch {}
},
// 22: Move reservation fields from places to day_assignments
() => {
// Add new columns to day_assignments
try { _db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_status TEXT DEFAULT \'none\''); } catch {}
try { _db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_notes TEXT'); } catch {}
try { _db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_datetime TEXT'); } catch {}
// Migrate existing data: copy reservation info from places to all their assignments
try {
_db.exec(`
UPDATE day_assignments SET
reservation_status = (SELECT reservation_status FROM places WHERE places.id = day_assignments.place_id),
reservation_notes = (SELECT reservation_notes FROM places WHERE places.id = day_assignments.place_id),
reservation_datetime = (SELECT reservation_datetime FROM places WHERE places.id = day_assignments.place_id)
WHERE place_id IN (SELECT id FROM places WHERE reservation_status IS NOT NULL AND reservation_status != 'none')
`);
console.log('[DB] Migrated reservation data from places to day_assignments');
} catch (e) {
console.error('[DB] Migration 22 data copy error:', e.message);
}
},
// 23: Add assignment_id to reservations table
() => {
try { _db.exec('ALTER TABLE reservations ADD COLUMN assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL'); } catch {}
},
// Future migrations go here (append only, never reorder)
]; ];
// Recreate budget_items to allow NULL persons (SQLite can't ALTER NOT NULL) if (currentVersion < migrations.length) {
try { for (let i = currentVersion; i < migrations.length; i++) {
const hasNotNull = _db.prepare("SELECT sql FROM sqlite_master WHERE name = 'budget_items'").get() console.log(`[DB] Running migration ${i + 1}/${migrations.length}`);
if (hasNotNull?.sql?.includes('NOT NULL DEFAULT 1')) { migrations[i]();
_db.exec(`
CREATE TABLE budget_items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
category TEXT NOT NULL DEFAULT 'Sonstiges',
name TEXT NOT NULL,
total_price REAL NOT NULL DEFAULT 0,
persons INTEGER DEFAULT NULL,
days INTEGER DEFAULT NULL,
note TEXT,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO budget_items_new SELECT * FROM budget_items;
DROP TABLE budget_items;
ALTER TABLE budget_items_new RENAME TO budget_items;
`)
} }
} catch (e) { /* table doesn't exist yet or already migrated */ } _db.prepare('UPDATE schema_version SET version = ?').run(migrations.length);
for (const sql of migrations) { console.log(`[DB] Migrations complete — schema version ${migrations.length}`);
try { _db.exec(sql); } catch (e) { /* column already exists */ }
} }
// First registered user becomes admin — no default admin seed needed // First registered user becomes admin — no default admin seed needed
@@ -367,14 +459,14 @@ function initDb() {
const defaultCategories = [ const defaultCategories = [
{ name: 'Hotel', color: '#3b82f6', icon: '🏨' }, { name: 'Hotel', color: '#3b82f6', icon: '🏨' },
{ name: 'Restaurant', color: '#ef4444', icon: '🍽️' }, { name: 'Restaurant', color: '#ef4444', icon: '🍽️' },
{ name: 'Sehenswürdigkeit', color: '#8b5cf6', icon: '🏛️' }, { name: 'Attraction', color: '#8b5cf6', icon: '🏛️' },
{ name: 'Shopping', color: '#f59e0b', icon: '🛍️' }, { name: 'Shopping', color: '#f59e0b', icon: '🛍️' },
{ name: 'Transport', color: '#6b7280', icon: '🚌' }, { name: 'Transport', color: '#6b7280', icon: '🚌' },
{ name: 'Aktivität', color: '#10b981', icon: '🎯' }, { name: 'Activity', color: '#10b981', icon: '🎯' },
{ name: 'Bar/Café', color: '#f97316', icon: '☕' }, { name: 'Bar/Cafe', color: '#f97316', icon: '☕' },
{ name: 'Strand', color: '#06b6d4', icon: '🏖️' }, { name: 'Beach', color: '#06b6d4', icon: '🏖️' },
{ name: 'Natur', color: '#84cc16', icon: '🌿' }, { name: 'Nature', color: '#84cc16', icon: '🌿' },
{ name: 'Sonstiges', color: '#6366f1', icon: '📍' }, { name: 'Other', color: '#6366f1', icon: '📍' },
]; ];
const insertCat = _db.prepare('INSERT INTO categories (name, color, icon) VALUES (?, ?, ?)'); const insertCat = _db.prepare('INSERT INTO categories (name, color, icon) VALUES (?, ?, ?)');
for (const cat of defaultCategories) insertCat.run(cat.name, cat.color, cat.icon); for (const cat of defaultCategories) insertCat.run(cat.name, cat.color, cat.icon);
@@ -421,6 +513,7 @@ if (process.env.DEMO_MODE === 'true') {
// without needing a server restart after reinitialize() // without needing a server restart after reinitialize()
const db = new Proxy({}, { const db = new Proxy({}, {
get(_, prop) { get(_, prop) {
if (!_db) throw new Error('Database connection is not available (restore in progress?)');
const val = _db[prop]; const val = _db[prop];
return typeof val === 'function' ? val.bind(_db) : val; return typeof val === 'function' ? val.bind(_db) : val;
}, },
+105 -75
View File
@@ -63,15 +63,18 @@ function ensureDemoMembership(db, adminId, demoId) {
function seedExampleTrips(db, adminId, demoId) { function seedExampleTrips(db, adminId, demoId) {
const insertTrip = db.prepare('INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)'); const insertTrip = db.prepare('INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)');
const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)'); const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
const insertPlace = db.prepare('INSERT INTO places (trip_id, name, lat, lng, address, category_id, place_time, duration_minutes, notes, image_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); const insertPlace = db.prepare('INSERT INTO places (trip_id, name, lat, lng, address, category_id, place_time, duration_minutes, notes, image_url, google_place_id, website, phone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
const insertAssignment = db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, ?)'); const insertAssignment = db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, ?)');
const insertPacking = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)'); const insertPacking = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)');
const insertBudget = db.prepare('INSERT INTO budget_items (trip_id, category, name, total_price, persons, note) VALUES (?, ?, ?, ?, ?, ?)'); const insertBudget = db.prepare('INSERT INTO budget_items (trip_id, category, name, total_price, persons, note) VALUES (?, ?, ?, ?, ?, ?)');
const insertReservation = db.prepare('INSERT INTO reservations (trip_id, day_id, title, reservation_time, confirmation_number, status, type, location) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); const insertReservation = db.prepare('INSERT INTO reservations (trip_id, day_id, title, reservation_time, confirmation_number, status, type, location) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
const insertMember = db.prepare('INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)'); const insertMember = db.prepare('INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)');
const insertNote = db.prepare('INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)');
// ─── Trip 1: Tokyo & Kyoto ─── // Category IDs: 1=Hotel, 2=Restaurant, 3=Attraction, 5=Transport, 7=Bar/Cafe, 8=Beach, 9=Nature, 6=Entertainment
const trip1 = insertTrip.run(adminId, 'Tokyo & Kyoto', 'Zwei Wochen Japan — von den neonbeleuchteten Strassen Tokyos bis zu den stillen Tempeln Kyotos.', '2026-04-15', '2026-04-21', 'EUR');
// ─── Trip 1: Tokyo & Kyoto ─────────────────────────────────────────────────
const trip1 = insertTrip.run(adminId, 'Tokyo & Kyoto', 'Two weeks in Japan — from the neon-lit streets of Tokyo to the serene temples of Kyoto.', '2026-04-15', '2026-04-21', 'JPY');
const t1 = Number(trip1.lastInsertRowid); const t1 = Number(trip1.lastInsertRowid);
const t1days = []; const t1days = [];
@@ -80,38 +83,39 @@ function seedExampleTrips(db, adminId, demoId) {
t1days.push(Number(d.lastInsertRowid)); t1days.push(Number(d.lastInsertRowid));
} }
// Places — cat IDs: 1=Hotel, 2=Restaurant, 3=Sehenswuerdigkeit, 5=Transport, 7=Bar/Cafe, 9=Natur
const t1places = [ const t1places = [
[t1, 'Hotel Shinjuku Granbell', 35.6938, 139.7035, 'Shinjuku, Tokyo, Japan', 1, '15:00', 60, 'Check-in ab 15 Uhr. Nahe Shinjuku Station.', null], [t1, 'Hotel Shinjuku Granbell', 35.6938, 139.7035, '2-14-5 Kabukicho, Shinjuku City, Tokyo 160-0021, Japan', 1, '15:00', 60, 'Check-in from 3 PM. Steps from Shinjuku Station.', null, 'ChIJdaGEJBeMGGARYgt8sLBv6lM', 'https://www.grfranbellhotel.jp/shinjuku/', '+81 3-5155-2666'],
[t1, 'Senso-ji Tempel', 35.7148, 139.7967, 'Asakusa, Tokyo, Japan', 3, '09:00', 90, 'Aeltester Tempel Tokyos. Morgens weniger Touristen.', null], [t1, 'Senso-ji Temple', 35.7148, 139.7967, '2 Chome-3-1 Asakusa, Taito City, Tokyo 111-0032, Japan', 3, '09:00', 90, 'Oldest temple in Tokyo. Fewer tourists in the early morning.', null, 'ChIJ8T1GpMGOGGARDYGSgpoOdfg', 'https://www.senso-ji.jp/', '+81 3-3842-0181'],
[t1, 'Shibuya Crossing', 35.6595, 139.7004, 'Shibuya, Tokyo, Japan', 3, '18:00', 45, 'Die beruehmteste Kreuzung der Welt. Abends am beeindruckendsten.', null], [t1, 'Shibuya Crossing', 35.6595, 139.7004, '2 Chome-2-1 Dogenzaka, Shibuya City, Tokyo 150-0043, Japan', 3, '18:00', 45, 'World\'s busiest pedestrian crossing. Most impressive at night.', null, 'ChIJLyzOhmyLGGARMKWbl5z6wGg', null, null],
[t1, 'Tsukiji Outer Market', 35.6654, 139.7707, 'Tsukiji, Tokyo, Japan', 2, '08:00', 120, 'Frisches Sushi zum Fruehstueck! Strassenstaende erkunden.', null], [t1, 'Tsukiji Outer Market', 35.6654, 139.7707, '4 Chome-16-2 Tsukiji, Chuo City, Tokyo 104-0045, Japan', 2, '08:00', 120, 'Fresh sushi for breakfast! Explore the street food stalls.', null, 'ChIJq2i1dZCLGGAR1TfoBRo25VU', 'https://www.tsukiji.or.jp/', null],
[t1, 'Meiji-Schrein', 35.6764, 139.6993, 'Shibuya, Tokyo, Japan', 3, '10:00', 75, 'Ruhige Oase mitten in der Stadt. Durch den Wald zum Schrein.', null], [t1, 'Meiji Jingu Shrine', 35.6764, 139.6993, '1-1 Yoyogikamizonocho, Shibuya City, Tokyo 151-8557, Japan', 3, '10:00', 75, 'Peaceful oasis in the middle of the city. Walk through the forest to the shrine.', null, 'ChIJ5SuJSByMGGARMg9qOlTFgkc', 'https://www.meijijingu.or.jp/', '+81 3-3379-5511'],
[t1, 'Akihabara', 35.7023, 139.7745, 'Akihabara, Tokyo, Japan', 3, '14:00', 180, 'Electric Town — Anime, Manga, Elektronik. Retro-Gaming Shops!', null], [t1, 'Akihabara Electric Town', 35.7023, 139.7745, 'Sotokanda, Chiyoda City, Tokyo, Japan', 3, '14:00', 180, 'Electric Town — anime, manga, electronics. Retro gaming shops!', null, 'ChIJGz1usEyMGGAR1mYByqOOJao', null, null],
[t1, 'Shinkansen nach Kyoto', 35.6812, 139.7671, 'Tokyo Station, Japan', 5, '08:30', 140, 'Nozomi Shinkansen, ca. 2h15. Fensterplatz fuer Fuji-Blick!', null], [t1, 'Shinkansen to Kyoto', 35.6812, 139.7671, '1 Chome Marunouchi, Chiyoda City, Tokyo 100-0005, Japan', 5, '08:30', 140, 'Nozomi Shinkansen, approx. 2h15. Window seat for Mt. Fuji views!', null, 'ChIJC3Cf2PuLGGAROO00ukl8JwA', null, null],
[t1, 'Hotel Granvia Kyoto', 34.9856, 135.7580, 'Kyoto Station, Kyoto, Japan', 1, '14:00', 60, 'Direkt am Bahnhof. Perfekte Lage fuer Tagesausfluege.', null], [t1, 'Hotel Granvia Kyoto', 34.9856, 135.7580, 'Karasuma-dori Shiokoji-sagaru, Shimogyo-ku, Kyoto 600-8216, Japan', 1, '14:00', 60, 'Right at Kyoto Station. Perfect base for day trips.', null, 'ChIJUf6MDFcIAWARLihjKC9FWDY', 'https://www.granvia-kyoto.co.jp/', '+81 75-344-8888'],
[t1, 'Fushimi Inari Taisha', 34.9671, 135.7727, 'Fushimi, Kyoto, Japan', 3, '07:00', 150, '10.000 rote Torii-Tore. Frueh morgens starten fuer leere Wege!', null], [t1, 'Fushimi Inari Taisha', 34.9671, 135.7727, '68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto 612-0882, Japan', 3, '07:00', 150, '10,000 vermillion torii gates. Start early for empty paths!', null, 'ChIJIW0JRbMIAWARPYEzP5LVHGE', 'http://inari.jp/', '+81 75-641-7331'],
[t1, 'Kinkaku-ji (Goldener Pavillon)', 35.0394, 135.7292, 'Kita, Kyoto, Japan', 3, '10:00', 60, 'Der goldene Tempel am See. Ikonisches Fotomotiv.', null], [t1, 'Kinkaku-ji (Golden Pavilion)', 35.0394, 135.7292, '1 Kinkakujicho, Kita Ward, Kyoto 603-8361, Japan', 3, '10:00', 60, 'The golden temple reflected in the mirror pond. Iconic photo spot.', null, 'ChIJvUbrwCCoAWAR5-uyAXPzBHg', null, '+81 75-461-0013'],
[t1, 'Arashiyama Bambushain', 35.0095, 135.6673, 'Arashiyama, Kyoto, Japan', 9, '09:00', 90, 'Magischer Bambuswald. Am besten morgens vor den Massen.', null], [t1, 'Arashiyama Bamboo Grove', 35.0095, 135.6673, 'Sagatenryuji Susukinobabacho, Ukyo Ward, Kyoto 616-8385, Japan', 9, '09:00', 90, 'Magical bamboo forest. Best visited in the morning before the crowds.', null, 'ChIJFS4EvA6pAWARQsAPVijvW7I', null, null],
[t1, 'Nishiki Market', 35.0050, 135.7647, 'Nakagyo, Kyoto, Japan', 2, '12:00', 90, 'Kyotos Kuechengasse. Matcha-Eis und frische Mochi probieren!', null], [t1, 'Nishiki Market', 35.0050, 135.7647, 'Nishiki-koji Dori, Nakagyo Ward, Kyoto 604-8054, Japan', 2, '12:00', 90, 'Kyoto\'s kitchen street. Try the matcha ice cream and fresh mochi!', null, 'ChIJ09zzUigJAWARXzIdh1NE3hQ', 'http://www.kyoto-nishiki.or.jp/', null],
[t1, 'Gion Viertel', 35.0037, 135.7755, 'Gion, Kyoto, Japan', 3, '17:00', 120, 'Historisches Geisha-Viertel. Abends beste Chance auf Maiko-Sichtung.', null], [t1, 'Gion District', 35.0037, 135.7755, 'Gionmachi Minamigawa, Higashiyama Ward, Kyoto 605-0074, Japan', 3, '17:00', 120, 'Historic geisha district. Best chance of spotting a maiko in the evening.', null, 'ChIJ7WWWjfYJAWARGqEHAfXIzgQ', null, null],
]; ];
const t1pIds = t1places.map(p => Number(insertPlace.run(...p).lastInsertRowid)); const t1pIds = t1places.map(p => Number(insertPlace.run(...p).lastInsertRowid));
// Assign places to days
// Day 1: Hotel Check-in, Shibuya // Day 1: Hotel Check-in, Shibuya
insertAssignment.run(t1days[0], t1pIds[0], 0); insertAssignment.run(t1days[0], t1pIds[0], 0);
insertAssignment.run(t1days[0], t1pIds[2], 1); insertAssignment.run(t1days[0], t1pIds[2], 1);
insertNote.run(t1days[0], t1, 'Pick up Pocket WiFi at airport', '13:00', 'Info', 0.5);
// Day 2: Tsukiji, Senso-ji, Akihabara // Day 2: Tsukiji, Senso-ji, Akihabara
insertAssignment.run(t1days[1], t1pIds[3], 0); insertAssignment.run(t1days[1], t1pIds[3], 0);
insertAssignment.run(t1days[1], t1pIds[1], 1); insertAssignment.run(t1days[1], t1pIds[1], 1);
insertAssignment.run(t1days[1], t1pIds[5], 2); insertAssignment.run(t1days[1], t1pIds[5], 2);
// Day 3: Meiji-Schrein, free afternoon // Day 3: Meiji Shrine, free afternoon
insertAssignment.run(t1days[2], t1pIds[4], 0); insertAssignment.run(t1days[2], t1pIds[4], 0);
insertNote.run(t1days[2], t1, 'Explore Harajuku after the shrine', '12:00', 'MapPin', 1);
// Day 4: Shinkansen to Kyoto, Hotel // Day 4: Shinkansen to Kyoto, Hotel
insertAssignment.run(t1days[3], t1pIds[6], 0); insertAssignment.run(t1days[3], t1pIds[6], 0);
insertAssignment.run(t1days[3], t1pIds[7], 1); insertAssignment.run(t1days[3], t1pIds[7], 1);
insertNote.run(t1days[3], t1, 'Sit on right side for Mt. Fuji views!', '08:30', 'Train', 0.5);
// Day 5: Fushimi Inari, Nishiki Market // Day 5: Fushimi Inari, Nishiki Market
insertAssignment.run(t1days[4], t1pIds[8], 0); insertAssignment.run(t1days[4], t1pIds[8], 0);
insertAssignment.run(t1days[4], t1pIds[11], 1); insertAssignment.run(t1days[4], t1pIds[11], 1);
@@ -120,34 +124,34 @@ function seedExampleTrips(db, adminId, demoId) {
insertAssignment.run(t1days[5], t1pIds[10], 1); insertAssignment.run(t1days[5], t1pIds[10], 1);
// Day 7: Gion // Day 7: Gion
insertAssignment.run(t1days[6], t1pIds[12], 0); insertAssignment.run(t1days[6], t1pIds[12], 0);
insertNote.run(t1days[6], t1, 'Last evening — farewell dinner at Pontocho Alley', '19:00', 'Star', 1);
// Packing // Packing
const t1packing = [ const t1packing = [
['Reisepass', 1, 'Dokumente', 0], ['Japan Rail Pass', 1, 'Dokumente', 1], ['Passport', 1, 'Documents', 0], ['Japan Rail Pass', 1, 'Documents', 1],
['Adapter Typ A/B', 0, 'Elektronik', 2], ['Kamera + Ladegeraet', 0, 'Elektronik', 3], ['Power adapter Type A/B', 0, 'Electronics', 2], ['Camera + charger', 0, 'Electronics', 3],
['Bequeme Laufschuhe', 0, 'Kleidung', 4], ['Regenjacke', 0, 'Kleidung', 5], ['Comfortable walking shoes', 0, 'Clothing', 4], ['Rain jacket', 0, 'Clothing', 5],
['Sonnencreme', 0, 'Hygiene', 6], ['Reiseapotheke', 0, 'Hygiene', 7], ['Sunscreen', 0, 'Toiletries', 6], ['Travel first aid kit', 0, 'Toiletries', 7],
['Pocket WiFi Bestaetigung', 1, 'Elektronik', 8], ['Yen Bargeld', 0, 'Dokumente', 9], ['Pocket WiFi confirmation', 1, 'Electronics', 8], ['Yen cash', 0, 'Documents', 9],
]; ];
t1packing.forEach(p => insertPacking.run(t1, ...p)); t1packing.forEach(p => insertPacking.run(t1, ...p));
// Budget // Budget
insertBudget.run(t1, 'Unterkunft', 'Hotel Shinjuku (3 Naechte)', 450, 2, 'Doppelzimmer'); insertBudget.run(t1, 'Accommodation', 'Hotel Shinjuku (3 nights)', 67500, 2, 'Double room');
insertBudget.run(t1, 'Unterkunft', 'Hotel Granvia Kyoto (4 Naechte)', 680, 2, 'Superior Room'); insertBudget.run(t1, 'Accommodation', 'Hotel Granvia Kyoto (4 nights)', 102000, 2, 'Superior room');
insertBudget.run(t1, 'Transport', 'Fluege FRA-NRT', 1200, 2, 'Lufthansa Direktflug'); insertBudget.run(t1, 'Transport', 'Flights FRA-NRT return', 180000, 2, 'Lufthansa direct');
insertBudget.run(t1, 'Transport', 'Japan Rail Pass (7 Tage)', 380, 2, 'Ordinaer'); insertBudget.run(t1, 'Transport', 'Japan Rail Pass (7 days)', 57000, 2, 'Ordinary');
insertBudget.run(t1, 'Essen', 'Tagesbudget Essen', 350, 2, 'Ca. 50 EUR/Tag'); insertBudget.run(t1, 'Food', 'Daily food budget', 52500, 2, 'Approx. 7,500 JPY/day');
insertBudget.run(t1, 'Aktivitaeten', 'Tempel-Eintritte & Erlebnisse', 120, 2, null); insertBudget.run(t1, 'Activities', 'Temple entries & experiences', 18000, 2, null);
// Reservations // Reservations
insertReservation.run(t1, t1days[0], 'Hotel Shinjuku Check-in', '15:00', 'SG-2026-78432', 'confirmed', 'hotel', 'Shinjuku, Tokyo'); insertReservation.run(t1, t1days[0], 'Hotel Shinjuku Check-in', '15:00', 'SG-2026-78432', 'confirmed', 'hotel', 'Shinjuku, Tokyo');
insertReservation.run(t1, t1days[3], 'Shinkansen Tokyo → Kyoto', '08:30', 'JR-NOZOMI-445', 'confirmed', 'transport', 'Tokyo Station'); insertReservation.run(t1, t1days[3], 'Shinkansen Tokyo → Kyoto', '08:30', 'JR-NOZOMI-445', 'confirmed', 'transport', 'Tokyo Station');
// Share with demo user
insertMember.run(t1, demoId, adminId); insertMember.run(t1, demoId, adminId);
// ─── Trip 2: Barcelona Citytrip ─── // ─── Trip 2: Barcelona Long Weekend ────────────────────────────────────────
const trip2 = insertTrip.run(adminId, 'Barcelona Citytrip', 'Gaudi, Tapas und Meerblick — ein langes Wochenende in Kataloniens Hauptstadt.', '2026-05-21', '2026-05-24', 'EUR'); const trip2 = insertTrip.run(adminId, 'Barcelona Long Weekend', 'Gaudi, tapas, and Mediterranean vibes — a long weekend in the Catalan capital.', '2026-05-21', '2026-05-24', 'EUR');
const t2 = Number(trip2.lastInsertRowid); const t2 = Number(trip2.lastInsertRowid);
const t2days = []; const t2days = [];
@@ -157,14 +161,14 @@ function seedExampleTrips(db, adminId, demoId) {
} }
const t2places = [ const t2places = [
[t2, 'Hotel W Barcelona', 41.3686, 2.1920, 'Barceloneta, Barcelona, Spain', 1, '14:00', 60, 'Direkt am Strand. Rooftop-Bar mit Panorama!', null], [t2, 'W Barcelona', 41.3686, 2.1920, 'Placa de la Rosa dels Vents 1, 08039 Barcelona, Spain', 1, '14:00', 60, 'Right on the beach. Rooftop bar with panoramic views!', null, 'ChIJKfj5C8yjpBIRCPC3RPI0JO4', 'https://www.marriott.com/hotels/travel/bcnwh-w-barcelona/', '+34 932 95 28 00'],
[t2, 'Sagrada Familia', 41.4036, 2.1744, 'Eixample, Barcelona, Spain', 3, '10:00', 120, 'Gaudis Meisterwerk. Tickets unbedingt vorher online buchen!', null], [t2, 'Sagrada Familia', 41.4036, 2.1744, 'C/ de Mallorca, 401, 08013 Barcelona, Spain', 3, '10:00', 120, 'Gaudi\'s masterpiece. Book tickets online in advance — sells out fast!', null, 'ChIJk_s92NyipBIRUMnDG8Kq2Js', 'https://sagradafamilia.org/', '+34 932 08 04 14'],
[t2, 'Park Gueell', 41.4145, 2.1527, 'Gracia, Barcelona, Spain', 3, '09:00', 90, 'Mosaik-Terrasse mit Stadtblick. Frueh buchen fuer Monumental Zone.', null], [t2, 'Park Guell', 41.4145, 2.1527, '08024 Barcelona, Spain', 3, '09:00', 90, 'Mosaic terrace with city views. Book early for the Monumental Zone.', null, 'ChIJ4eQMeOmipBIRb65JRUzGE8k', 'https://parkguell.barcelona/', '+34 934 09 18 31'],
[t2, 'La Boqueria', 41.3816, 2.1717, 'La Rambla, Barcelona, Spain', 2, '12:00', 75, 'Beruehmter Markt an der Rambla. Frischer Saft und Jamon Iberico!', null], [t2, 'La Boqueria Market', 41.3816, 2.1717, 'La Rambla, 91, 08001 Barcelona, Spain', 2, '12:00', 75, 'Famous market on La Rambla. Fresh juice, jamon iberico, and seafood!', null, 'ChIJB_RfKcuipBIRkPKW7MzVGKg', 'http://www.boqueria.barcelona/', '+34 933 18 25 84'],
[t2, 'Barceloneta Beach', 41.3784, 2.1925, 'Barceloneta, Barcelona, Spain', 8, '16:00', 120, 'Stadtstrand zum Entspannen nach dem Sightseeing.', null], [t2, 'Barceloneta Beach', 41.3784, 2.1925, 'Passeig Maritim de la Barceloneta, 08003 Barcelona, Spain', 8, '16:00', 120, 'City beach to unwind after sightseeing. Great chiringuitos nearby.', null, 'ChIJAQCl79-ipBIRUKF3myrMYkM', null, null],
[t2, 'Barri Gotic', 41.3834, 2.1762, 'Ciutat Vella, Barcelona, Spain', 3, '15:00', 90, 'Mittelalterliche Gassen. Kathedrale und Placa Reial entdecken.', null], [t2, 'Gothic Quarter', 41.3834, 2.1762, 'Barri Gotic, 08002 Barcelona, Spain', 3, '15:00', 90, 'Medieval lanes, the cathedral, and Placa Reial. Get lost in the alleys!', null, 'ChIJ4_xkvv2ipBIRrK3bdd-lHgo', null, null],
[t2, 'Casa Batllo', 41.3916, 2.1650, 'Passeig de Gracia, Barcelona, Spain', 3, '11:00', 75, 'Gaudis Drachen-Haus. Die Fassade allein ist schon ein Erlebnis.', null], [t2, 'Casa Batllo', 41.3916, 2.1650, 'Passeig de Gracia, 43, 08007 Barcelona, Spain', 3, '11:00', 75, 'Gaudi\'s dragon house. The facade alone is worth the visit.', null, 'ChIJ-2VKIcaipBIRKK63H5PYjqQ', 'https://www.casabatllo.es/', '+34 932 16 03 06'],
[t2, 'El Born & Tapas', 41.3856, 2.1825, 'El Born, Barcelona, Spain', 7, '20:00', 120, 'Trendviertel mit den besten Tapas-Bars. Cal Pep oder El Xampanyet!', null], [t2, 'El Born & Tapas', 41.3856, 2.1825, 'El Born, 08003 Barcelona, Spain', 7, '20:00', 120, 'Trendy neighborhood with the best tapas bars. Try Cal Pep or El Xampanyet!', null, 'ChIJNY56dxuipBIRbqjSczmLvIA', null, null],
]; ];
const t2pIds = t2places.map(p => Number(insertPlace.run(...p).lastInsertRowid)); const t2pIds = t2places.map(p => Number(insertPlace.run(...p).lastInsertRowid));
@@ -177,68 +181,94 @@ function seedExampleTrips(db, adminId, demoId) {
insertAssignment.run(t2days[1], t2pIds[1], 0); insertAssignment.run(t2days[1], t2pIds[1], 0);
insertAssignment.run(t2days[1], t2pIds[6], 1); insertAssignment.run(t2days[1], t2pIds[6], 1);
insertAssignment.run(t2days[1], t2pIds[3], 2); insertAssignment.run(t2days[1], t2pIds[3], 2);
// Day 3: Park Gueell, Barri Gotic insertNote.run(t2days[1], t2, 'Tickets already booked for 10:00 AM slot', '09:30', 'Ticket', 0.5);
// Day 3: Park Guell, Gothic Quarter
insertAssignment.run(t2days[2], t2pIds[2], 0); insertAssignment.run(t2days[2], t2pIds[2], 0);
insertAssignment.run(t2days[2], t2pIds[5], 1); insertAssignment.run(t2days[2], t2pIds[5], 1);
// Day 4: Free morning, departure // Day 4: Beach morning, departure
insertAssignment.run(t2days[3], t2pIds[4], 0); insertAssignment.run(t2days[3], t2pIds[4], 0);
insertNote.run(t2days[3], t2, 'Flight departs at 18:30 — leave hotel by 15:00', '14:00', 'Plane', 1);
// Packing // Packing
['Reisepass', 'Sonnencreme SPF50', 'Badehose/Bikini', 'Sonnenbrille', 'Bequeme Sandalen', 'Strandtuch'].forEach((name, i) => { ['Passport', 'Sunscreen SPF50', 'Swimwear', 'Sunglasses', 'Comfortable sandals', 'Beach towel'].forEach((name, i) => {
insertPacking.run(t2, name, 0, i < 1 ? 'Dokumente' : 'Sommer', i); insertPacking.run(t2, name, 0, i < 1 ? 'Documents' : 'Summer', i);
}); });
// Budget // Budget
insertBudget.run(t2, 'Unterkunft', 'Hotel W Barcelona (3 Naechte)', 780, 2, 'Sea View Room'); insertBudget.run(t2, 'Accommodation', 'W Barcelona (3 nights)', 780, 2, 'Sea View Room');
insertBudget.run(t2, 'Transport', 'Fluege BER-BCN', 180, 2, 'Eurowings'); insertBudget.run(t2, 'Transport', 'Flights BER-BCN return', 180, 2, 'Eurowings');
insertBudget.run(t2, 'Essen', 'Restaurants & Tapas', 300, 2, 'Ca. 75 EUR/Tag'); insertBudget.run(t2, 'Food', 'Restaurants & tapas', 300, 2, 'Approx. 75 EUR/day');
insertBudget.run(t2, 'Aktivitaeten', 'Sagrada Familia + Park Gueell + Casa Batllo', 95, 2, 'Online-Tickets'); insertBudget.run(t2, 'Activities', 'Sagrada Familia + Park Guell + Casa Batllo', 95, 2, 'Online tickets');
insertReservation.run(t2, t2days[1], 'Sagrada Familia Eintritt', '10:00', 'SF-2026-11234', 'confirmed', 'activity', 'Eixample, Barcelona'); insertReservation.run(t2, t2days[1], 'Sagrada Familia Entry', '10:00', 'SF-2026-11234', 'confirmed', 'activity', 'Eixample, Barcelona');
insertMember.run(t2, demoId, adminId); insertMember.run(t2, demoId, adminId);
// ─── Trip 3: Wochenende in Wien ─── // ─── Trip 3: New York City ─────────────────────────────────────────────────
const trip3 = insertTrip.run(adminId, 'Wochenende in Wien', 'Kaffeehaus-Kultur, imperiale Pracht und Sachertorte.', '2026-06-12', '2026-06-14', 'EUR'); const trip3 = insertTrip.run(adminId, 'New York City', 'The city that never sleeps — iconic landmarks, world-class food, and Broadway lights.', '2026-09-18', '2026-09-22', 'USD');
const t3 = Number(trip3.lastInsertRowid); const t3 = Number(trip3.lastInsertRowid);
const t3days = []; const t3days = [];
for (let i = 0; i < 3; i++) { for (let i = 0; i < 5; i++) {
const d = insertDay.run(t3, i + 1, `2026-06-${12 + i}`); const d = insertDay.run(t3, i + 1, `2026-09-${18 + i}`);
t3days.push(Number(d.lastInsertRowid)); t3days.push(Number(d.lastInsertRowid));
} }
const t3places = [ const t3places = [
[t3, 'Hotel Sacher Wien', 48.2038, 16.3699, 'Philharmonikerstrasse 4, Wien, Austria', 1, '15:00', 45, 'Das legendaere Hotel. Sachertorte im Cafe muss sein!', null], [t3, 'The Plaza Hotel', 40.7645, -73.9744, '768 5th Ave, New York, NY 10019, USA', 1, '15:00', 60, 'Iconic luxury hotel on Central Park. The lobby alone is worth a visit.', null, 'ChIJYbISlAVYwokRn6ORbSPV0xk', 'https://www.theplazany.com/', '+1 212-759-3000'],
[t3, 'Stephansdom', 48.2082, 16.3738, 'Stephansplatz, Wien, Austria', 3, '10:00', 60, 'Wahrzeichen Wiens. Turmbesteigung fuer 360-Grad-Blick.', null], [t3, 'Statue of Liberty', 40.6892, -74.0445, 'Liberty Island, New York, NY 10004, USA', 3, '09:00', 180, 'Book crown access tickets months in advance. Ferry from Battery Park.', null, 'ChIJPTacEpBQwokRKwIlDXelxkA', 'https://www.nps.gov/stli/', '+1 212-363-3200'],
[t3, 'Schloss Schoenbrunn', 48.1845, 16.3122, 'Schoenbrunn, Wien, Austria', 3, '09:30', 150, 'Imperiale Pracht. Grand Tour Ticket fuer alle 40 Raeume.', null], [t3, 'Central Park', 40.7829, -73.9654, 'Central Park, New York, NY 10024, USA', 9, '10:00', 120, 'Bethesda Fountain, Bow Bridge, and Strawberry Fields. Rent bikes!', null, 'ChIJ4zGFAZpYwokRGUGph3Mf37k', 'https://www.centralparknyc.org/', null],
[t3, 'Naschmarkt', 48.1986, 16.3633, 'Wienzeile, Wien, Austria', 2, '12:00', 75, 'Wiens groesster Markt. Orientalische Gewuerze bis Wiener Schnitzel.', null], [t3, 'Times Square', 40.7580, -73.9855, 'Manhattan, NY 10036, USA', 3, '19:00', 60, 'The crossroads of the world. Best experienced at night with all the lights.', null, 'ChIJmQJIxlVYwokRLgeuocVOGVU', 'https://www.timessquarenyc.org/', null],
[t3, 'Cafe Central', 48.2107, 16.3654, 'Herrengasse 14, Wien, Austria', 7, '15:00', 60, 'Wo einst Trotzki Schach spielte. Melange und Apfelstrudel!', null], [t3, 'Empire State Building', 40.7484, -73.9857, '350 5th Ave, New York, NY 10118, USA', 3, '11:00', 90, '86th floor observation deck. Go at sunset for the best views.', null, 'ChIJaXQRs6lZwokRY6EFpJnhNNE', 'https://www.esbnyc.com/', '+1 212-736-3100'],
[t3, 'Prater & Riesenrad', 48.2166, 16.3964, 'Prater, Wien, Austria', 6, '17:00', 90, 'Riesenrad bei Sonnenuntergang. Blick ueber die ganze Stadt.', null], [t3, 'Brooklyn Bridge', 40.7061, -73.9969, 'Brooklyn Bridge, New York, NY 10038, USA', 3, '16:00', 75, 'Walk from Manhattan to Brooklyn. DUMBO has great pizza and views.', null, 'ChIJK3vOQyNawokRXEYwET2GUtY', null, null],
[t3, 'The Metropolitan Museum of Art', 40.7794, -73.9632, '1000 5th Ave, New York, NY 10028, USA', 3, '10:00', 180, 'One of the world\'s greatest art museums. Could spend days here.', null, 'ChIJb8Jg766MwokR1YWG0nV7k-E', 'https://www.metmuseum.org/', '+1 212-535-7710'],
[t3, 'Joe\'s Pizza', 40.7309, -73.9969, '7 Carmine St, New York, NY 10014, USA', 2, '13:00', 30, 'New York\'s most famous pizza slice. Cash only, always a line, always worth it.', null, 'ChIJrfCL1IZZwokRwO3NKN22ZBc', 'http://www.joespizzanyc.com/', '+1 212-366-1182'],
[t3, 'Top of the Rock', 40.7593, -73.9794, '30 Rockefeller Plaza, New York, NY 10112, USA', 3, '17:30', 60, 'Better views than Empire State because you can SEE the Empire State.', null, 'ChIJ_y2Fb1JYwokRT_iGzhTLdBo', 'https://www.topoftherocknyc.com/', '+1 212-698-2000'],
[t3, 'Chelsea Market', 40.7424, -74.0061, '75 9th Ave, New York, NY 10011, USA', 2, '12:00', 90, 'Food hall in a converted factory. Lobster rolls, tacos, doughnuts, and more.', null, 'ChIJw2FNFyZZwokRcP9th_vIbkE', 'https://www.chelseamarket.com/', null],
[t3, 'Broadway Show', 40.7590, -73.9845, 'Broadway, Manhattan, NY 10019, USA', 6, '20:00', 150, 'Can\'t visit NYC without seeing a show. Book TKTS booth for discounts.', null, 'ChIJMYQhxFtYwokR7cJBcNqfKDY', null, null],
]; ];
const t3pIds = t3places.map(p => Number(insertPlace.run(...p).lastInsertRowid)); const t3pIds = t3places.map(p => Number(insertPlace.run(...p).lastInsertRowid));
// Day 1: Arrival, Stephansdom, Cafe Central // Day 1: Arrival, Times Square, Broadway
insertAssignment.run(t3days[0], t3pIds[0], 0); insertAssignment.run(t3days[0], t3pIds[0], 0);
insertAssignment.run(t3days[0], t3pIds[1], 1); insertAssignment.run(t3days[0], t3pIds[3], 1);
insertAssignment.run(t3days[0], t3pIds[4], 2); insertAssignment.run(t3days[0], t3pIds[10], 2);
// Day 2: Schoenbrunn, Naschmarkt, Prater // Day 2: Statue of Liberty, Brooklyn Bridge, Joe's Pizza
insertAssignment.run(t3days[1], t3pIds[2], 0); insertAssignment.run(t3days[1], t3pIds[1], 0);
insertAssignment.run(t3days[1], t3pIds[3], 1); insertAssignment.run(t3days[1], t3pIds[5], 1);
insertAssignment.run(t3days[1], t3pIds[5], 2); insertAssignment.run(t3days[1], t3pIds[7], 2);
// Day 3: Free morning insertNote.run(t3days[1], t3, 'First ferry at 8:30 AM — arrive early at Battery Park', '08:00', 'Ship', 0.5);
insertAssignment.run(t3days[2], t3pIds[4], 0); // Day 3: Central Park, Met Museum, Top of the Rock sunset
insertAssignment.run(t3days[2], t3pIds[2], 0);
insertAssignment.run(t3days[2], t3pIds[6], 1);
insertAssignment.run(t3days[2], t3pIds[8], 2);
// Day 4: Empire State Building, Chelsea Market, shopping
insertAssignment.run(t3days[3], t3pIds[4], 0);
insertAssignment.run(t3days[3], t3pIds[9], 1);
insertNote.run(t3days[3], t3, 'SoHo and 5th Avenue shopping in the afternoon', '14:00', 'ShoppingBag', 1.5);
// Day 5: Free morning, departure
insertNote.run(t3days[4], t3, 'Flight departs JFK at 17:00 — last bagel at Russ & Daughters!', '10:00', 'Plane', 0);
// Packing // Packing
['Personalausweis', 'Regenschirm', 'Bequeme Schuhe', 'Kamera'].forEach((name, i) => { const t3packing = [
insertPacking.run(t3, name, 0, i < 1 ? 'Dokumente' : 'Sonstiges', i); ['Passport', 1, 'Documents', 0], ['ESTA confirmation', 1, 'Documents', 1],
}); ['Travel insurance', 0, 'Documents', 2], ['Comfortable sneakers', 0, 'Clothing', 3],
['Light jacket', 0, 'Clothing', 4], ['Portable charger', 0, 'Electronics', 5],
['Camera', 0, 'Electronics', 6], ['Subway card (OMNY)', 0, 'Transport', 7],
];
t3packing.forEach(p => insertPacking.run(t3, ...p));
// Budget // Budget
insertBudget.run(t3, 'Unterkunft', 'Hotel Sacher (2 Naechte)', 520, 2, 'Classic Doppelzimmer'); insertBudget.run(t3, 'Accommodation', 'The Plaza Hotel (4 nights)', 2400, 2, 'Park View Room');
insertBudget.run(t3, 'Transport', 'Zug MUC-VIE', 60, 2, 'OeBB Sparschiene'); insertBudget.run(t3, 'Transport', 'Flights FRA-JFK return', 850, 2, 'United Airlines');
insertBudget.run(t3, 'Essen', 'Restaurants & Cafes', 200, 2, null); insertBudget.run(t3, 'Food', 'Daily food budget', 500, 2, 'Approx. 100 USD/day');
insertBudget.run(t3, 'Activities', 'Statue of Liberty + Empire State + Top of the Rock + Met', 180, 2, 'CityPASS');
insertBudget.run(t3, 'Entertainment', 'Broadway show tickets', 300, 2, 'Hamilton or Wicked');
insertReservation.run(t3, t3days[0], 'The Plaza Hotel Check-in', '15:00', 'PZ-2026-55891', 'confirmed', 'hotel', '768 5th Ave, New York');
insertReservation.run(t3, t3days[0], 'Broadway Show', '20:00', 'BW-HAM-2026-1192', 'pending', 'activity', 'Richard Rodgers Theatre');
insertReservation.run(t3, t3days[1], 'Statue of Liberty Ferry', '08:30', 'SOL-2026-3347', 'confirmed', 'transport', 'Battery Park');
insertMember.run(t3, demoId, adminId); insertMember.run(t3, demoId, adminId);
+29 -10
View File
@@ -1,6 +1,7 @@
require('dotenv').config(); require('dotenv').config();
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const helmet = require('helmet');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
@@ -42,16 +43,11 @@ app.use(cors({
origin: corsOrigin, origin: corsOrigin,
credentials: true credentials: true
})); }));
app.use(express.json()); app.use(helmet({
contentSecurityPolicy: false, // managed by frontend meta tag or reverse proxy
// Security headers crossOriginEmbedderPolicy: false, // allows loading external images (maps, etc.)
app.use((req, res, next) => { }));
res.setHeader('X-Content-Type-Options', 'nosniff'); app.use(express.json({ limit: '100kb' }));
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
next();
});
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
// Serve uploaded files // Serve uploaded files
@@ -61,6 +57,7 @@ app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const tripsRoutes = require('./routes/trips'); const tripsRoutes = require('./routes/trips');
const daysRoutes = require('./routes/days'); const daysRoutes = require('./routes/days');
const accommodationsRoutes = require('./routes/days').accommodationsRouter;
const placesRoutes = require('./routes/places'); const placesRoutes = require('./routes/places');
const assignmentsRoutes = require('./routes/assignments'); const assignmentsRoutes = require('./routes/assignments');
const packingRoutes = require('./routes/packing'); const packingRoutes = require('./routes/packing');
@@ -81,6 +78,7 @@ app.use('/api/auth', authRoutes);
app.use('/api/auth/oidc', oidcRoutes); app.use('/api/auth/oidc', oidcRoutes);
app.use('/api/trips', tripsRoutes); app.use('/api/trips', tripsRoutes);
app.use('/api/trips/:tripId/days', daysRoutes); app.use('/api/trips/:tripId/days', daysRoutes);
app.use('/api/trips/:tripId/accommodations', accommodationsRoutes);
app.use('/api/trips/:tripId/places', placesRoutes); app.use('/api/trips/:tripId/places', placesRoutes);
app.use('/api/trips/:tripId/packing', packingRoutes); app.use('/api/trips/:tripId/packing', packingRoutes);
app.use('/api/trips/:tripId/files', filesRoutes); app.use('/api/trips/:tripId/files', filesRoutes);
@@ -139,4 +137,25 @@ const server = app.listen(PORT, () => {
setupWebSocket(server); setupWebSocket(server);
}); });
// Graceful shutdown
function shutdown(signal) {
console.log(`\n${signal} received — shutting down gracefully...`);
scheduler.stop();
server.close(() => {
console.log('HTTP server closed');
const { closeDb } = require('./db/database');
closeDb();
console.log('Shutdown complete');
process.exit(0);
});
// Force exit after 10s if connections don't close
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 10000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
module.exports = app; module.exports = app;
+92 -10
View File
@@ -1,5 +1,7 @@
const express = require('express'); const express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const { execSync } = require('child_process');
const path = require('path');
const { db } = require('../db/database'); const { db } = require('../db/database');
const { authenticate, adminOnly } = require('../middleware/auth'); const { authenticate, adminOnly } = require('../middleware/auth');
@@ -28,18 +30,18 @@ router.post('/users', (req, res) => {
const { username, email, password, role } = req.body; const { username, email, password, role } = req.body;
if (!username?.trim() || !email?.trim() || !password?.trim()) { if (!username?.trim() || !email?.trim() || !password?.trim()) {
return res.status(400).json({ error: 'Benutzername, E-Mail und Passwort sind erforderlich' }); return res.status(400).json({ error: 'Username, email and password are required' });
} }
if (role && !['user', 'admin'].includes(role)) { if (role && !['user', 'admin'].includes(role)) {
return res.status(400).json({ error: 'Ungültige Rolle' }); return res.status(400).json({ error: 'Invalid role' });
} }
const existingUsername = db.prepare('SELECT id FROM users WHERE username = ?').get(username.trim()); const existingUsername = db.prepare('SELECT id FROM users WHERE username = ?').get(username.trim());
if (existingUsername) return res.status(409).json({ error: 'Benutzername bereits vergeben' }); if (existingUsername) return res.status(409).json({ error: 'Username already taken' });
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email.trim()); const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email.trim());
if (existingEmail) return res.status(409).json({ error: 'E-Mail bereits vergeben' }); if (existingEmail) return res.status(409).json({ error: 'Email already taken' });
const passwordHash = bcrypt.hashSync(password.trim(), 10); const passwordHash = bcrypt.hashSync(password.trim(), 10);
@@ -59,19 +61,19 @@ router.put('/users/:id', (req, res) => {
const { username, email, role, password } = req.body; const { username, email, role, password } = req.body;
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); if (!user) return res.status(404).json({ error: 'User not found' });
if (role && !['user', 'admin'].includes(role)) { if (role && !['user', 'admin'].includes(role)) {
return res.status(400).json({ error: 'Ungültige Rolle' }); return res.status(400).json({ error: 'Invalid role' });
} }
if (username && username !== user.username) { if (username && username !== user.username) {
const conflict = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, req.params.id); const conflict = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, req.params.id);
if (conflict) return res.status(409).json({ error: 'Benutzername bereits vergeben' }); if (conflict) return res.status(409).json({ error: 'Username already taken' });
} }
if (email && email !== user.email) { if (email && email !== user.email) {
const conflict = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, req.params.id); const conflict = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, req.params.id);
if (conflict) return res.status(409).json({ error: 'E-Mail bereits vergeben' }); if (conflict) return res.status(409).json({ error: 'Email already taken' });
} }
const passwordHash = password ? bcrypt.hashSync(password, 10) : null; const passwordHash = password ? bcrypt.hashSync(password, 10) : null;
@@ -96,11 +98,11 @@ router.put('/users/:id', (req, res) => {
// DELETE /api/admin/users/:id // DELETE /api/admin/users/:id
router.delete('/users/:id', (req, res) => { router.delete('/users/:id', (req, res) => {
if (parseInt(req.params.id) === req.user.id) { if (parseInt(req.params.id) === req.user.id) {
return res.status(400).json({ error: 'Eigenes Konto kann nicht gelöscht werden' }); return res.status(400).json({ error: 'Cannot delete own account' });
} }
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id); const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); if (!user) return res.status(404).json({ error: 'User not found' });
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
res.json({ success: true }); res.json({ success: true });
@@ -152,6 +154,86 @@ router.post('/save-demo-baseline', (req, res) => {
} }
}); });
// ── Version check ──────────────────────────────────────────
// Detect if running inside Docker
const isDocker = (() => {
try {
const fs = require('fs');
return fs.existsSync('/.dockerenv') || (fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker'));
} catch { return false }
})();
router.get('/version-check', async (req, res) => {
const { version: currentVersion } = require('../../package.json');
try {
const resp = await fetch(
'https://api.github.com/repos/mauriceboe/NOMAD/releases/latest',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'NOMAD-Server' } }
);
if (!resp.ok) return res.json({ current: currentVersion, latest: currentVersion, update_available: false });
const data = await resp.json();
const latest = (data.tag_name || '').replace(/^v/, '');
const update_available = latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
res.json({ current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker });
} catch {
res.json({ current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker });
}
});
function compareVersions(a, b) {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const na = pa[i] || 0, nb = pb[i] || 0;
if (na > nb) return 1;
if (na < nb) return -1;
}
return 0;
}
// POST /api/admin/update — pull latest code, install deps, restart
router.post('/update', async (req, res) => {
const rootDir = path.resolve(__dirname, '../../..');
const serverDir = path.resolve(__dirname, '../..');
const clientDir = path.join(rootDir, 'client');
const steps = [];
try {
// 1. git pull
const pullOutput = execSync('git pull origin main', { cwd: rootDir, timeout: 60000, encoding: 'utf8' });
steps.push({ step: 'git pull', success: true, output: pullOutput.trim() });
// 2. npm install server
execSync('npm install --production', { cwd: serverDir, timeout: 120000, encoding: 'utf8' });
steps.push({ step: 'npm install (server)', success: true });
// 3. npm install + build client (production only)
if (process.env.NODE_ENV === 'production') {
execSync('npm install', { cwd: clientDir, timeout: 120000, encoding: 'utf8' });
execSync('npm run build', { cwd: clientDir, timeout: 120000, encoding: 'utf8' });
steps.push({ step: 'npm install + build (client)', success: true });
}
// Read new version
delete require.cache[require.resolve('../../package.json')];
const { version: newVersion } = require('../../package.json');
steps.push({ step: 'version', version: newVersion });
// 4. Send response before restart
res.json({ success: true, steps, restarting: true });
// 5. Graceful restart — exit and let process manager (Docker/systemd/pm2) restart
setTimeout(() => {
console.log('[Update] Restarting after update...');
process.exit(0);
}, 1000);
} catch (err) {
steps.push({ step: 'error', success: false, output: err.message });
res.status(500).json({ success: false, steps });
}
});
// ── Addons ───────────────────────────────────────────────── // ── Addons ─────────────────────────────────────────────────
router.get('/addons', (req, res) => { router.get('/addons', (req, res) => {
+15 -23
View File
@@ -13,7 +13,7 @@ function getAssignmentWithPlace(assignmentId) {
const a = db.prepare(` const a = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description, SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency, p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
p.reservation_status, p.reservation_notes, p.reservation_datetime, p.place_time, p.duration_minutes, p.notes as place_notes, p.place_time, p.end_time, p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da FROM day_assignments da
@@ -46,10 +46,8 @@ function getAssignmentWithPlace(assignmentId) {
category_id: a.category_id, category_id: a.category_id,
price: a.price, price: a.price,
currency: a.place_currency, currency: a.place_currency,
reservation_status: a.reservation_status,
reservation_notes: a.reservation_notes,
reservation_datetime: a.reservation_datetime,
place_time: a.place_time, place_time: a.place_time,
end_time: a.end_time,
duration_minutes: a.duration_minutes, duration_minutes: a.duration_minutes,
notes: a.place_notes, notes: a.place_notes,
image_url: a.image_url, image_url: a.image_url,
@@ -73,15 +71,15 @@ router.get('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) =>
const { tripId, dayId } = req.params; const { tripId, dayId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId); const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
if (!day) return res.status(404).json({ error: 'Tag nicht gefunden' }); if (!day) return res.status(404).json({ error: 'Day not found' });
const assignments = db.prepare(` const assignments = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description, SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency, p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
p.reservation_status, p.reservation_notes, p.reservation_datetime, p.place_time, p.duration_minutes, p.notes as place_notes, p.place_time, p.end_time, p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da FROM day_assignments da
@@ -124,9 +122,6 @@ router.get('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) =>
category_id: a.category_id, category_id: a.category_id,
price: a.price, price: a.price,
currency: a.place_currency, currency: a.place_currency,
reservation_status: a.reservation_status,
reservation_notes: a.reservation_notes,
reservation_datetime: a.reservation_datetime,
place_time: a.place_time, place_time: a.place_time,
duration_minutes: a.duration_minutes, duration_minutes: a.duration_minutes,
notes: a.place_notes, notes: a.place_notes,
@@ -155,16 +150,13 @@ router.post('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) =
const { place_id, notes } = req.body; const { place_id, notes } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId); const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
if (!day) return res.status(404).json({ error: 'Tag nicht gefunden' }); if (!day) return res.status(404).json({ error: 'Day not found' });
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId); const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId);
if (!place) return res.status(404).json({ error: 'Ort nicht gefunden' }); if (!place) return res.status(404).json({ error: 'Place not found' });
const existing = db.prepare('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?').get(dayId, place_id);
if (existing) return res.status(409).json({ error: 'Ort ist bereits diesem Tag zugewiesen' });
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId); const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId);
const orderIndex = (maxOrder.max !== null ? maxOrder.max : -1) + 1; const orderIndex = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
@@ -183,13 +175,13 @@ router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, (req,
const { tripId, dayId, id } = req.params; const { tripId, dayId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const assignment = db.prepare( const assignment = db.prepare(
'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND d.trip_id = ?' 'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND d.trip_id = ?'
).get(id, dayId, tripId); ).get(id, dayId, tripId);
if (!assignment) return res.status(404).json({ error: 'Zuweisung nicht gefunden' }); if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
db.prepare('DELETE FROM day_assignments WHERE id = ?').run(id); db.prepare('DELETE FROM day_assignments WHERE id = ?').run(id);
res.json({ success: true }); res.json({ success: true });
@@ -202,10 +194,10 @@ router.put('/trips/:tripId/days/:dayId/assignments/reorder', authenticate, (req,
const { orderedIds } = req.body; const { orderedIds } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId); const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
if (!day) return res.status(404).json({ error: 'Tag nicht gefunden' }); if (!day) return res.status(404).json({ error: 'Day not found' });
const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?'); const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?');
db.exec('BEGIN'); db.exec('BEGIN');
@@ -228,7 +220,7 @@ router.put('/trips/:tripId/assignments/:id/move', authenticate, (req, res) => {
const { new_day_id, order_index } = req.body; const { new_day_id, order_index } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const assignment = db.prepare(` const assignment = db.prepare(`
SELECT da.* FROM day_assignments da SELECT da.* FROM day_assignments da
@@ -236,10 +228,10 @@ router.put('/trips/:tripId/assignments/:id/move', authenticate, (req, res) => {
WHERE da.id = ? AND d.trip_id = ? WHERE da.id = ? AND d.trip_id = ?
`).get(id, tripId); `).get(id, tripId);
if (!assignment) return res.status(404).json({ error: 'Zuweisung nicht gefunden' }); if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
const newDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(new_day_id, tripId); const newDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(new_day_id, tripId);
if (!newDay) return res.status(404).json({ error: 'Zieltag nicht gefunden' }); if (!newDay) return res.status(404).json({ error: 'Target day not found' });
const oldDayId = assignment.day_id; const oldDayId = assignment.day_id;
db.prepare('UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?').run(new_day_id, order_index || 0, id); db.prepare('UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?').run(new_day_id, order_index || 0, id);
+9 -2
View File
@@ -61,9 +61,12 @@ function getCountryFromAddress(address) {
'malaysia':'MY','colombia':'CO','kolumbien':'CO','peru':'PE','chile':'CL','iran':'IR', 'malaysia':'MY','colombia':'CO','kolumbien':'CO','peru':'PE','chile':'CL','iran':'IR',
'iraq':'IQ','irak':'IQ','pakistan':'PK','kenya':'KE','kenia':'KE','nigeria':'NG', 'iraq':'IQ','irak':'IQ','pakistan':'PK','kenya':'KE','kenia':'KE','nigeria':'NG',
'saudi arabia':'SA','saudi-arabien':'SA','albania':'AL','albanien':'AL', 'saudi arabia':'SA','saudi-arabien':'SA','albania':'AL','albanien':'AL',
'日本':'JP','中国':'CN','한국':'KR','대한민국':'KR','ไทย':'TH',
}; };
const normalized = last.toLowerCase(); const normalized = last.toLowerCase();
if (NAME_TO_CODE[normalized]) return NAME_TO_CODE[normalized]; if (NAME_TO_CODE[normalized]) return NAME_TO_CODE[normalized];
// Try original case (for non-Latin scripts like 日本)
if (NAME_TO_CODE[last]) return NAME_TO_CODE[last];
// Try 2-letter code directly // Try 2-letter code directly
if (last.length === 2 && last === last.toUpperCase()) return last; if (last.length === 2 && last === last.toUpperCase()) return last;
return null; return null;
@@ -130,12 +133,16 @@ router.get('/stats', (req, res) => {
}); });
// Unique cities (extract city from address — second to last comma segment) // Unique cities (extract city from address — second to last comma segment)
// Strip postal codes and normalize to avoid duplicates like "Tokyo" vs "Tokyo 131-0045"
const citySet = new Set(); const citySet = new Set();
for (const place of places) { for (const place of places) {
if (place.address) { if (place.address) {
const parts = place.address.split(',').map(s => s.trim()).filter(Boolean); const parts = place.address.split(',').map(s => s.trim()).filter(Boolean);
if (parts.length >= 2) citySet.add(parts[parts.length - 2]); let raw = parts.length >= 2 ? parts[parts.length - 2] : parts[0];
else if (parts.length === 1) citySet.add(parts[0]); if (raw) {
const city = raw.replace(/[\d\-−〒]+/g, '').trim().toLowerCase();
if (city) citySet.add(city);
}
} }
} }
const totalCities = citySet.size; const totalCities = citySet.size;
+5 -5
View File
@@ -145,7 +145,7 @@ router.post('/register', authLimiter, (req, res) => {
res.status(201).json({ token, user: { ...user, avatar_url: null } }); res.status(201).json({ token, user: { ...user, avatar_url: null } });
} catch (err) { } catch (err) {
res.status(500).json({ error: 'Fehler beim Erstellen des Benutzers' }); res.status(500).json({ error: 'Error creating user' });
} }
}); });
@@ -154,17 +154,17 @@ router.post('/login', authLimiter, (req, res) => {
const { email, password } = req.body; const { email, password } = req.body;
if (!email || !password) { if (!email || !password) {
return res.status(400).json({ error: 'E-Mail und Passwort sind erforderlich' }); return res.status(400).json({ error: 'Email and password are required' });
} }
const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email); const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email);
if (!user) { if (!user) {
return res.status(401).json({ error: 'Ungültige E-Mail oder Passwort' }); return res.status(401).json({ error: 'Invalid email or password' });
} }
const validPassword = bcrypt.compareSync(password, user.password_hash); const validPassword = bcrypt.compareSync(password, user.password_hash);
if (!validPassword) { if (!validPassword) {
return res.status(401).json({ error: 'Ungültige E-Mail oder Passwort' }); return res.status(401).json({ error: 'Invalid email or password' });
} }
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id); db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
@@ -181,7 +181,7 @@ router.get('/me', authenticate, (req, res) => {
).get(req.user.id); ).get(req.user.id);
if (!user) { if (!user) {
return res.status(404).json({ error: 'Benutzer nicht gefunden' }); return res.status(404).json({ error: 'User not found' });
} }
res.json({ user: { ...user, avatar_url: avatarUrl(user) } }); res.json({ user: { ...user, avatar_url: avatarUrl(user) } });
+35 -23
View File
@@ -48,7 +48,7 @@ router.get('/list', (req, res) => {
res.json({ backups: files }); res.json({ backups: files });
} catch (err) { } catch (err) {
res.status(500).json({ error: 'Fehler beim Laden der Backups' }); res.status(500).json({ error: 'Error loading backups' });
} }
}); });
@@ -100,7 +100,7 @@ router.post('/create', async (req, res) => {
} catch (err) { } catch (err) {
console.error('Backup error:', err); console.error('Backup error:', err);
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath); if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
res.status(500).json({ error: 'Fehler beim Erstellen des Backups' }); res.status(500).json({ error: 'Error creating backup' });
} }
}); });
@@ -115,7 +115,7 @@ router.get('/download/:filename', (req, res) => {
const filePath = path.join(backupsDir, filename); const filePath = path.join(backupsDir, filename);
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Backup nicht gefunden' }); return res.status(404).json({ error: 'Backup not found' });
} }
res.download(filePath, filename); res.download(filePath, filename);
@@ -132,36 +132,48 @@ async function restoreFromZip(zipPath, res) {
const extractedDb = path.join(extractDir, 'travel.db'); const extractedDb = path.join(extractDir, 'travel.db');
if (!fs.existsSync(extractedDb)) { if (!fs.existsSync(extractedDb)) {
fs.rmSync(extractDir, { recursive: true, force: true }); fs.rmSync(extractDir, { recursive: true, force: true });
return res.status(400).json({ error: 'Ungültiges Backup: travel.db nicht gefunden' }); return res.status(400).json({ error: 'Invalid backup: travel.db not found' });
} }
// Step 1: close DB connection BEFORE touching the file (required on Windows) // Step 1: close DB connection BEFORE touching the file (required on Windows)
closeDb(); closeDb();
// Step 2: remove WAL/SHM and overwrite DB file try {
const dbDest = path.join(dataDir, 'travel.db'); // Step 2: remove WAL/SHM and overwrite DB file
for (const ext of ['', '-wal', '-shm']) { const dbDest = path.join(dataDir, 'travel.db');
try { fs.unlinkSync(dbDest + ext); } catch (e) {} for (const ext of ['', '-wal', '-shm']) {
} try { fs.unlinkSync(dbDest + ext); } catch (e) {}
fs.copyFileSync(extractedDb, dbDest); }
fs.copyFileSync(extractedDb, dbDest);
// Step 3: restore uploads // Step 3: restore uploads — overwrite in-place instead of rmSync
const extractedUploads = path.join(extractDir, 'uploads'); // (rmSync fails with EBUSY because express.static holds the directory)
if (fs.existsSync(extractedUploads)) { const extractedUploads = path.join(extractDir, 'uploads');
if (fs.existsSync(uploadsDir)) fs.rmSync(uploadsDir, { recursive: true, force: true }); if (fs.existsSync(extractedUploads)) {
fs.cpSync(extractedUploads, uploadsDir, { recursive: true }); // Clear contents of each subdirectory without removing the root uploads dir
for (const sub of fs.readdirSync(uploadsDir)) {
const subPath = path.join(uploadsDir, sub);
if (fs.statSync(subPath).isDirectory()) {
for (const file of fs.readdirSync(subPath)) {
try { fs.unlinkSync(path.join(subPath, file)); } catch (e) {}
}
}
}
// Copy restored files over
fs.cpSync(extractedUploads, uploadsDir, { recursive: true, force: true });
}
} finally {
// Step 4: ALWAYS reopen DB — even if file copy failed, so the server stays functional
reinitialize();
} }
fs.rmSync(extractDir, { recursive: true, force: true }); fs.rmSync(extractDir, { recursive: true, force: true });
// Step 4: reopen DB with restored data
reinitialize();
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
console.error('Restore error:', err); console.error('Restore error:', err);
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true }); if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true });
if (!res.headersSent) res.status(500).json({ error: err.message || 'Fehler beim Wiederherstellen' }); if (!res.headersSent) res.status(500).json({ error: err.message || 'Error restoring backup' });
} }
} }
@@ -173,7 +185,7 @@ router.post('/restore/:filename', async (req, res) => {
} }
const zipPath = path.join(backupsDir, filename); const zipPath = path.join(backupsDir, filename);
if (!fs.existsSync(zipPath)) { if (!fs.existsSync(zipPath)) {
return res.status(404).json({ error: 'Backup nicht gefunden' }); return res.status(404).json({ error: 'Backup not found' });
} }
await restoreFromZip(zipPath, res); await restoreFromZip(zipPath, res);
}); });
@@ -183,13 +195,13 @@ const uploadTmp = multer({
dest: path.join(dataDir, 'tmp/'), dest: path.join(dataDir, 'tmp/'),
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
if (file.originalname.endsWith('.zip')) cb(null, true); if (file.originalname.endsWith('.zip')) cb(null, true);
else cb(new Error('Nur ZIP-Dateien erlaubt')); else cb(new Error('Only ZIP files allowed'));
}, },
limits: { fileSize: 500 * 1024 * 1024 }, limits: { fileSize: 500 * 1024 * 1024 },
}); });
router.post('/upload-restore', uploadTmp.single('backup'), async (req, res) => { router.post('/upload-restore', uploadTmp.single('backup'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Keine Datei hochgeladen' }); if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const zipPath = req.file.path; const zipPath = req.file.path;
await restoreFromZip(zipPath, res); await restoreFromZip(zipPath, res);
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath); if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
@@ -223,7 +235,7 @@ router.delete('/:filename', (req, res) => {
const filePath = path.join(backupsDir, filename); const filePath = path.join(backupsDir, filename);
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Backup nicht gefunden' }); return res.status(404).json({ error: 'Backup not found' });
} }
fs.unlinkSync(filePath); fs.unlinkSync(filePath);
+8 -8
View File
@@ -14,7 +14,7 @@ router.get('/', authenticate, (req, res) => {
const { tripId } = req.params; const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const items = db.prepare( const items = db.prepare(
'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC' 'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC'
@@ -29,9 +29,9 @@ router.post('/', authenticate, (req, res) => {
const { category, name, total_price, persons, days, note } = req.body; const { category, name, total_price, persons, days, note } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!name) return res.status(400).json({ error: 'Name ist erforderlich' }); if (!name) return res.status(400).json({ error: 'Name is required' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?').get(tripId); const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?').get(tripId);
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1; const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
@@ -40,7 +40,7 @@ router.post('/', authenticate, (req, res) => {
'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' 'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run( ).run(
tripId, tripId,
category || 'Sonstiges', category || 'Other',
name, name,
total_price || 0, total_price || 0,
persons != null ? persons : null, persons != null ? persons : null,
@@ -60,10 +60,10 @@ router.put('/:id', authenticate, (req, res) => {
const { category, name, total_price, persons, days, note, sort_order } = req.body; const { category, name, total_price, persons, days, note, sort_order } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId); const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Budget-Eintrag nicht gefunden' }); if (!item) return res.status(404).json({ error: 'Budget item not found' });
db.prepare(` db.prepare(`
UPDATE budget_items SET UPDATE budget_items SET
@@ -96,10 +96,10 @@ router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params; const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId); const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Budget-Eintrag nicht gefunden' }); if (!item) return res.status(404).json({ error: 'Budget item not found' });
db.prepare('DELETE FROM budget_items WHERE id = ?').run(id); db.prepare('DELETE FROM budget_items WHERE id = ?').run(id);
res.json({ success: true }); res.json({ success: true });
+3 -3
View File
@@ -16,7 +16,7 @@ router.get('/', authenticate, (req, res) => {
router.post('/', authenticate, adminOnly, (req, res) => { router.post('/', authenticate, adminOnly, (req, res) => {
const { name, color, icon } = req.body; const { name, color, icon } = req.body;
if (!name) return res.status(400).json({ error: 'Kategoriename ist erforderlich' }); if (!name) return res.status(400).json({ error: 'Category name is required' });
const result = db.prepare( const result = db.prepare(
'INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)' 'INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)'
@@ -31,7 +31,7 @@ router.put('/:id', authenticate, adminOnly, (req, res) => {
const { name, color, icon } = req.body; const { name, color, icon } = req.body;
const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(req.params.id); const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(req.params.id);
if (!category) return res.status(404).json({ error: 'Kategorie nicht gefunden' }); if (!category) return res.status(404).json({ error: 'Category not found' });
db.prepare(` db.prepare(`
UPDATE categories SET UPDATE categories SET
@@ -49,7 +49,7 @@ router.put('/:id', authenticate, adminOnly, (req, res) => {
router.delete('/:id', authenticate, adminOnly, (req, res) => { router.delete('/:id', authenticate, adminOnly, (req, res) => {
const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(req.params.id); const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(req.params.id);
if (!category) return res.status(404).json({ error: 'Kategorie nicht gefunden' }); if (!category) return res.status(404).json({ error: 'Category not found' });
db.prepare('DELETE FROM categories WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM categories WHERE id = ?').run(req.params.id);
res.json({ success: true }); res.json({ success: true });
+8 -8
View File
@@ -12,7 +12,7 @@ function verifyAccess(tripId, userId) {
// GET /api/trips/:tripId/days/:dayId/notes // GET /api/trips/:tripId/days/:dayId/notes
router.get('/', authenticate, (req, res) => { router.get('/', authenticate, (req, res) => {
const { tripId, dayId } = req.params; const { tripId, dayId } = req.params;
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const notes = db.prepare( const notes = db.prepare(
'SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC' 'SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC'
@@ -24,13 +24,13 @@ router.get('/', authenticate, (req, res) => {
// POST /api/trips/:tripId/days/:dayId/notes // POST /api/trips/:tripId/days/:dayId/notes
router.post('/', authenticate, (req, res) => { router.post('/', authenticate, (req, res) => {
const { tripId, dayId } = req.params; const { tripId, dayId } = req.params;
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId); const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
if (!day) return res.status(404).json({ error: 'Tag nicht gefunden' }); if (!day) return res.status(404).json({ error: 'Day not found' });
const { text, time, icon, sort_order } = req.body; const { text, time, icon, sort_order } = req.body;
if (!text?.trim()) return res.status(400).json({ error: 'Text erforderlich' }); if (!text?.trim()) return res.status(400).json({ error: 'Text required' });
const result = db.prepare( const result = db.prepare(
'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)' 'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
@@ -44,10 +44,10 @@ router.post('/', authenticate, (req, res) => {
// PUT /api/trips/:tripId/days/:dayId/notes/:id // PUT /api/trips/:tripId/days/:dayId/notes/:id
router.put('/:id', authenticate, (req, res) => { router.put('/:id', authenticate, (req, res) => {
const { tripId, dayId, id } = req.params; const { tripId, dayId, id } = req.params;
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const note = db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId); const note = db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId);
if (!note) return res.status(404).json({ error: 'Notiz nicht gefunden' }); if (!note) return res.status(404).json({ error: 'Note not found' });
const { text, time, icon, sort_order } = req.body; const { text, time, icon, sort_order } = req.body;
db.prepare( db.prepare(
@@ -68,10 +68,10 @@ router.put('/:id', authenticate, (req, res) => {
// DELETE /api/trips/:tripId/days/:dayId/notes/:id // DELETE /api/trips/:tripId/days/:dayId/notes/:id
router.delete('/:id', authenticate, (req, res) => { router.delete('/:id', authenticate, (req, res) => {
const { tripId, dayId, id } = req.params; const { tripId, dayId, id } = req.params;
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const note = db.prepare('SELECT id FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId); const note = db.prepare('SELECT id FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId);
if (!note) return res.status(404).json({ error: 'Notiz nicht gefunden' }); if (!note) return res.status(404).json({ error: 'Note not found' });
db.prepare('DELETE FROM day_notes WHERE id = ?').run(id); db.prepare('DELETE FROM day_notes WHERE id = ?').run(id);
res.json({ success: true }); res.json({ success: true });
+155 -14
View File
@@ -13,7 +13,7 @@ function getAssignmentsForDay(dayId) {
const assignments = db.prepare(` const assignments = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description, SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency, p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
p.reservation_status, p.reservation_notes, p.reservation_datetime, p.place_time, p.duration_minutes, p.notes as place_notes, p.place_time, p.end_time, p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da FROM day_assignments da
@@ -46,10 +46,8 @@ function getAssignmentsForDay(dayId) {
category_id: a.category_id, category_id: a.category_id,
price: a.price, price: a.price,
currency: a.place_currency, currency: a.place_currency,
reservation_status: a.reservation_status,
reservation_notes: a.reservation_notes,
reservation_datetime: a.reservation_datetime,
place_time: a.place_time, place_time: a.place_time,
end_time: a.end_time,
duration_minutes: a.duration_minutes, duration_minutes: a.duration_minutes,
notes: a.place_notes, notes: a.place_notes,
image_url: a.image_url, image_url: a.image_url,
@@ -75,7 +73,7 @@ router.get('/', authenticate, (req, res) => {
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) { if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' }); return res.status(404).json({ error: 'Trip not found' });
} }
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId); const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId);
@@ -91,7 +89,7 @@ router.get('/', authenticate, (req, res) => {
const allAssignments = db.prepare(` const allAssignments = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description, SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency, p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
p.reservation_status, p.reservation_notes, p.reservation_datetime, p.place_time, p.duration_minutes, p.notes as place_notes, p.place_time, p.end_time, p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da FROM day_assignments da
@@ -137,10 +135,8 @@ router.get('/', authenticate, (req, res) => {
category_id: a.category_id, category_id: a.category_id,
price: a.price, price: a.price,
currency: a.place_currency, currency: a.place_currency,
reservation_status: a.reservation_status,
reservation_notes: a.reservation_notes,
reservation_datetime: a.reservation_datetime,
place_time: a.place_time, place_time: a.place_time,
end_time: a.end_time,
duration_minutes: a.duration_minutes, duration_minutes: a.duration_minutes,
notes: a.place_notes, notes: a.place_notes,
image_url: a.image_url, image_url: a.image_url,
@@ -184,7 +180,7 @@ router.post('/', authenticate, (req, res) => {
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) { if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' }); return res.status(404).json({ error: 'Trip not found' });
} }
const { date, notes } = req.body; const { date, notes } = req.body;
@@ -209,12 +205,12 @@ router.put('/:id', authenticate, (req, res) => {
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) { if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' }); return res.status(404).json({ error: 'Trip not found' });
} }
const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId); const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!day) { if (!day) {
return res.status(404).json({ error: 'Tag nicht gefunden' }); return res.status(404).json({ error: 'Day not found' });
} }
const { notes, title } = req.body; const { notes, title } = req.body;
@@ -232,12 +228,12 @@ router.delete('/:id', authenticate, (req, res) => {
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) { if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' }); return res.status(404).json({ error: 'Trip not found' });
} }
const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId); const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!day) { if (!day) {
return res.status(404).json({ error: 'Tag nicht gefunden' }); return res.status(404).json({ error: 'Day not found' });
} }
db.prepare('DELETE FROM days WHERE id = ?').run(id); db.prepare('DELETE FROM days WHERE id = ?').run(id);
@@ -245,4 +241,149 @@ router.delete('/:id', authenticate, (req, res) => {
broadcast(tripId, 'day:deleted', { dayId: Number(id) }, req.headers['x-socket-id']); broadcast(tripId, 'day:deleted', { dayId: Number(id) }, req.headers['x-socket-id']);
}); });
// === Accommodation routes ===
const accommodationsRouter = express.Router({ mergeParams: true });
function getAccommodationWithPlace(id) {
return db.prepare(`
SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng
FROM day_accommodations a
JOIN places p ON a.place_id = p.id
WHERE a.id = ?
`).get(id);
}
// GET /api/trips/:tripId/accommodations
accommodationsRouter.get('/', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Trip not found' });
}
const accommodations = db.prepare(`
SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng
FROM day_accommodations a
JOIN places p ON a.place_id = p.id
WHERE a.trip_id = ?
ORDER BY a.created_at ASC
`).all(tripId);
res.json({ accommodations });
});
// POST /api/trips/:tripId/accommodations
accommodationsRouter.post('/', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Trip not found' });
}
const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = req.body;
if (!place_id || !start_day_id || !end_day_id) {
return res.status(400).json({ error: 'place_id, start_day_id, and end_day_id are required' });
}
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId);
if (!place) {
return res.status(404).json({ error: 'Place not found' });
}
const startDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(start_day_id, tripId);
if (!startDay) {
return res.status(404).json({ error: 'Start day not found' });
}
const endDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(end_day_id, tripId);
if (!endDay) {
return res.status(404).json({ error: 'End day not found' });
}
const result = db.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_out || null, confirmation || null, notes || null);
const accommodation = getAccommodationWithPlace(result.lastInsertRowid);
res.status(201).json({ accommodation });
broadcast(tripId, 'accommodation:created', { accommodation }, req.headers['x-socket-id']);
});
// PUT /api/trips/:tripId/accommodations/:id
accommodationsRouter.put('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Trip not found' });
}
const existing = db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existing) {
return res.status(404).json({ error: 'Accommodation not found' });
}
const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = req.body;
const newPlaceId = place_id !== undefined ? place_id : existing.place_id;
const newStartDayId = start_day_id !== undefined ? start_day_id : existing.start_day_id;
const newEndDayId = end_day_id !== undefined ? end_day_id : existing.end_day_id;
const newCheckIn = check_in !== undefined ? check_in : existing.check_in;
const newCheckOut = check_out !== undefined ? check_out : existing.check_out;
const newConfirmation = confirmation !== undefined ? confirmation : existing.confirmation;
const newNotes = notes !== undefined ? notes : existing.notes;
if (place_id !== undefined) {
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId);
if (!place) {
return res.status(404).json({ error: 'Place not found' });
}
}
if (start_day_id !== undefined) {
const startDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(start_day_id, tripId);
if (!startDay) {
return res.status(404).json({ error: 'Start day not found' });
}
}
if (end_day_id !== undefined) {
const endDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(end_day_id, tripId);
if (!endDay) {
return res.status(404).json({ error: 'End day not found' });
}
}
db.prepare(
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?'
).run(newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckOut, newConfirmation, newNotes, id);
const accommodation = getAccommodationWithPlace(id);
res.json({ accommodation });
broadcast(tripId, 'accommodation:updated', { accommodation }, req.headers['x-socket-id']);
});
// DELETE /api/trips/:tripId/accommodations/:id
accommodationsRouter.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Trip not found' });
}
const existing = db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existing) {
return res.status(404).json({ error: 'Accommodation not found' });
}
db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'accommodation:deleted', { accommodationId: Number(id) }, req.headers['x-socket-id']);
});
module.exports = router; module.exports = router;
module.exports.accommodationsRouter = accommodationsRouter;
+13 -8
View File
@@ -35,10 +35,15 @@ const upload = multer({
'text/plain', 'text/plain',
'text/csv', 'text/csv',
]; ];
const ext = path.extname(file.originalname).toLowerCase();
const blockedExts = ['.svg', '.html', '.htm', '.xml'];
if (blockedExts.includes(ext) || file.mimetype.includes('svg')) {
return cb(new Error('File type not allowed'));
}
if (allowed.includes(file.mimetype) || file.mimetype.startsWith('image/')) { if (allowed.includes(file.mimetype) || file.mimetype.startsWith('image/')) {
cb(null, true); cb(null, true);
} else { } else {
cb(new Error('Dateityp nicht erlaubt')); cb(new Error('File type not allowed'));
} }
}, },
}); });
@@ -59,7 +64,7 @@ router.get('/', authenticate, (req, res) => {
const { tripId } = req.params; const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const files = db.prepare(` const files = db.prepare(`
SELECT f.*, r.title as reservation_title SELECT f.*, r.title as reservation_title
@@ -79,11 +84,11 @@ router.post('/', authenticate, demoUploadBlock, upload.single('file'), (req, res
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) { if (!trip) {
if (req.file) fs.unlinkSync(req.file.path); if (req.file) fs.unlinkSync(req.file.path);
return res.status(404).json({ error: 'Reise nicht gefunden' }); return res.status(404).json({ error: 'Trip not found' });
} }
if (!req.file) { if (!req.file) {
return res.status(400).json({ error: 'Keine Datei hochgeladen' }); return res.status(400).json({ error: 'No file uploaded' });
} }
const result = db.prepare(` const result = db.prepare(`
@@ -116,10 +121,10 @@ router.put('/:id', authenticate, (req, res) => {
const { description, place_id, reservation_id } = req.body; const { description, place_id, reservation_id } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId); const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' }); if (!file) return res.status(404).json({ error: 'File not found' });
db.prepare(` db.prepare(`
UPDATE trip_files SET UPDATE trip_files SET
@@ -149,10 +154,10 @@ router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params; const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId); const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' }); if (!file) return res.status(404).json({ error: 'File not found' });
const filePath = path.join(filesDir, file.filename); const filePath = path.join(filesDir, file.filename);
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
+12 -12
View File
@@ -49,7 +49,7 @@ async function searchNominatim(query, lang) {
router.post('/search', authenticate, async (req, res) => { router.post('/search', authenticate, async (req, res) => {
const { query } = req.body; const { query } = req.body;
if (!query) return res.status(400).json({ error: 'Suchanfrage ist erforderlich' }); if (!query) return res.status(400).json({ error: 'Search query is required' });
const apiKey = getMapsKey(req.user.id); const apiKey = getMapsKey(req.user.id);
@@ -60,7 +60,7 @@ router.post('/search', authenticate, async (req, res) => {
return res.json({ places, source: 'openstreetmap' }); return res.json({ places, source: 'openstreetmap' });
} catch (err) { } catch (err) {
console.error('Nominatim search error:', err); console.error('Nominatim search error:', err);
return res.status(500).json({ error: 'Fehler bei der OpenStreetMap Suche' }); return res.status(500).json({ error: 'OpenStreetMap search error' });
} }
} }
@@ -78,7 +78,7 @@ router.post('/search', authenticate, async (req, res) => {
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
return res.status(response.status).json({ error: data.error?.message || 'Google Places API Fehler' }); return res.status(response.status).json({ error: data.error?.message || 'Google Places API error' });
} }
const places = (data.places || []).map(p => ({ const places = (data.places || []).map(p => ({
@@ -96,7 +96,7 @@ router.post('/search', authenticate, async (req, res) => {
res.json({ places, source: 'google' }); res.json({ places, source: 'google' });
} catch (err) { } catch (err) {
console.error('Maps search error:', err); console.error('Maps search error:', err);
res.status(500).json({ error: 'Fehler bei der Google Places Suche' }); res.status(500).json({ error: 'Google Places search error' });
} }
}); });
@@ -106,7 +106,7 @@ router.get('/details/:placeId', authenticate, async (req, res) => {
const apiKey = getMapsKey(req.user.id); const apiKey = getMapsKey(req.user.id);
if (!apiKey) { if (!apiKey) {
return res.status(400).json({ error: 'Google Maps API-Schlüssel nicht konfiguriert' }); return res.status(400).json({ error: 'Google Maps API key not configured' });
} }
try { try {
@@ -122,7 +122,7 @@ router.get('/details/:placeId', authenticate, async (req, res) => {
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
return res.status(response.status).json({ error: data.error?.message || 'Google Places API Fehler' }); return res.status(response.status).json({ error: data.error?.message || 'Google Places API error' });
} }
const place = { const place = {
@@ -151,7 +151,7 @@ router.get('/details/:placeId', authenticate, async (req, res) => {
res.json({ place }); res.json({ place });
} catch (err) { } catch (err) {
console.error('Maps details error:', err); console.error('Maps details error:', err);
res.status(500).json({ error: 'Fehler beim Abrufen der Ortsdetails' }); res.status(500).json({ error: 'Error fetching place details' });
} }
}); });
@@ -168,7 +168,7 @@ router.get('/place-photo/:placeId', authenticate, async (req, res) => {
const apiKey = getMapsKey(req.user.id); const apiKey = getMapsKey(req.user.id);
if (!apiKey) { if (!apiKey) {
return res.status(400).json({ error: 'Google Maps API-Schlüssel nicht konfiguriert' }); return res.status(400).json({ error: 'Google Maps API key not configured' });
} }
try { try {
@@ -183,11 +183,11 @@ router.get('/place-photo/:placeId', authenticate, async (req, res) => {
if (!detailsRes.ok) { if (!detailsRes.ok) {
console.error('Google Places photo details error:', details.error?.message || detailsRes.status); console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
return res.status(404).json({ error: 'Foto konnte nicht abgerufen werden' }); return res.status(404).json({ error: 'Photo could not be retrieved' });
} }
if (!details.photos?.length) { if (!details.photos?.length) {
return res.status(404).json({ error: 'Kein Foto verfügbar' }); return res.status(404).json({ error: 'No photo available' });
} }
const photo = details.photos[0]; const photo = details.photos[0];
@@ -202,7 +202,7 @@ router.get('/place-photo/:placeId', authenticate, async (req, res) => {
const photoUrl = mediaData.photoUri; const photoUrl = mediaData.photoUri;
if (!photoUrl) { if (!photoUrl) {
return res.status(404).json({ error: 'Foto-URL nicht verfügbar' }); return res.status(404).json({ error: 'Photo URL not available' });
} }
photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() }); photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() });
@@ -220,7 +220,7 @@ router.get('/place-photo/:placeId', authenticate, async (req, res) => {
res.json({ photoUrl, attribution }); res.json({ photoUrl, attribution });
} catch (err) { } catch (err) {
console.error('Place photo error:', err); console.error('Place photo error:', err);
res.status(500).json({ error: 'Fehler beim Abrufen des Fotos' }); res.status(500).json({ error: 'Error fetching photo' });
} }
}); });
+1 -1
View File
@@ -196,7 +196,7 @@ router.get('/callback', async (req, res) => {
// Generate JWT and redirect to frontend // Generate JWT and redirect to frontend
const token = generateToken(user); const token = generateToken(user);
// In dev mode, frontend runs on a different port // In dev mode, frontend runs on a different port
res.redirect(frontendUrl(`/login?token=${token}`)); res.redirect(frontendUrl(`/login#token=${token}`));
} catch (err) { } catch (err) {
console.error('[OIDC] Callback error:', err); console.error('[OIDC] Callback error:', err);
res.redirect(frontendUrl('/login?oidc_error=server_error')); res.redirect(frontendUrl('/login?oidc_error=server_error'));
+8 -8
View File
@@ -14,7 +14,7 @@ router.get('/', authenticate, (req, res) => {
const { tripId } = req.params; const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const items = db.prepare( const items = db.prepare(
'SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC' 'SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC'
@@ -29,9 +29,9 @@ router.post('/', authenticate, (req, res) => {
const { name, category, checked } = req.body; const { name, category, checked } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!name) return res.status(400).json({ error: 'Artikelname ist erforderlich' }); if (!name) return res.status(400).json({ error: 'Item name is required' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId); const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId);
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1; const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
@@ -51,10 +51,10 @@ router.put('/:id', authenticate, (req, res) => {
const { name, checked, category } = req.body; const { name, checked, category } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const item = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId); const item = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Artikel nicht gefunden' }); if (!item) return res.status(404).json({ error: 'Item not found' });
db.prepare(` db.prepare(`
UPDATE packing_items SET UPDATE packing_items SET
@@ -80,10 +80,10 @@ router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params; const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId); const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Artikel nicht gefunden' }); if (!item) return res.status(404).json({ error: 'Item not found' });
db.prepare('DELETE FROM packing_items WHERE id = ?').run(id); db.prepare('DELETE FROM packing_items WHERE id = ?').run(id);
res.json({ success: true }); res.json({ success: true });
@@ -96,7 +96,7 @@ router.put('/reorder', authenticate, (req, res) => {
const { orderedIds } = req.body; const { orderedIds } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const update = db.prepare('UPDATE packing_items SET sort_order = ? WHERE id = ? AND trip_id = ?'); const update = db.prepare('UPDATE packing_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
const updateMany = db.transaction((ids) => { const updateMany = db.transaction((ids) => {
+11 -9
View File
@@ -25,10 +25,12 @@ const upload = multer({
storage, storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) { const ext = path.extname(file.originalname).toLowerCase();
const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowedExts.includes(ext)) {
cb(null, true); cb(null, true);
} else { } else {
cb(new Error('Nur Bilddateien sind erlaubt')); cb(new Error('Only jpg, png, gif, webp images allowed'));
} }
}, },
}); });
@@ -46,7 +48,7 @@ router.get('/', authenticate, (req, res) => {
const { day_id, place_id } = req.query; const { day_id, place_id } = req.query;
const trip = canAccessTrip(tripId, req.user.id); const trip = canAccessTrip(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
let query = 'SELECT * FROM photos WHERE trip_id = ?'; let query = 'SELECT * FROM photos WHERE trip_id = ?';
const params = [tripId]; const params = [tripId];
@@ -76,11 +78,11 @@ router.post('/', authenticate, demoUploadBlock, upload.array('photos', 20), (req
if (!trip) { if (!trip) {
// Delete uploaded files on auth failure // Delete uploaded files on auth failure
if (req.files) req.files.forEach(f => fs.unlinkSync(f.path)); if (req.files) req.files.forEach(f => fs.unlinkSync(f.path));
return res.status(404).json({ error: 'Reise nicht gefunden' }); return res.status(404).json({ error: 'Trip not found' });
} }
if (!req.files || req.files.length === 0) { if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'Keine Dateien hochgeladen' }); return res.status(400).json({ error: 'No files uploaded' });
} }
const insertPhoto = db.prepare(` const insertPhoto = db.prepare(`
@@ -120,10 +122,10 @@ router.put('/:id', authenticate, (req, res) => {
const { caption, day_id, place_id } = req.body; const { caption, day_id, place_id } = req.body;
const trip = canAccessTrip(tripId, req.user.id); const trip = canAccessTrip(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const photo = db.prepare('SELECT * FROM photos WHERE id = ? AND trip_id = ?').get(id, tripId); const photo = db.prepare('SELECT * FROM photos WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!photo) return res.status(404).json({ error: 'Foto nicht gefunden' }); if (!photo) return res.status(404).json({ error: 'Photo not found' });
db.prepare(` db.prepare(`
UPDATE photos SET UPDATE photos SET
@@ -147,10 +149,10 @@ router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params; const { tripId, id } = req.params;
const trip = canAccessTrip(tripId, req.user.id); const trip = canAccessTrip(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const photo = db.prepare('SELECT * FROM photos WHERE id = ? AND trip_id = ?').get(id, tripId); const photo = db.prepare('SELECT * FROM photos WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!photo) return res.status(404).json({ error: 'Foto nicht gefunden' }); if (!photo) return res.status(404).json({ error: 'Photo not found' });
// Delete file // Delete file
const filePath = path.join(photosDir, photo.filename); const filePath = path.join(photosDir, photo.filename);
+21 -26
View File
@@ -17,7 +17,7 @@ router.get('/', authenticate, (req, res) => {
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) { if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' }); return res.status(404).json({ error: 'Trip not found' });
} }
let query = ` let query = `
@@ -89,30 +89,29 @@ router.post('/', authenticate, (req, res) => {
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) { if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' }); return res.status(404).json({ error: 'Trip not found' });
} }
const { const {
name, description, lat, lng, address, category_id, price, currency, name, description, lat, lng, address, category_id, price, currency,
reservation_status, reservation_notes, reservation_datetime, place_time, place_time, end_time,
duration_minutes, notes, image_url, google_place_id, website, phone, duration_minutes, notes, image_url, google_place_id, website, phone,
transport_mode, tags = [] transport_mode, tags = []
} = req.body; } = req.body;
if (!name) { if (!name) {
return res.status(400).json({ error: 'Ortsname ist erforderlich' }); return res.status(400).json({ error: 'Place name is required' });
} }
const result = db.prepare(` const result = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
reservation_status, reservation_notes, reservation_datetime, place_time, place_time, end_time,
duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode) duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
tripId, name, description || null, lat || null, lng || null, address || null, tripId, name, description || null, lat || null, lng || null, address || null,
category_id || null, price || null, currency || null, category_id || null, price || null, currency || null,
reservation_status || 'none', reservation_notes || null, reservation_datetime || null, place_time || null, end_time || null, duration_minutes || 60, notes || null, image_url || null,
place_time || null, duration_minutes || 60, notes || null, image_url || null,
google_place_id || null, website || null, phone || null, transport_mode || 'walking' google_place_id || null, website || null, phone || null, transport_mode || 'walking'
); );
@@ -136,12 +135,12 @@ router.get('/:id', authenticate, (req, res) => {
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) { if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' }); return res.status(404).json({ error: 'Trip not found' });
} }
const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId); const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!placeCheck) { if (!placeCheck) {
return res.status(404).json({ error: 'Ort nicht gefunden' }); return res.status(404).json({ error: 'Place not found' });
} }
const place = getPlaceWithTags(id); const place = getPlaceWithTags(id);
@@ -154,17 +153,17 @@ router.get('/:id/image', authenticate, async (req, res) => {
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) { if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' }); return res.status(404).json({ error: 'Trip not found' });
} }
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId); const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!place) { if (!place) {
return res.status(404).json({ error: 'Ort nicht gefunden' }); return res.status(404).json({ error: 'Place not found' });
} }
const user = db.prepare('SELECT unsplash_api_key FROM users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT unsplash_api_key FROM users WHERE id = ?').get(req.user.id);
if (!user || !user.unsplash_api_key) { if (!user || !user.unsplash_api_key) {
return res.status(400).json({ error: 'Kein Unsplash API-Schlüssel konfiguriert' }); return res.status(400).json({ error: 'No Unsplash API key configured' });
} }
try { try {
@@ -175,7 +174,7 @@ router.get('/:id/image', authenticate, async (req, res) => {
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
return res.status(response.status).json({ error: data.errors?.[0] || 'Unsplash API Fehler' }); return res.status(response.status).json({ error: data.errors?.[0] || 'Unsplash API error' });
} }
const photos = (data.results || []).map(p => ({ const photos = (data.results || []).map(p => ({
@@ -190,7 +189,7 @@ router.get('/:id/image', authenticate, async (req, res) => {
res.json({ photos }); res.json({ photos });
} catch (err) { } catch (err) {
console.error('Unsplash error:', err); console.error('Unsplash error:', err);
res.status(500).json({ error: 'Fehler beim Suchen des Bildes' }); res.status(500).json({ error: 'Error searching for image' });
} }
}); });
@@ -200,17 +199,17 @@ router.put('/:id', authenticate, (req, res) => {
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) { if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' }); return res.status(404).json({ error: 'Trip not found' });
} }
const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId); const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existingPlace) { if (!existingPlace) {
return res.status(404).json({ error: 'Ort nicht gefunden' }); return res.status(404).json({ error: 'Place not found' });
} }
const { const {
name, description, lat, lng, address, category_id, price, currency, name, description, lat, lng, address, category_id, price, currency,
reservation_status, reservation_notes, reservation_datetime, place_time, place_time, end_time,
duration_minutes, notes, image_url, google_place_id, website, phone, duration_minutes, notes, image_url, google_place_id, website, phone,
transport_mode, tags transport_mode, tags
} = req.body; } = req.body;
@@ -225,10 +224,8 @@ router.put('/:id', authenticate, (req, res) => {
category_id = ?, category_id = ?,
price = ?, price = ?,
currency = COALESCE(?, currency), currency = COALESCE(?, currency),
reservation_status = COALESCE(?, reservation_status),
reservation_notes = ?,
reservation_datetime = ?,
place_time = ?, place_time = ?,
end_time = ?,
duration_minutes = COALESCE(?, duration_minutes), duration_minutes = COALESCE(?, duration_minutes),
notes = ?, notes = ?,
image_url = ?, image_url = ?,
@@ -247,10 +244,8 @@ router.put('/:id', authenticate, (req, res) => {
category_id !== undefined ? category_id : existingPlace.category_id, category_id !== undefined ? category_id : existingPlace.category_id,
price !== undefined ? price : existingPlace.price, price !== undefined ? price : existingPlace.price,
currency || null, currency || null,
reservation_status || null,
reservation_notes !== undefined ? reservation_notes : existingPlace.reservation_notes,
reservation_datetime !== undefined ? reservation_datetime : existingPlace.reservation_datetime,
place_time !== undefined ? place_time : existingPlace.place_time, place_time !== undefined ? place_time : existingPlace.place_time,
end_time !== undefined ? end_time : existingPlace.end_time,
duration_minutes || null, duration_minutes || null,
notes !== undefined ? notes : existingPlace.notes, notes !== undefined ? notes : existingPlace.notes,
image_url !== undefined ? image_url : existingPlace.image_url, image_url !== undefined ? image_url : existingPlace.image_url,
@@ -282,12 +277,12 @@ router.delete('/:id', authenticate, (req, res) => {
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) { if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' }); return res.status(404).json({ error: 'Trip not found' });
} }
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId); const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!place) { if (!place) {
return res.status(404).json({ error: 'Ort nicht gefunden' }); return res.status(404).json({ error: 'Place not found' });
} }
db.prepare('DELETE FROM places WHERE id = ?').run(id); db.prepare('DELETE FROM places WHERE id = ?').run(id);
+17 -14
View File
@@ -14,10 +14,10 @@ router.get('/', authenticate, (req, res) => {
const { tripId } = req.params; const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const reservations = db.prepare(` const reservations = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name SELECT r.*, d.day_number, p.name as place_name, r.assignment_id
FROM reservations r FROM reservations r
LEFT JOIN days d ON r.day_id = d.id LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id LEFT JOIN places p ON r.place_id = p.id
@@ -31,20 +31,21 @@ router.get('/', authenticate, (req, res) => {
// POST /api/trips/:tripId/reservations // POST /api/trips/:tripId/reservations
router.post('/', authenticate, (req, res) => { router.post('/', authenticate, (req, res) => {
const { tripId } = req.params; const { tripId } = req.params;
const { title, reservation_time, location, confirmation_number, notes, day_id, place_id, status, type } = req.body; const { title, reservation_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!title) return res.status(400).json({ error: 'Titel ist erforderlich' }); if (!title) return res.status(400).json({ error: 'Title is required' });
const result = db.prepare(` const result = db.prepare(`
INSERT INTO reservations (trip_id, day_id, place_id, title, reservation_time, location, confirmation_number, notes, status, type) INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, location, confirmation_number, notes, status, type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
tripId, tripId,
day_id || null, day_id || null,
place_id || null, place_id || null,
assignment_id || null,
title, title,
reservation_time || null, reservation_time || null,
location || null, location || null,
@@ -55,7 +56,7 @@ router.post('/', authenticate, (req, res) => {
); );
const reservation = db.prepare(` const reservation = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name SELECT r.*, d.day_number, p.name as place_name, r.assignment_id
FROM reservations r FROM reservations r
LEFT JOIN days d ON r.day_id = d.id LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id LEFT JOIN places p ON r.place_id = p.id
@@ -69,13 +70,13 @@ router.post('/', authenticate, (req, res) => {
// PUT /api/trips/:tripId/reservations/:id // PUT /api/trips/:tripId/reservations/:id
router.put('/:id', authenticate, (req, res) => { router.put('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params; const { tripId, id } = req.params;
const { title, reservation_time, location, confirmation_number, notes, day_id, place_id, status, type } = req.body; const { title, reservation_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId); const reservation = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!reservation) return res.status(404).json({ error: 'Reservierung nicht gefunden' }); if (!reservation) return res.status(404).json({ error: 'Reservation not found' });
db.prepare(` db.prepare(`
UPDATE reservations SET UPDATE reservations SET
@@ -86,6 +87,7 @@ router.put('/:id', authenticate, (req, res) => {
notes = ?, notes = ?,
day_id = ?, day_id = ?,
place_id = ?, place_id = ?,
assignment_id = ?,
status = COALESCE(?, status), status = COALESCE(?, status),
type = COALESCE(?, type) type = COALESCE(?, type)
WHERE id = ? WHERE id = ?
@@ -97,13 +99,14 @@ router.put('/:id', authenticate, (req, res) => {
notes !== undefined ? (notes || null) : reservation.notes, notes !== undefined ? (notes || null) : reservation.notes,
day_id !== undefined ? (day_id || null) : reservation.day_id, day_id !== undefined ? (day_id || null) : reservation.day_id,
place_id !== undefined ? (place_id || null) : reservation.place_id, place_id !== undefined ? (place_id || null) : reservation.place_id,
assignment_id !== undefined ? (assignment_id || null) : reservation.assignment_id,
status || null, status || null,
type || null, type || null,
id id
); );
const updated = db.prepare(` const updated = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name SELECT r.*, d.day_number, p.name as place_name, r.assignment_id
FROM reservations r FROM reservations r
LEFT JOIN days d ON r.day_id = d.id LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id LEFT JOIN places p ON r.place_id = p.id
@@ -119,10 +122,10 @@ router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params; const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id); const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
const reservation = db.prepare('SELECT id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId); const reservation = db.prepare('SELECT id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!reservation) return res.status(404).json({ error: 'Reservierung nicht gefunden' }); if (!reservation) return res.status(404).json({ error: 'Reservation not found' });
db.prepare('DELETE FROM reservations WHERE id = ?').run(id); db.prepare('DELETE FROM reservations WHERE id = ?').run(id);
res.json({ success: true }); res.json({ success: true });
+3 -3
View File
@@ -22,7 +22,7 @@ router.get('/', authenticate, (req, res) => {
router.put('/', authenticate, (req, res) => { router.put('/', authenticate, (req, res) => {
const { key, value } = req.body; const { key, value } = req.body;
if (!key) return res.status(400).json({ error: 'Schlüssel ist erforderlich' }); if (!key) return res.status(400).json({ error: 'Key is required' });
const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : ''); const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
@@ -39,7 +39,7 @@ router.post('/bulk', authenticate, (req, res) => {
const { settings } = req.body; const { settings } = req.body;
if (!settings || typeof settings !== 'object') { if (!settings || typeof settings !== 'object') {
return res.status(400).json({ error: 'Einstellungen-Objekt ist erforderlich' }); return res.status(400).json({ error: 'Settings object is required' });
} }
const upsert = db.prepare(` const upsert = db.prepare(`
@@ -56,7 +56,7 @@ router.post('/bulk', authenticate, (req, res) => {
db.exec('COMMIT'); db.exec('COMMIT');
} catch (err) { } catch (err) {
db.exec('ROLLBACK'); db.exec('ROLLBACK');
return res.status(500).json({ error: 'Fehler beim Speichern der Einstellungen', detail: err.message }); return res.status(500).json({ error: 'Error saving settings', detail: err.message });
} }
res.json({ success: true, updated: Object.keys(settings).length }); res.json({ success: true, updated: Object.keys(settings).length });
+3 -3
View File
@@ -16,7 +16,7 @@ router.get('/', authenticate, (req, res) => {
router.post('/', authenticate, (req, res) => { router.post('/', authenticate, (req, res) => {
const { name, color } = req.body; const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'Tag-Name ist erforderlich' }); if (!name) return res.status(400).json({ error: 'Tag name is required' });
const result = db.prepare( const result = db.prepare(
'INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)' 'INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)'
@@ -31,7 +31,7 @@ router.put('/:id', authenticate, (req, res) => {
const { name, color } = req.body; const { name, color } = req.body;
const tag = db.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); const tag = db.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!tag) return res.status(404).json({ error: 'Tag nicht gefunden' }); if (!tag) return res.status(404).json({ error: 'Tag not found' });
db.prepare('UPDATE tags SET name = COALESCE(?, name), color = COALESCE(?, color) WHERE id = ?') db.prepare('UPDATE tags SET name = COALESCE(?, name), color = COALESCE(?, color) WHERE id = ?')
.run(name || null, color || null, req.params.id); .run(name || null, color || null, req.params.id);
@@ -43,7 +43,7 @@ router.put('/:id', authenticate, (req, res) => {
// DELETE /api/tags/:id // DELETE /api/tags/:id
router.delete('/:id', authenticate, (req, res) => { router.delete('/:id', authenticate, (req, res) => {
const tag = db.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); const tag = db.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!tag) return res.status(404).json({ error: 'Tag nicht gefunden' }); if (!tag) return res.status(404).json({ error: 'Tag not found' });
db.prepare('DELETE FROM tags WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM tags WHERE id = ?').run(req.params.id);
res.json({ success: true }); res.json({ success: true });
+25 -20
View File
@@ -24,8 +24,13 @@ const uploadCover = multer({
storage: coverStorage, storage: coverStorage,
limits: { fileSize: 20 * 1024 * 1024 }, limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true); const ext = path.extname(file.originalname).toLowerCase();
else cb(new Error('Nur Bilder erlaubt')); const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowedExts.includes(ext)) {
cb(null, true);
} else {
cb(new Error('Only jpg, png, gif, webp images allowed'));
}
}, },
}); });
@@ -74,9 +79,9 @@ router.get('/', authenticate, (req, res) => {
// POST /api/trips // POST /api/trips
router.post('/', authenticate, (req, res) => { router.post('/', authenticate, (req, res) => {
const { title, description, start_date, end_date, currency } = req.body; const { title, description, start_date, end_date, currency } = req.body;
if (!title) return res.status(400).json({ error: 'Titel ist erforderlich' }); if (!title) return res.status(400).json({ error: 'Title is required' });
if (start_date && end_date && new Date(end_date) < new Date(start_date)) if (start_date && end_date && new Date(end_date) < new Date(start_date))
return res.status(400).json({ error: 'Enddatum muss nach dem Startdatum liegen' }); return res.status(400).json({ error: 'End date must be after start date' });
const result = db.prepare(` const result = db.prepare(`
INSERT INTO trips (user_id, title, description, start_date, end_date, currency) INSERT INTO trips (user_id, title, description, start_date, end_date, currency)
@@ -97,24 +102,24 @@ router.get('/:id', authenticate, (req, res) => {
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL) WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
`).get({ userId, tripId: req.params.id }); `).get({ userId, tripId: req.params.id });
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
res.json({ trip }); res.json({ trip });
}); });
// PUT /api/trips/:id — all members can edit; archive/cover owner-only // PUT /api/trips/:id — all members can edit; archive/cover owner-only
router.put('/:id', authenticate, (req, res) => { router.put('/:id', authenticate, (req, res) => {
const access = canAccessTrip(req.params.id, req.user.id); const access = canAccessTrip(req.params.id, req.user.id);
if (!access) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!access) return res.status(404).json({ error: 'Trip not found' });
const ownerOnly = req.body.is_archived !== undefined || req.body.cover_image !== undefined; const ownerOnly = req.body.is_archived !== undefined || req.body.cover_image !== undefined;
if (ownerOnly && !isOwner(req.params.id, req.user.id)) if (ownerOnly && !isOwner(req.params.id, req.user.id))
return res.status(403).json({ error: 'Nur der Eigentümer kann diese Einstellung ändern' }); return res.status(403).json({ error: 'Only the owner can change this setting' });
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id); const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id);
const { title, description, start_date, end_date, currency, is_archived, cover_image } = req.body; const { title, description, start_date, end_date, currency, is_archived, cover_image } = req.body;
if (start_date && end_date && new Date(end_date) < new Date(start_date)) if (start_date && end_date && new Date(end_date) < new Date(start_date))
return res.status(400).json({ error: 'Enddatum muss nach dem Startdatum liegen' }); return res.status(400).json({ error: 'End date must be after start date' });
const newTitle = title || trip.title; const newTitle = title || trip.title;
const newDesc = description !== undefined ? description : trip.description; const newDesc = description !== undefined ? description : trip.description;
@@ -141,11 +146,11 @@ router.put('/:id', authenticate, (req, res) => {
// POST /api/trips/:id/cover // POST /api/trips/:id/cover
router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cover'), (req, res) => { router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cover'), (req, res) => {
if (!isOwner(req.params.id, req.user.id)) if (!isOwner(req.params.id, req.user.id))
return res.status(403).json({ error: 'Nur der Eigentümer kann das Titelbild ändern' }); return res.status(403).json({ error: 'Only the owner can change the cover image' });
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id); const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!req.file) return res.status(400).json({ error: 'Kein Bild hochgeladen' }); if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
if (trip.cover_image) { if (trip.cover_image) {
const oldPath = path.join(__dirname, '../../', trip.cover_image.replace(/^\//, '')); const oldPath = path.join(__dirname, '../../', trip.cover_image.replace(/^\//, ''));
@@ -164,7 +169,7 @@ router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cov
// DELETE /api/trips/:id — owner only // DELETE /api/trips/:id — owner only
router.delete('/:id', authenticate, (req, res) => { router.delete('/:id', authenticate, (req, res) => {
if (!isOwner(req.params.id, req.user.id)) if (!isOwner(req.params.id, req.user.id))
return res.status(403).json({ error: 'Nur der Eigentümer kann die Reise löschen' }); return res.status(403).json({ error: 'Only the owner can delete the trip' });
const deletedTripId = Number(req.params.id); const deletedTripId = Number(req.params.id);
db.prepare('DELETE FROM trips WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM trips WHERE id = ?').run(req.params.id);
res.json({ success: true }); res.json({ success: true });
@@ -176,7 +181,7 @@ router.delete('/:id', authenticate, (req, res) => {
// GET /api/trips/:id/members // GET /api/trips/:id/members
router.get('/:id/members', authenticate, (req, res) => { router.get('/:id/members', authenticate, (req, res) => {
if (!canAccessTrip(req.params.id, req.user.id)) if (!canAccessTrip(req.params.id, req.user.id))
return res.status(404).json({ error: 'Reise nicht gefunden' }); return res.status(404).json({ error: 'Trip not found' });
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id); const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id);
const members = db.prepare(` const members = db.prepare(`
@@ -203,23 +208,23 @@ router.get('/:id/members', authenticate, (req, res) => {
// POST /api/trips/:id/members — add by email or username // POST /api/trips/:id/members — add by email or username
router.post('/:id/members', authenticate, (req, res) => { router.post('/:id/members', authenticate, (req, res) => {
if (!canAccessTrip(req.params.id, req.user.id)) if (!canAccessTrip(req.params.id, req.user.id))
return res.status(404).json({ error: 'Reise nicht gefunden' }); return res.status(404).json({ error: 'Trip not found' });
const { identifier } = req.body; // email or username const { identifier } = req.body; // email or username
if (!identifier) return res.status(400).json({ error: 'E-Mail oder Benutzername erforderlich' }); if (!identifier) return res.status(400).json({ error: 'Email or username required' });
const target = db.prepare( const target = db.prepare(
'SELECT id, username, email, avatar FROM users WHERE email = ? OR username = ?' 'SELECT id, username, email, avatar FROM users WHERE email = ? OR username = ?'
).get(identifier.trim(), identifier.trim()); ).get(identifier.trim(), identifier.trim());
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); if (!target) return res.status(404).json({ error: 'User not found' });
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id); const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id);
if (target.id === trip.user_id) if (target.id === trip.user_id)
return res.status(400).json({ error: 'Der Eigentümer der Reise ist bereits Mitglied' }); return res.status(400).json({ error: 'Trip owner is already a member' });
const existing = db.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(req.params.id, target.id); const existing = db.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(req.params.id, target.id);
if (existing) return res.status(400).json({ error: 'Benutzer hat bereits Zugriff' }); if (existing) return res.status(400).json({ error: 'User already has access' });
db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(req.params.id, target.id, req.user.id); db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(req.params.id, target.id, req.user.id);
@@ -229,12 +234,12 @@ router.post('/:id/members', authenticate, (req, res) => {
// DELETE /api/trips/:id/members/:userId — owner removes anyone; member removes self // DELETE /api/trips/:id/members/:userId — owner removes anyone; member removes self
router.delete('/:id/members/:userId', authenticate, (req, res) => { router.delete('/:id/members/:userId', authenticate, (req, res) => {
if (!canAccessTrip(req.params.id, req.user.id)) if (!canAccessTrip(req.params.id, req.user.id))
return res.status(404).json({ error: 'Reise nicht gefunden' }); return res.status(404).json({ error: 'Trip not found' });
const targetId = parseInt(req.params.userId); const targetId = parseInt(req.params.userId);
const isSelf = targetId === req.user.id; const isSelf = targetId === req.user.id;
if (!isSelf && !isOwner(req.params.id, req.user.id)) if (!isSelf && !isOwner(req.params.id, req.user.id))
return res.status(403).json({ error: 'Keine Berechtigung' }); return res.status(403).json({ error: 'No permission' });
db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(req.params.id, targetId); db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(req.params.id, targetId);
res.json({ success: true }); res.json({ success: true });
+14 -14
View File
@@ -9,8 +9,8 @@ const CACHE_TTL = 24 * 60 * 60 * 1000;
const router = express.Router(); const router = express.Router();
router.use(authenticate); router.use(authenticate);
// Broadcast vacay updates to all users in the same plan // Broadcast vacay updates to all users in the same plan (exclude only the triggering socket, not the whole user)
function notifyPlanUsers(planId, excludeUserId, event = 'vacay:update') { function notifyPlanUsers(planId, excludeSid, event = 'vacay:update') {
try { try {
const { broadcastToUser } = require('../websocket'); const { broadcastToUser } = require('../websocket');
const plan = db.prepare('SELECT owner_id FROM vacay_plans WHERE id = ?').get(planId); const plan = db.prepare('SELECT owner_id FROM vacay_plans WHERE id = ?').get(planId);
@@ -18,7 +18,7 @@ function notifyPlanUsers(planId, excludeUserId, event = 'vacay:update') {
const userIds = [plan.owner_id]; const userIds = [plan.owner_id];
const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(planId); const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(planId);
members.forEach(m => userIds.push(m.user_id)); members.forEach(m => userIds.push(m.user_id));
userIds.filter(id => id !== excludeUserId).forEach(id => broadcastToUser(id, { type: event })); userIds.forEach(id => broadcastToUser(id, { type: event }, excludeSid));
} catch { /* */ } } catch { /* */ }
} }
@@ -191,7 +191,7 @@ router.put('/plan', async (req, res) => {
} }
} }
notifyPlanUsers(planId, req.user.id, 'vacay:settings'); notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:settings');
const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId); const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
res.json({ res.json({
@@ -213,7 +213,7 @@ router.put('/color', (req, res) => {
INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?) INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)
ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color
`).run(userId, planId, color || '#6366f1'); `).run(userId, planId, color || '#6366f1');
notifyPlanUsers(planId, req.user.id, 'vacay:update'); notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:update');
res.json({ success: true }); res.json({ success: true });
}); });
@@ -300,7 +300,7 @@ router.post('/invite/accept', (req, res) => {
} }
// Notify all plan users (not just owner) // Notify all plan users (not just owner)
notifyPlanUsers(plan_id, req.user.id, 'vacay:accepted'); notifyPlanUsers(plan_id, req.headers['x-socket-id'], 'vacay:accepted');
res.json({ success: true }); res.json({ success: true });
}); });
@@ -310,7 +310,7 @@ router.post('/invite/decline', (req, res) => {
const { plan_id } = req.body; const { plan_id } = req.body;
db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(plan_id, req.user.id); db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(plan_id, req.user.id);
notifyPlanUsers(plan_id, req.user.id, 'vacay:declined'); notifyPlanUsers(plan_id, req.headers['x-socket-id'], 'vacay:declined');
res.json({ success: true }); res.json({ success: true });
}); });
@@ -417,7 +417,7 @@ router.post('/years', (req, res) => {
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?)').run(u.id, planId, year, carriedOver); db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?)').run(u.id, planId, year, carriedOver);
} }
} catch { /* exists */ } } catch { /* exists */ }
notifyPlanUsers(planId, req.user.id, 'vacay:settings'); notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:settings');
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId); const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId);
res.json({ years: years.map(y => y.year) }); res.json({ years: years.map(y => y.year) });
}); });
@@ -428,7 +428,7 @@ router.delete('/years/:year', (req, res) => {
db.prepare('DELETE FROM vacay_years WHERE plan_id = ? AND year = ?').run(planId, year); db.prepare('DELETE FROM vacay_years WHERE plan_id = ? AND year = ?').run(planId, year);
db.prepare("DELETE FROM vacay_entries WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`); db.prepare("DELETE FROM vacay_entries WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
db.prepare("DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`); db.prepare("DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
notifyPlanUsers(planId, req.user.id, 'vacay:settings'); notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:settings');
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId); const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId);
res.json({ years: years.map(y => y.year) }); res.json({ years: years.map(y => y.year) });
}); });
@@ -466,11 +466,11 @@ router.post('/entries/toggle', (req, res) => {
const existing = db.prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId); const existing = db.prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId);
if (existing) { if (existing) {
db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id); db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id);
notifyPlanUsers(planId, req.user.id); notifyPlanUsers(planId, req.headers['x-socket-id']);
res.json({ action: 'removed' }); res.json({ action: 'removed' });
} else { } else {
db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, userId, date, ''); db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, userId, date, '');
notifyPlanUsers(planId, req.user.id); notifyPlanUsers(planId, req.headers['x-socket-id']);
res.json({ action: 'added' }); res.json({ action: 'added' });
} }
}); });
@@ -481,13 +481,13 @@ router.post('/entries/company-holiday', (req, res) => {
const existing = db.prepare('SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').get(planId, date); const existing = db.prepare('SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').get(planId, date);
if (existing) { if (existing) {
db.prepare('DELETE FROM vacay_company_holidays WHERE id = ?').run(existing.id); db.prepare('DELETE FROM vacay_company_holidays WHERE id = ?').run(existing.id);
notifyPlanUsers(planId, req.user.id); notifyPlanUsers(planId, req.headers['x-socket-id']);
res.json({ action: 'removed' }); res.json({ action: 'removed' });
} else { } else {
db.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, date, note || ''); db.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, date, note || '');
// Remove any vacation entries on this date // Remove any vacation entries on this date
db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, date); db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, date);
notifyPlanUsers(planId, req.user.id); notifyPlanUsers(planId, req.headers['x-socket-id']);
res.json({ action: 'added' }); res.json({ action: 'added' });
} }
}); });
@@ -544,7 +544,7 @@ router.put('/stats/:year', (req, res) => {
INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, 0) INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, 0)
ON CONFLICT(user_id, plan_id, year) DO UPDATE SET vacation_days = excluded.vacation_days ON CONFLICT(user_id, plan_id, year) DO UPDATE SET vacation_days = excluded.vacation_days
`).run(userId, planId, year, vacation_days); `).run(userId, planId, year, vacation_days);
notifyPlanUsers(planId, req.user.id); notifyPlanUsers(planId, req.headers['x-socket-id']);
res.json({ success: true }); res.json({ success: true });
}); });

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