Compare commits

..

38 Commits

Author SHA1 Message Date
Maurice 068b90ed72 v2.6.0 — Collab overhaul, route travel times, chat & notes redesign
## Collab — Complete Redesign
- iMessage-style live chat with blue bubbles, grouped messages, date separators
- Emoji reactions via right-click (desktop) or double-tap (mobile)
- Twemoji (Apple-style) emoji picker with categories
- Link previews with OG image/title/description
- Soft-delete messages with "deleted a message" placeholder
- Message reactions with real-time WebSocket sync
- Chat timestamps respect 12h/24h setting and timezone

## Collab Notes
- Redesigned note cards with colored header bar (booking-card style)
- 2-column grid layout (desktop), 1-column (mobile)
- Category settings modal for managing categories with colors
- File/image attachments on notes with mini-preview thumbnails
- Website links with OG image preview on note cards
- File preview portal (lightbox for images, inline viewer for PDF/TXT)
- Note files appear in Files tab with "From Collab Notes" badge
- Pin highlighting with tinted background
- Author avatar chip in header bar with custom tooltip

## Collab Polls
- Complete rewrite — clean Apple-style poll cards
- Animated progress bars with vote percentages
- Blue check circles for own votes, voter avatars
- Create poll modal with multi-choice toggle
- Active/closed poll sections
- Custom tooltips on voter chips

## What's Next Widget
- New widget showing upcoming trip activities
- Time display with "until" separator
- Participant chips per activity
- Day grouping (Today, Tomorrow, dates)
- Respects 12h/24h and locale settings

## Route Travel Times
- Auto-calculated walking + driving times via OSRM (free, no API key)
- Floating badge on each route segment between places
- Walking person icon + car icon with times
- Hides when zoomed out (< zoom 16)
- Toggle in Settings > Display to enable/disable

## Other Improvements
- Collab addon enabled by default for new installations
- Coming Soon removed from Collab in admin settings
- Tab state persisted across page reloads (sessionStorage)
- Day sidebar expanded/collapsed state persisted
- File preview with extension badges (PDF, TXT, etc.) in Files tab
- Collab Notes filter tab in Files
- Reservations section in Day Detail view
- Dark mode fix for invite button text color
- Chat scroll hidden (no visible scrollbar)
- Mobile: tab icons removed for space, touch-friendly UI
- Fixed 6 backend data structure bugs in Collab (polls, chat, notes)
- Soft-delete for chat messages (persists in history)
- Message reactions table (migration 28)
- Note attachments via trip_files with note_id (migration 30)

## Database Migrations
- Migration 27: budget_item_members table
- Migration 28: collab_message_reactions table
- Migration 29: soft-delete column on collab_messages
- Migration 30: note_id on trip_files, website on collab_notes
2026-03-25 22:59:39 +01:00
Maurice 17288f9a0e Budget: per-person expense tracking with member chips
- New budget_item_members junction table (migration 27)
- Assign trip members to budget items via avatar chips in Persons column
- Per-person split auto-calculated from assigned member count
- Per-person summary integrated into total budget card
- Member chips rendered via portal dropdown (no overflow clipping)
- Mobile: larger touch-friendly chips (30px) under item name
- Desktop: compact chips (20px) in Persons column
- Custom NOMAD-style tooltips on chips
- WebSocket live sync for all member operations
- Fix invite button text color in dark mode
- Widen budget layout to 1800px max-width
- Shorten "Per Person/Day" column header
2026-03-25 17:31:37 +01:00
Maurice 3bf49d4180 Per-assignment times, participant avatar fix, UI improvements
- Times are now per-assignment instead of per-place, so the same place
  on different days can have different times
- Migration 26 adds assignment_time/assignment_end_time columns
- New endpoint PUT /assignments/:id/time for updating assignment times
- Time picker removed from place creation (only shown when editing)
- End-before-start validation disables save button
- Time collision warning shows overlapping activities on the same day
- Fix participant avatars using avatar_url instead of avatar filename
- Rename "Add Place" to "Add Place/Activity" (DE + EN)
- Improve README update instructions with docker inspect tip
2026-03-25 16:47:33 +01:00
mauriceboe 66e2799870 Remove OpenWeatherMap API setup instructions
Removed OpenWeatherMap setup instructions from README.
2026-03-25 13:26:46 +01:00
Maurice 732accce3d Fix: addon seeding skipped on fresh installs due to collab migration
Migration 25 inserted the collab addon before seeding ran, causing
the COUNT check to find 1 row and skip all default addons. Switch to
INSERT OR IGNORE so every default addon is created regardless.
2026-03-25 13:21:37 +01:00
Maurice 785e8264cd Health endpoint, file types config, budget rename, UI fixes
- Add /api/health endpoint (returns 200 OK without auth)
- Update docker-compose healthcheck to use /api/health
- Admin: configurable allowed file types
- Budget categories can now be renamed (inline edit)
- Place inspector: opening hours + files side by side on desktop
- Address clamped to 2 lines, coordinates hidden on mobile
- Category icon-only on mobile, rating hidden on mobile
- Time validation: "10" becomes "10:00"
- Hotel picker: separate save button, edit opens full popup
- Day header background improved for dark mode
- Notes: 150 char limit with counter, textarea input
- Files grid: full width when no opening hours
- Various responsive fixes
2026-03-25 00:14:53 +01:00
Maurice e3cb5745dd Fix production build: remove extra closing div in PlaceInspector 2026-03-24 23:28:05 +01:00
Maurice 785f0a7684 Participants, context menus, budget rename, file types, UI polish
- Assignment participants: toggle who joins each activity
  - Chips with hover-to-remove (strikethrough effect)
  - Add button with dropdown for available members
  - Avatars in day plan sidebar
  - Side-by-side with reservation in place inspector
- Right-click context menus for places, notes in day plan + places list
- Budget categories can now be renamed (pencil icon inline edit)
- Admin: configurable allowed file types (stored in app_settings)
- File manager shows allowed types dynamically
- Hotel picker: select place + save button (no auto-close)
- Edit pencil opens full hotel popup with all options
- Place inspector: opening hours + files side by side on desktop
- Address clamped to 2 lines, coordinates hidden on mobile
- Category shows icon only on mobile
- Rating hidden on mobile in place inspector
- Time validation: "10" becomes "10:00"
- Climate weather: full hourly data from archive API
- CustomSelect: grouped headers support (isHeader)
- Various responsive fixes
2026-03-24 23:25:02 +01:00
Maurice e1cd9655fb Context menus, climate hourly data, UI fixes
- Right-click context menus for places in day plan (edit, remove, Google Maps, delete)
- Right-click context menus for places in places list (edit, add to day, delete)
- Right-click context menus for notes (edit, delete)
- Historical climate now shows full hourly data, wind, sunrise/sunset (same as forecast)
- Day header selected background improved for dark mode
- Note input: textarea with 150 char limit and counter
- Note text wraps properly in day plan
2026-03-24 22:23:15 +01:00
Maurice 2e0481c045 Fix: char counter + textarea on note subtitle field, title stays single line 2026-03-24 22:05:37 +01:00
Maurice 3d13ed75d7 Note input: show char counter, textarea 3 rows 2026-03-24 22:04:40 +01:00
Maurice 7094e54432 Add 150 char limit to day notes input 2026-03-24 22:02:44 +01:00
Maurice 858bea1952 Make file name clickable as link in place inspector 2026-03-24 22:00:08 +01:00
Maurice 3fd2410ba6 Fix language picker showing opposite language on login page 2026-03-24 21:58:24 +01:00
Maurice c1e568cb1e Fix route line not updating: reactive effect on assignment changes 2026-03-24 21:56:08 +01:00
Maurice 21a71697be Fix route line remaining after place deletion (store timing) 2026-03-24 21:52:23 +01:00
Maurice e660cca284 Fix route not updating after deleting a place 2026-03-24 21:50:25 +01:00
Maurice 763c878dab Fix PlaceAvatar showing inherited photo from previous place 2026-03-24 21:48:06 +01:00
Maurice d0d39d1e35 Fix time validation (20:undefined), show end_time in place inspector 2026-03-24 21:44:30 +01:00
Maurice e70cd5729e Remove booking hint banner, responsive location/assignment layout on mobile 2026-03-24 20:47:27 +01:00
Maurice 114ec7d131 Fix: mobile day detail view, day click always opens detail 2026-03-24 20:21:37 +01:00
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
98 changed files with 8788 additions and 1731 deletions
+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"]
+22 -15
View File
@@ -1,5 +1,9 @@
<p align="center"> <p align="center">
<img src="client/public/logo-dark.svg" alt="NOMAD" height="60" /> <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 /> <br />
<em>Navigation Organizer for Maps, Activities & Destinations</em> <em>Navigation Organizer for Maps, Activities & Destinations</em>
</p> </p>
@@ -18,6 +22,7 @@
</p> </p>
![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>
@@ -25,7 +30,7 @@
| | | | | |
|---|---| |---|---|
| ![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>
@@ -38,7 +43,7 @@
- **Place Search** — Search via Google Places (with photos, ratings, opening hours) or OpenStreetMap (free, no API key needed) - **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 - **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 - **Route Optimization** — Auto-optimize place order and export to Google Maps
- **Weather Forecasts** — Current weather and 5-day forecasts with smart caching - **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback
### Travel Management ### Travel Management
- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments - **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments
@@ -66,20 +71,20 @@
### Customization & Admin ### Customization & Admin
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching - **Dark Mode** — Full light and dark theme with dynamic status bar color matching
- **Multilingual** — English and German (i18n) - **Multilingual** — English and German (i18n)
- **Admin Panel** — User management, global categories, addon management, API keys, and backups - **Admin Panel** — User management, global categories, addon management, API keys, backups, and GitHub release history
- **Auto-Backups** — Scheduled backups with configurable interval and retention - **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 - **PWA**: vite-plugin-pwa + Workbox
- **Real-Time**: WebSocket (`ws`) - **Real-Time**: WebSocket (`ws`)
- **State**: Zustand - **State**: Zustand
- **Auth**: JWT + OIDC - **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
@@ -126,15 +131,23 @@ docker compose up -d
### Updating ### Updating
**Docker Compose** (recommended):
```bash
docker compose pull && docker compose up -d
```
**Docker Run** — use the same volume paths from your original `docker run` command:
```bash ```bash
docker pull mauriceboe/nomad docker pull mauriceboe/nomad
docker rm -f nomad docker rm -f nomad
docker run -d --name nomad -p 3000:3000 -v /your/data:/app/data -v /your/uploads:/app/uploads --restart unless-stopped mauriceboe/nomad docker run -d --name nomad -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/nomad
``` ```
Or with Docker Compose: `docker compose pull && docker compose up -d` > **Tip:** Not sure which paths you used? Run `docker inspect nomad --format '{{json .Mounts}}'` before removing the container.
Your data is persisted in the mounted `data` and `uploads` volumes. Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
### Reverse Proxy (recommended) ### Reverse Proxy (recommended)
@@ -207,12 +220,6 @@ API keys are configured in the **Admin Panel** after login. Keys set by the admi
3. Create an API key under Credentials 3. Create an API key under Credentials
4. In NOMAD: Admin Panel → Settings → Google Maps 4. In NOMAD: Admin Panel → Settings → Google Maps
### OpenWeatherMap (Weather Forecasts)
1. Sign up at [OpenWeatherMap](https://openweathermap.org/api)
2. Get a free API key
3. In NOMAD: Admin Panel → Settings → OpenWeatherMap
## Building from Source ## Building from Source
```bash ```bash
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "nomad-client", "name": "nomad-client",
"version": "2.5.1", "version": "2.5.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nomad-client", "name": "nomad-client",
"version": "2.5.1", "version": "2.5.5",
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7", "axios": "^1.6.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nomad-client", "name": "nomad-client",
"version": "2.5.2", "version": "2.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+17 -9
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>
) )
@@ -80,15 +81,22 @@ export default function App() {
// Apply dark mode class to <html> + update PWA theme-color // 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')
} }
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) { if (mode === 'auto') {
meta.setAttribute('content', settings.dark_mode ? '#09090b' : '#ffffff') 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 (
+41 -2
View File
@@ -94,6 +94,10 @@ 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),
getParticipants: (tripId, id) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
setParticipants: (tripId, id, userIds) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds }).then(r => r.data),
updateTime: (tripId, id, times) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
} }
export const packingApi = { export const packingApi = {
@@ -129,6 +133,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 = {
@@ -146,6 +152,9 @@ export const budgetApi = {
create: (tripId, data) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data), create: (tripId, data) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data), update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data), delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
setMembers: (tripId, id, userIds) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
togglePaid: (tripId, id, userId, paid) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
perPersonSummary: (tripId) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
} }
export const filesApi = { export const filesApi = {
@@ -165,7 +174,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 +184,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),
@@ -181,6 +198,28 @@ export const dayNotesApi = {
delete: (tripId, dayId, id) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data), delete: (tripId, dayId, id) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
} }
export const collabApi = {
// Notes
getNotes: (tripId) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data),
createNote: (tripId, data) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
updateNote: (tripId, id, data) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
deleteNote: (tripId, id) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
uploadNoteFile: (tripId, noteId, formData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
deleteNoteFile: (tripId, noteId, fileId) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
// Polls
getPolls: (tripId) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
createPoll: (tripId, data) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
votePoll: (tripId, id, optionIndex) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex }).then(r => r.data),
closePoll: (tripId, id) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
deletePoll: (tripId, id) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
// Chat
getMessages: (tripId, before) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
sendMessage: (tripId, data) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
deleteMessage: (tripId, id) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
reactMessage: (tripId, id, emoji) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji }).then(r => r.data),
linkPreview: (tripId, url) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
}
export const backupApi = { export const backupApi = {
list: () => apiClient.get('/backup/list').then(r => r.data), list: () => apiClient.get('/backup/list').then(r => r.data),
create: () => apiClient.post('/backup/create').then(r => r.data), create: () => apiClient.post('/backup/create').then(r => r.data),
@@ -189,7 +228,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')
+16 -8
View File
@@ -16,7 +16,8 @@ function AddonIcon({ name, size = 20 }) {
export default function AddonManager() { export default function AddonManager() {
const { t } = useTranslation() const { t } = useTranslation()
const dark = useSettingsStore(s => s.settings.dark_mode) const 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)
@@ -117,8 +118,9 @@ export default function AddonManager() {
} }
function AddonRow({ addon, onToggle, t }) { function AddonRow({ addon, onToggle, t }) {
const isComingSoon = false
return ( return (
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)' }}> <div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
{/* Icon */} {/* Icon */}
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}> <div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
<AddonIcon name={addon.icon} size={20} /> <AddonIcon name={addon.icon} size={20} />
@@ -128,6 +130,11 @@ function AddonRow({ addon, onToggle, t }) {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{addon.name}</span> <span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{addon.name}</span>
{isComingSoon && (
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
Coming Soon
</span>
)}
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{ <span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{
background: addon.type === 'global' ? 'var(--bg-secondary)' : 'var(--bg-secondary)', background: addon.type === 'global' ? 'var(--bg-secondary)' : 'var(--bg-secondary)',
color: 'var(--text-muted)', color: 'var(--text-muted)',
@@ -140,19 +147,20 @@ function AddonRow({ addon, onToggle, t }) {
{/* Toggle */} {/* Toggle */}
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className="text-xs font-medium" style={{ color: addon.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}> <span className="text-xs font-medium" style={{ color: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} {isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span> </span>
<button <button
onClick={() => onToggle(addon)} onClick={() => !isComingSoon && onToggle(addon)}
disabled={isComingSoon}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors" className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: addon.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }} style={{ background: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
> >
<span <span
className="inline-block h-4 w-4 transform rounded-full transition-transform" className="inline-block h-4 w-4 transform rounded-full transition-transform"
style={{ style={{
background: addon.enabled ? 'var(--bg-card)' : 'var(--bg-card)', background: 'var(--bg-card)',
transform: addon.enabled ? 'translateX(22px)' : 'translateX(4px)', transform: (addon.enabled && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
}} }}
/> />
</button> </button>
+5 -9
View File
@@ -387,7 +387,7 @@ export default function BackupPanel() {
</div> </div>
<div> <div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}> <h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>
{language === 'de' ? 'Backup wiederherstellen?' : 'Restore Backup?'} {t('backup.restoreConfirmTitle')}
</h3> </h3>
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}> <p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
{restoreConfirm.filename} {restoreConfirm.filename}
@@ -398,17 +398,13 @@ export default function BackupPanel() {
{/* Body */} {/* Body */}
<div style={{ padding: '20px 24px' }}> <div style={{ padding: '20px 24px' }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}> <p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{language === 'de' {t('backup.restoreWarning')}
? 'Alle aktuellen Daten (Reisen, Orte, Benutzer, Uploads) werden unwiderruflich durch das Backup ersetzt. Dieser Vorgang kann nicht rückgängig gemacht werden.'
: 'All current data (trips, places, users, uploads) will be permanently replaced by the backup. This action cannot be undone.'}
</p> </p>
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }} <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" className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
> >
{language === 'de' {t('backup.restoreTip')}
? 'Tipp: Erstelle zuerst ein Backup des aktuellen Stands, bevor du wiederherstellst.'
: 'Tip: Create a backup of the current state before restoring.'}
</div> </div>
</div> </div>
@@ -419,7 +415,7 @@ export default function BackupPanel() {
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700" 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' }} style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
> >
{language === 'de' ? 'Abbrechen' : 'Cancel'} {t('common.cancel')}
</button> </button>
<button <button
onClick={executeRestore} onClick={executeRestore}
@@ -427,7 +423,7 @@ export default function BackupPanel() {
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'} onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'} onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
> >
{language === 'de' ? 'Ja, wiederherstellen' : 'Yes, restore'} {t('backup.restoreConfirm')}
</button> </button>
</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>
)
}
+233 -13
View File
@@ -1,8 +1,10 @@
import React, { useState, useEffect, useRef, useMemo } from 'react' import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import ReactDOM from 'react-dom'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { Plus, Trash2, Calculator, Wallet } from 'lucide-react' import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { budgetApi } from '../../api/client'
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD'] const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD']
@@ -110,6 +112,172 @@ function AddItemRow({ onAdd, t }) {
) )
} }
// ── Chip with custom tooltip ─────────────────────────────────────────────────
function ChipWithTooltip({ label, avatarUrl, size = 20 }) {
const [hover, setHover] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const ref = useRef(null)
const onEnter = () => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect()
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
}
setHover(true)
}
return (
<>
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
style={{
width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)',
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
}}>
{avatarUrl
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: label?.[0]?.toUpperCase()
}
</div>
{hover && ReactDOM.createPortal(
<div style={{
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
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)',
}}>
{label}
</div>,
document.body
)}
</>
)
}
// ── Budget Member Chips (for Persons column) ────────────────────────────────
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compact = true }) {
const chipSize = compact ? 20 : 30
const btnSize = compact ? 18 : 28
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
const [showDropdown, setShowDropdown] = useState(false)
const [dropPos, setDropPos] = useState({ top: 0, left: 0 })
const btnRef = useRef(null)
const dropRef = useRef(null)
const openDropdown = useCallback(() => {
if (btnRef.current) {
const rect = btnRef.current.getBoundingClientRect()
setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 })
}
setShowDropdown(v => !v)
}, [])
useEffect(() => {
if (!showDropdown) return
const close = (e) => {
if (dropRef.current && dropRef.current.contains(e.target)) return
if (btnRef.current && btnRef.current.contains(e.target)) return
setShowDropdown(false)
}
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [showDropdown])
const memberIds = members.map(m => m.user_id)
const toggleMember = (userId) => {
const newIds = memberIds.includes(userId)
? memberIds.filter(id => id !== userId)
: [...memberIds, userId]
onSetMembers(newIds)
}
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
{members.map(m => (
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize} />
))}
<button ref={btnRef} onClick={openDropdown}
style={{
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
}}>
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
</button>
{showDropdown && ReactDOM.createPortal(
<div ref={dropRef} style={{
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 150,
}}>
{tripMembers.map(tm => {
const isActive = memberIds.includes(tm.id)
return (
<button key={tm.id} onClick={() => toggleMember(tm.id)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', textAlign: 'left',
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }}
>
<div style={{
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
}}>
{tm.avatar_url
? <img src={tm.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: tm.username?.[0]?.toUpperCase()
}
</div>
<span style={{ flex: 1 }}>{tm.username}</span>
{isActive && <Check size={12} color="var(--text-primary)" />}
</button>
)
})}
</div>,
document.body
)}
</div>
)
}
// ── Per-Person Inline (inside total card) ────────────────────────────────────
function PerPersonInline({ tripId, budgetItems, currency, locale }) {
const [data, setData] = useState(null)
const fmt = (v) => fmtNum(v, locale, currency)
useEffect(() => {
budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
}, [tripId, budgetItems])
if (!data || data.length === 0) return null
return (
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map(person => (
<div key={person.user_id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
width: 22, height: 22, borderRadius: '50%', background: 'rgba(255,255,255,0.1)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, fontWeight: 700,
color: 'rgba(255,255,255,0.7)', overflow: 'hidden', flexShrink: 0,
}}>
{person.avatar_url
? <img src={person.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: person.username?.[0]?.toUpperCase()
}
</div>
<span style={{ flex: 1, fontSize: 12, fontWeight: 500, color: 'rgba(255,255,255,0.7)' }}>{person.username}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#fff' }}>{fmt(person.total_assigned)}</span>
</div>
))}
</div>
)
}
// ── Pie Chart (pure CSS conic-gradient) ────────────────────────────────────── // ── Pie Chart (pure CSS conic-gradient) ──────────────────────────────────────
function PieChart({ segments, size = 200, totalLabel }) { function PieChart({ segments, size = 200, totalLabel }) {
if (!segments.length) return null if (!segments.length) return null
@@ -148,13 +316,15 @@ function PieChart({ segments, size = 200, totalLabel }) {
} }
// ── Main Component ─────────────────────────────────────────────────────────── // ── Main Component ───────────────────────────────────────────────────────────
export default function BudgetPanel({ tripId }) { export default function BudgetPanel({ tripId, tripMembers = [] }) {
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip } = useTripStore() const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers } = useTripStore()
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const [newCategoryName, setNewCategoryName] = useState('') const [newCategoryName, setNewCategoryName] = useState('')
const [editingCat, setEditingCat] = useState(null) // { name, value }
const currency = trip?.currency || 'EUR' const currency = trip?.currency || 'EUR'
const fmt = (v, cur) => fmtNum(v, locale, cur) const fmt = (v, cur) => fmtNum(v, locale, cur)
const hasMultipleMembers = tripMembers.length > 1
const setCurrency = (cur) => { const setCurrency = (cur) => {
if (tripId) updateTrip(tripId, { currency: cur }) if (tripId) updateTrip(tripId, { currency: cur })
@@ -163,7 +333,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
@@ -187,10 +357,15 @@ export default function BudgetPanel({ tripId }) {
const items = grouped[cat] || [] const items = grouped[cat] || []
for (const item of items) await deleteBudgetItem(tripId, item.id) for (const item of items) await deleteBudgetItem(tripId, item.id)
} }
const handleRenameCategory = async (oldName, newName) => {
if (!newName.trim() || newName.trim() === oldName) return
const items = grouped[oldName] || []
for (const item of items) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
}
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)' }
@@ -239,9 +414,27 @@ export default function BudgetPanel({ tripId }) {
return ( return (
<div key={cat} style={{ marginBottom: 16 }}> <div key={cat} style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', borderRadius: '10px 10px 0 0', padding: '9px 14px' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', borderRadius: '10px 10px 0 0', padding: '9px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} /> <div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span> {editingCat?.name === cat ? (
<input
autoFocus
value={editingCat.value}
onChange={e => setEditingCat({ ...editingCat, value: e.target.value })}
onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }}
onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }}
style={{ fontWeight: 600, fontSize: 13, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }}
/>
) : (
<>
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
<button onClick={() => setEditingCat({ name: cat, value: cat })}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
<Pencil size={10} />
</button>
</>
)}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span> <span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
@@ -258,8 +451,8 @@ export default function BudgetPanel({ tripId }) {
<thead> <thead>
<tr> <tr>
<th style={{ ...th, textAlign: 'left', minWidth: 100 }}>{t('budget.table.name')}</th> <th style={{ ...th, textAlign: 'left', minWidth: 100 }}>{t('budget.table.name')}</th>
<th style={{ ...th, minWidth: 80 }}>{t('budget.table.total')}</th> <th style={{ ...th, minWidth: 60 }}>{t('budget.table.total')}</th>
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 50 }}>{t('budget.table.persons')}</th> <th className="hidden sm:table-cell" style={{ ...th, minWidth: 130 }}>{t('budget.table.persons')}</th>
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 45 }}>{t('budget.table.days')}</th> <th className="hidden sm:table-cell" style={{ ...th, minWidth: 45 }}>{t('budget.table.days')}</th>
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perPerson')}</th> <th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perPerson')}</th>
<th className="hidden md:table-cell" style={{ ...th, minWidth: 80 }}>{t('budget.table.perDay')}</th> <th className="hidden md:table-cell" style={{ ...th, minWidth: 80 }}>{t('budget.table.perDay')}</th>
@@ -273,16 +466,38 @@ export default function BudgetPanel({ tripId }) {
const pp = calcPP(item.total_price, item.persons) const pp = calcPP(item.total_price, item.persons)
const pd = calcPD(item.total_price, item.days) const pd = calcPD(item.total_price, item.days)
const ppd = calcPPD(item.total_price, item.persons, item.days) const ppd = calcPPD(item.total_price, item.persons, item.days)
const hasMembers = item.members?.length > 0
return ( return (
<tr key={item.id} style={{ transition: 'background 0.1s' }} <tr key={item.id} style={{ transition: 'background 0.1s' }}
onMouseEnter={e => 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'}>
<td style={td}><InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} /></td> <td style={td}>
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} />
{/* Mobile: larger chips under name since Persons column is hidden */}
{hasMultipleMembers && (
<div className="sm:hidden" style={{ marginTop: 4 }}>
<BudgetMemberChips
members={item.members || []}
tripMembers={tripMembers}
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
compact={false}
/>
</div>
)}
</td>
<td style={{ ...td, textAlign: 'center' }}> <td style={{ ...td, textAlign: 'center' }}>
<InlineEditCell value={item.total_price} type="number" onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder="0,00" locale={locale} editTooltip={t('budget.editTooltip')} /> <InlineEditCell value={item.total_price} type="number" onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder="0,00" locale={locale} editTooltip={t('budget.editTooltip')} />
</td> </td>
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}> <td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} /> {hasMultipleMembers ? (
<BudgetMemberChips
members={item.members || []}
tripMembers={tripMembers}
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
/>
) : (
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
)}
</td> </td>
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}> <td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} /> <InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
@@ -351,6 +566,9 @@ export default function BudgetPanel({ tripId }) {
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div> </div>
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div> <div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} />
)}
</div> </div>
{pieSegments.length > 0 && ( {pieSegments.length > 0 && (
@@ -358,6 +576,7 @@ export default function BudgetPanel({ tripId }) {
background: 'var(--bg-card)', borderRadius: 16, padding: '20px 16px', background: 'var(--bg-card)', borderRadius: 16, padding: '20px 16px',
border: '1px solid var(--border-primary)', border: '1px solid var(--border-primary)',
boxShadow: '0 2px 12px rgba(0,0,0,0.04)', boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
marginBottom: 16,
}}> }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 16, textAlign: 'center' }}>{t('budget.byCategory')}</div> <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 16, textAlign: 'center' }}>{t('budget.byCategory')}</div>
@@ -386,6 +605,7 @@ export default function BudgetPanel({ tripId }) {
</div> </div>
</div> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>
+775
View File
@@ -0,0 +1,775 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import ReactDOM from 'react-dom'
import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react'
import { collabApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
// ── Twemoji helper (Apple-style emojis via CDN) ──
function emojiToCodepoint(emoji) {
const codepoints = []
for (const c of emoji) {
const cp = c.codePointAt(0)
if (cp !== 0xfe0f) codepoints.push(cp.toString(16)) // skip variation selector
}
return codepoints.join('-')
}
function TwemojiImg({ emoji, size = 20, style = {} }) {
const cp = emojiToCodepoint(emoji)
const [failed, setFailed] = useState(false)
if (failed) {
return <span style={{ fontSize: size, lineHeight: 1, display: 'inline-block', verticalAlign: 'middle', ...style }}>{emoji}</span>
}
return (
<img
src={`https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/${cp}.png`}
alt={emoji}
draggable={false}
style={{ width: size, height: size, display: 'inline-block', verticalAlign: 'middle', ...style }}
onError={() => setFailed(true)}
/>
)
}
const EMOJI_CATEGORIES = {
'Smileys': ['😀','😂','🥹','😍','🤩','😎','🥳','😭','🤔','👀','🙈','🫠','😴','🤯','🥺','😤','💀','👻','🫡','🤝'],
'Reactions': ['❤️','🔥','👍','👎','👏','🎉','💯','✨','⭐','💪','🙏','😱','😂','💖','💕','🤞','✅','❌','⚡','🏆'],
'Travel': ['✈️','🏖️','🗺️','🧳','🏔️','🌅','🌴','🚗','🚂','🛳️','🏨','🍽️','🍕','🍹','📸','🎒','⛱️','🌍','🗼','🎌'],
}
// SQLite stores UTC without 'Z' suffix — append it so JS parses as UTC
function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) }
function formatTime(isoString, is12h) {
const d = parseUTC(isoString)
const h = d.getHours()
const mm = String(d.getMinutes()).padStart(2, '0')
if (is12h) {
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
return `${h12}:${mm} ${period}`
}
return `${String(h).padStart(2, '0')}:${mm}`
}
function formatDateSeparator(isoString, t) {
const d = parseUTC(isoString)
const now = new Date()
const yesterday = new Date(); yesterday.setDate(now.getDate() - 1)
if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today'
if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday'
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
}
function shouldShowDateSeparator(msg, prevMsg) {
if (!prevMsg) return true
const d1 = parseUTC(msg.created_at).toDateString()
const d2 = parseUTC(prevMsg.created_at).toDateString()
return d1 !== d2
}
/* ── Emoji Picker ── */
function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }) {
const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0])
const ref = useRef(null)
const getPos = () => {
const container = containerRef?.current
const anchor = anchorRef?.current
if (container && anchor) {
const cRect = container.getBoundingClientRect()
const aRect = anchor.getBoundingClientRect()
return { bottom: window.innerHeight - aRect.top + 16, left: cRect.left + cRect.width / 2 - 140 }
}
return { bottom: 80, left: 0 }
}
const pos = getPos()
useEffect(() => {
const close = (e) => {
if (ref.current && ref.current.contains(e.target)) return
if (anchorRef?.current && anchorRef.current.contains(e.target)) return
onClose()
}
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [onClose, anchorRef])
return ReactDOM.createPortal(
<div ref={ref} style={{
position: 'fixed', bottom: pos.bottom, left: pos.left, zIndex: 10000,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
boxShadow: '0 8px 32px rgba(0,0,0,0.18)', width: 280, overflow: 'hidden',
}}>
{/* Category tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-faint)', padding: '6px 8px', gap: 2 }}>
{Object.keys(EMOJI_CATEGORIES).map(c => (
<button key={c} onClick={() => setCat(c)} style={{
flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer',
background: cat === c ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-primary)', fontSize: 10, fontWeight: 600, fontFamily: 'inherit',
}}>
{c}
</button>
))}
</div>
{/* Emoji grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(10, 1fr)', gap: 2, padding: 8 }}>
{EMOJI_CATEGORIES[cat].map((emoji, i) => (
<button key={i} onClick={() => onSelect(emoji)} style={{
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: 'none', cursor: 'pointer', borderRadius: 6,
padding: 2, transition: 'transform 0.1s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.transform = 'scale(1.2)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.transform = 'scale(1)' }}
>
<TwemojiImg emoji={emoji} size={20} />
</button>
))}
</div>
</div>,
document.body
)
}
/* ── Reaction Quick Menu (right-click) ── */
const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
function ReactionMenu({ x, y, onReact, onClose }) {
const ref = useRef(null)
useEffect(() => {
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() }
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [onClose])
// Clamp to viewport
const menuWidth = 156
const clampedLeft = Math.max(menuWidth / 2 + 8, Math.min(x, window.innerWidth - menuWidth / 2 - 8))
return (
<div ref={ref} style={{
position: 'fixed', top: y - 80, left: clampedLeft, transform: 'translateX(-50%)', zIndex: 10000,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '6px 8px',
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, width: menuWidth,
}}>
{QUICK_REACTIONS.map(emoji => (
<button key={emoji} onClick={() => onReact(emoji)} style={{
width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: 'none', cursor: 'pointer', borderRadius: '50%',
padding: 3, transition: 'transform 0.1s, background 0.1s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'none' }}
>
<TwemojiImg emoji={emoji} size={18} />
</button>
))}
</div>
)
}
/* ── Message Text with clickable URLs ── */
function MessageText({ text }) {
const parts = text.split(URL_REGEX)
const urls = text.match(URL_REGEX) || []
const result = []
parts.forEach((part, i) => {
if (part) result.push(part)
if (urls[i]) result.push(
<a key={i} href={urls[i]} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85 }}>
{urls[i]}
</a>
)
})
return <>{result}</>
}
/* ── Link Preview ── */
const URL_REGEX = /https?:\/\/[^\s<>"']+/g
const previewCache = {}
function LinkPreview({ url, tripId, own, onLoad }) {
const [data, setData] = useState(previewCache[url] || null)
const [loading, setLoading] = useState(!previewCache[url])
useEffect(() => {
if (previewCache[url]) return
collabApi.linkPreview(tripId, url).then(d => {
previewCache[url] = d
setData(d)
setLoading(false)
if (d?.title || d?.description || d?.image) onLoad?.()
}).catch(() => setLoading(false))
}, [url, tripId])
if (loading || !data || (!data.title && !data.description && !data.image)) return null
const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return '' } })()
return (
<a href={url} target="_blank" rel="noopener noreferrer" style={{
display: 'block', textDecoration: 'none', marginTop: 6, borderRadius: 12, overflow: 'hidden',
border: own ? '1px solid rgba(255,255,255,0.15)' : '1px solid var(--border-faint)',
background: own ? 'rgba(255,255,255,0.1)' : 'var(--bg-secondary)',
maxWidth: 280, transition: 'opacity 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
{data.image && (
<img src={data.image} alt="" style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block' }}
onError={e => e.target.style.display = 'none'} />
)}
<div style={{ padding: '8px 10px' }}>
{domain && (
<div style={{ fontSize: 10, fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}>
{data.site_name || domain}
</div>
)}
{data.title && (
<div style={{ fontSize: 12, fontWeight: 600, color: own ? '#fff' : 'var(--text-primary)', lineHeight: 1.3, marginBottom: 2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{data.title}
</div>
)}
{data.description && (
<div style={{ fontSize: 11, color: own ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{data.description}
</div>
)}
</div>
</a>
)
}
/* ── Reaction Badge with NOMAD tooltip ── */
function ReactionBadge({ reaction, currentUserId, onReact }) {
const [hover, setHover] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const ref = useRef(null)
const names = reaction.users.map(u => u.username).join(', ')
return (
<>
<button ref={ref} onClick={onReact}
onMouseEnter={() => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect()
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
}
setHover(true)
}}
onMouseLeave={() => setHover(false)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '1px 3px',
borderRadius: 99, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
background: 'transparent', transition: 'transform 0.1s',
}}
>
<TwemojiImg emoji={reaction.emoji} size={16} />
{reaction.count > 1 && <span style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>}
</button>
{hover && names && ReactDOM.createPortal(
<div style={{
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
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)',
}}>
{names}
</div>,
document.body
)}
</>
)
}
/* ── Main Component ── */
export default function CollabChat({ tripId, currentUser }) {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const [messages, setMessages] = useState([])
const [loading, setLoading] = useState(true)
const [hasMore, setHasMore] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [text, setText] = useState('')
const [replyTo, setReplyTo] = useState(null)
const [hoveredId, setHoveredId] = useState(null)
const [sending, setSending] = useState(false)
const [showEmoji, setShowEmoji] = useState(false)
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
const [deletingIds, setDeletingIds] = useState(new Set())
const containerRef = useRef(null)
const messagesRef = useRef(messages)
messagesRef.current = messages
const scrollRef = useRef(null)
const textareaRef = useRef(null)
const emojiBtnRef = useRef(null)
const isAtBottom = useRef(true)
const scrollToBottom = useCallback((behavior = 'auto') => {
const el = scrollRef.current
if (!el) return
requestAnimationFrame(() => el.scrollTo({ top: el.scrollHeight, behavior }))
}, [])
const checkAtBottom = useCallback(() => {
const el = scrollRef.current
if (!el) return
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48
}, [])
/* ── load messages ── */
useEffect(() => {
let cancelled = false
setLoading(true)
collabApi.getMessages(tripId).then(data => {
if (cancelled) return
const msgs = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
setMessages(msgs)
setHasMore(msgs.length >= 100)
setLoading(false)
setTimeout(() => scrollToBottom(), 30)
}).catch(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [tripId, scrollToBottom])
/* ── load more ── */
const handleLoadMore = useCallback(async () => {
if (loadingMore || messages.length === 0) return
setLoadingMore(true)
const el = scrollRef.current
const prevHeight = el ? el.scrollHeight : 0
try {
const data = await collabApi.getMessages(tripId, messages[0]?.id)
const older = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
if (older.length === 0) { setHasMore(false) }
else {
setMessages(prev => [...older, ...prev])
setHasMore(older.length >= 100)
requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight })
}
} catch {} finally { setLoadingMore(false) }
}, [tripId, loadingMore, messages])
/* ── websocket ── */
useEffect(() => {
const handler = (event) => {
if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) {
setMessages(prev => prev.some(m => m.id === event.message.id) ? prev : [...prev, event.message])
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 30)
}
if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) {
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, _deleted: true } : m))
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50)
}
if (event.type === 'collab:message:reacted' && String(event.tripId) === String(tripId)) {
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, reactions: event.reactions } : m))
}
}
addListener(handler)
return () => removeListener(handler)
}, [tripId, scrollToBottom])
/* ── auto-resize textarea ── */
const handleTextChange = useCallback((e) => {
setText(e.target.value)
const ta = textareaRef.current
if (ta) {
ta.style.height = 'auto'
const h = Math.min(ta.scrollHeight, 100)
ta.style.height = h + 'px'
ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden'
}
}, [])
/* ── send ── */
const handleSend = useCallback(async () => {
const body = text.trim()
if (!body || sending) return
setSending(true)
try {
const payload = { text: body }
if (replyTo) payload.reply_to = replyTo.id
const data = await collabApi.sendMessage(tripId, payload)
if (data?.message) {
setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
}
setText(''); setReplyTo(null); setShowEmoji(false)
if (textareaRef.current) textareaRef.current.style.height = 'auto'
isAtBottom.current = true
setTimeout(() => scrollToBottom('smooth'), 50)
} catch {} finally { setSending(false) }
}, [text, sending, replyTo, tripId, scrollToBottom])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
}, [handleSend])
const handleDelete = useCallback(async (msgId) => {
const msg = messages.find(m => m.id === msgId)
requestAnimationFrame(() => {
setDeletingIds(prev => new Set(prev).add(msgId))
})
setTimeout(async () => {
try {
await collabApi.deleteMessage(tripId, msgId)
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
} catch {}
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
}, 400)
}, [tripId])
const handleReact = useCallback(async (msgId, emoji) => {
setReactMenu(null)
try {
const data = await collabApi.reactMessage(tripId, msgId, emoji)
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m))
} catch {}
}, [tripId])
const handleEmojiSelect = useCallback((emoji) => {
setText(prev => prev + emoji)
textareaRef.current?.focus()
}, [])
const isOwn = (msg) => String(msg.user_id) === String(currentUser.id)
// Check if message is only emoji (1-3 emojis, no other text)
const isEmojiOnly = (text) => {
const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}[\uFE0F]?(?:\u200D\p{Extended_Pictographic}[\uFE0F]?)*){1,3}$/u
return emojiRegex.test(text.trim())
}
/* ── Loading ── */
if (loading) {
return (
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 24, height: 24, border: '2px solid var(--border-faint)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin .7s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
/* ── Main ── */
return (
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
{/* Messages */}
{messages.length === 0 ? (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32 }}>
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
<span style={{ fontSize: 12, opacity: 0.6 }}>{t('collab.chat.emptyDesc') || ''}</span>
</div>
) : (
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
display: 'flex', flexDirection: 'column', gap: 1,
}}>
{hasMore && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}>
<button onClick={handleLoadMore} disabled={loadingMore} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600,
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
}}>
<ChevronUp size={13} />
{loadingMore ? '...' : t('collab.chat.loadMore')}
</button>
</div>
)}
{messages.map((msg, idx) => {
const own = isOwn(msg)
const prevMsg = messages[idx - 1]
const nextMsg = messages[idx + 1]
const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id)
const isLastInGroup = !nextMsg || String(nextMsg?.user_id) !== String(msg.user_id)
const showDate = shouldShowDateSeparator(msg, prevMsg)
const showAvatar = !own && isLastInGroup
const bigEmoji = isEmojiOnly(msg.text)
const hasReply = msg.reply_text || msg.reply_to
// Deleted message placeholder
if (msg._deleted) {
return (
<React.Fragment key={msg.id}>
{showDate && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
{formatDateSeparator(msg.created_at, t)}
</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontStyle: 'italic' }}>
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)}
</span>
</div>
</React.Fragment>
)
}
// Bubble border radius — iMessage style tails
const br = own
? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px`
: `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}`
return (
<React.Fragment key={msg.id}>
{/* Date separator */}
{showDate && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
<span style={{
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99,
letterSpacing: 0.3, textTransform: 'uppercase',
}}>
{formatDateSeparator(msg.created_at, t)}
</span>
</div>
)}
<div style={{
display: 'flex', alignItems: own ? 'flex-end' : 'flex-start',
flexDirection: own ? 'row-reverse' : 'row',
gap: 6, marginTop: isNewGroup ? 10 : 1,
paddingLeft: own ? 40 : 0, paddingRight: own ? 0 : 40,
transition: 'transform 0.3s ease, opacity 0.3s ease, max-height 0.3s ease',
...(deletingIds.has(msg.id) ? { transform: 'scale(0.3)', opacity: 0, maxHeight: 0, marginTop: 0, overflow: 'hidden' } : {}),
}}>
{/* Avatar slot for others */}
{!own && (
<div style={{ width: 28, flexShrink: 0, alignSelf: 'flex-end' }}>
{showAvatar && (
msg.user_avatar ? (
<img src={msg.user_avatar} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
) : (
<div style={{
width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11, fontWeight: 700, color: 'var(--text-muted)',
}}>
{(msg.username || '?')[0].toUpperCase()}
</div>
)
)}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: own ? 'flex-end' : 'flex-start', maxWidth: '78%', minWidth: 0 }}>
{/* Username for others at group start */}
{!own && isNewGroup && (
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
{msg.username}
</span>
)}
{/* Bubble */}
<div
style={{ position: 'relative' }}
onMouseEnter={() => setHoveredId(msg.id)}
onMouseLeave={() => setHoveredId(null)}
onContextMenu={e => { e.preventDefault(); setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
onTouchEnd={e => {
const now = Date.now()
const lastTap = e.currentTarget.dataset.lastTap || 0
if (now - lastTap < 300) {
e.preventDefault()
const touch = e.changedTouches?.[0]
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
}
e.currentTarget.dataset.lastTap = now
}}
>
{bigEmoji ? (
<div style={{ fontSize: 40, lineHeight: 1.2, padding: '2px 0' }}>
{msg.text}
</div>
) : (
<div style={{
background: own ? '#007AFF' : 'var(--bg-secondary)',
color: own ? '#fff' : 'var(--text-primary)',
borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
fontSize: 14, lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
}}>
{/* Inline reply quote */}
{hasReply && (
<div style={{
padding: '5px 10px', marginBottom: 4, borderRadius: 12,
background: own ? 'rgba(255,255,255,0.15)' : 'var(--bg-tertiary)',
fontSize: 12, lineHeight: 1.3,
}}>
<div style={{ fontWeight: 600, fontSize: 11, opacity: 0.7, marginBottom: 1 }}>
{msg.reply_username || ''}
</div>
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{(msg.reply_text || '').slice(0, 80)}
</div>
</div>
)}
{hasReply ? (
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
) : <MessageText text={msg.text} />}
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
))}
</div>
)}
{/* Hover actions */}
<div style={{
position: 'absolute', top: -14,
display: 'flex', gap: 2,
opacity: hoveredId === msg.id ? 1 : 0,
pointerEvents: hoveredId === msg.id ? 'auto' : 'none',
transition: 'opacity .1s',
...(own ? { left: -6 } : { right: -6 }),
}}>
<button onClick={() => setReplyTo(msg)} title="Reply" style={{
width: 24, height: 24, borderRadius: '50%', border: 'none',
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)' }}
>
<Reply size={11} />
</button>
{own && (
<button onClick={() => handleDelete(msg.id)} title="Delete" style={{
width: 24, height: 24, borderRadius: '50%', border: 'none',
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s, background 0.15s, color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = '#ef4444'; e.currentTarget.style.color = '#fff' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
>
<Trash2 size={11} />
</button>
)}
</div>
</div>
{/* Reactions — iMessage style floating badge */}
{msg.reactions?.length > 0 && (
<div style={{
display: 'flex', gap: 3, marginTop: -6, marginBottom: 4,
justifyContent: own ? 'flex-end' : 'flex-start',
paddingLeft: own ? 0 : 8, paddingRight: own ? 8 : 0,
position: 'relative', zIndex: 1,
}}>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '3px 6px',
borderRadius: 99, background: 'var(--bg-card)',
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
}}>
{msg.reactions.map(r => {
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
return (
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => handleReact(msg.id, r.emoji)} />
)
})}
</div>
</div>
)}
{/* Timestamp — only on last message of group */}
{isLastInGroup && (
<span style={{ fontSize: 9, color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
{formatTime(msg.created_at, is12h)}
</span>
)}
</div>
</div>
</React.Fragment>
)
})}
</div>
)}
{/* Composer */}
<div style={{ flexShrink: 0, padding: '8px 12px calc(12px + env(safe-area-inset-bottom, 0px))', borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }}>
{/* Reply preview */}
{replyTo && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8,
padding: '6px 10px', borderRadius: 10, background: 'var(--bg-secondary)',
borderLeft: '3px solid #007AFF', fontSize: 12, color: 'var(--text-muted)',
}}>
<Reply size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
<strong>{replyTo.username}</strong>: {(replyTo.text || '').slice(0, 60)}
</span>
<button onClick={() => setReplyTo(null)} style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)',
display: 'flex', flexShrink: 0,
}}>
<X size={14} />
</button>
</div>
)}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
{/* Emoji button */}
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
}}>
<Smile size={20} />
</button>
<textarea
ref={textareaRef}
rows={1}
style={{
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
maxHeight: 100, overflowY: 'hidden',
}}
placeholder={t('collab.chat.placeholder')}
value={text}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
/>
{/* Send */}
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
transition: 'background 0.15s',
}}>
<ArrowUp size={18} strokeWidth={2.5} />
</button>
</div>
</div>
{/* Emoji picker */}
{showEmoji && <EmojiPicker onSelect={handleEmojiSelect} onClose={() => setShowEmoji(false)} anchorRef={emojiBtnRef} containerRef={containerRef} />}
{/* Reaction quick menu (right-click) */}
{reactMenu && ReactDOM.createPortal(
<ReactionMenu x={reactMenu.x} y={reactMenu.y} onReact={(emoji) => handleReact(reactMenu.msgId, emoji)} onClose={() => setReactMenu(null)} />,
document.body
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
import React, { useState, useEffect } from 'react'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
import CollabChat from './CollabChat'
import CollabNotes from './CollabNotes'
import CollabPolls from './CollabPolls'
import WhatsNextWidget from './WhatsNextWidget'
function useIsDesktop(breakpoint = 1024) {
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint)
useEffect(() => {
const check = () => setIsDesktop(window.innerWidth >= breakpoint)
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [breakpoint])
return isDesktop
}
const card = {
display: 'flex', flexDirection: 'column',
background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-faint)',
overflow: 'hidden', minHeight: 0,
}
export default function CollabPanel({ tripId, tripMembers = [] }) {
const { user } = useAuthStore()
const { t } = useTranslation()
const [mobileTab, setMobileTab] = useState('chat')
const isDesktop = useIsDesktop()
const tabs = [
{ id: 'chat', label: t('collab.tabs.chat') || 'Chat', icon: MessageCircle },
{ id: 'notes', label: t('collab.tabs.notes') || 'Notes', icon: StickyNote },
{ id: 'polls', label: t('collab.tabs.polls') || 'Polls', icon: BarChart3 },
{ id: 'next', label: t('collab.whatsNext.title') || "What's Next", icon: Sparkles },
]
if (isDesktop) {
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
{/* Chat — left, fixed width */}
<div style={{ ...card, flex: '0 0 380px' }}>
<CollabChat tripId={tripId} currentUser={user} />
</div>
{/* Right column: Notes top, Polls + What's Next bottom */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
{/* Notes — top */}
<div style={{ ...card, flex: 1 }}>
<CollabNotes tripId={tripId} currentUser={user} />
</div>
{/* Polls + What's Next — bottom row */}
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
<div style={{ ...card, flex: 1 }}>
<CollabPolls tripId={tripId} currentUser={user} />
</div>
<div style={{ ...card, flex: 1 }}>
<WhatsNextWidget tripMembers={tripMembers} />
</div>
</div>
</div>
</div>
)
}
// Mobile: tab bar + single panel
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'absolute', inset: 0 }}>
<div style={{
display: 'flex', gap: 2, padding: '8px 12px', borderBottom: '1px solid var(--border-faint)',
background: 'var(--bg-card)', flexShrink: 0,
}}>
{tabs.map(tab => {
const Icon = tab.icon
const active = mobileTab === tab.id
return (
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
background: active ? 'var(--accent)' : 'transparent',
color: active ? 'var(--accent-text)' : 'var(--text-muted)',
fontSize: 11, fontWeight: 600, fontFamily: 'inherit',
transition: 'all 0.15s',
}}>
{tab.label}
</button>
)
})}
</div>
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
{mobileTab === 'chat' && <CollabChat tripId={tripId} currentUser={user} />}
{mobileTab === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{mobileTab === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{mobileTab === 'next' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
</div>
)
}
@@ -0,0 +1,422 @@
import React, { useState, useEffect, useCallback } from 'react'
import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import ReactDOM from 'react-dom'
const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
function timeRemaining(deadline) {
if (!deadline) return null
const diff = new Date(deadline).getTime() - Date.now()
if (diff <= 0) return null
const mins = Math.floor(diff / 60000)
const hrs = Math.floor(mins / 60)
const days = Math.floor(hrs / 24)
if (days > 0) return `${days}d ${hrs % 24}h`
if (hrs > 0) return `${hrs}h ${mins % 60}m`
return `${mins}m`
}
function isExpired(deadline) {
if (!deadline) return false
return new Date(deadline).getTime() <= Date.now()
}
function totalVotes(poll) {
return (poll.options || []).reduce((s, o) => s + (o.voters?.length || 0), 0)
}
// ── Create Poll Modal ────────────────────────────────────────────────────────
function CreatePollModal({ onClose, onCreate, t }) {
const [question, setQuestion] = useState('')
const [options, setOptions] = useState(['', ''])
const [multiChoice, setMultiChoice] = useState(false)
const [submitting, setSubmitting] = useState(false)
const addOption = () => setOptions(prev => [...prev, ''])
const removeOption = (i) => setOptions(prev => prev.filter((_, j) => j !== i))
const updateOption = (i, v) => setOptions(prev => prev.map((o, j) => j === i ? v : o))
const canSubmit = question.trim() && options.filter(o => o.trim()).length >= 2 && !submitting
const handleSubmit = async (e) => {
e.preventDefault()
if (!canSubmit) return
setSubmitting(true)
try {
await onCreate({ question: question.trim(), options: options.filter(o => o.trim()), multiple_choice: multiChoice })
onClose()
} catch {} finally { setSubmitting(false) }
}
return ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={onClose}>
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onClick={e => e.stopPropagation()} onSubmit={handleSubmit}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.polls.new')}</h3>
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
</div>
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Question */}
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div>
<input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder') || 'Ask a question...'} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
</div>
{/* Options */}
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.options')}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{options.map((opt, i) => (
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`}
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
{options.length > 2 && (
<button type="button" onClick={() => removeOption(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={14} /></button>
)}
</div>
))}
<button type="button" onClick={addOption} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 12px', borderRadius: 10, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 12, fontFamily: FONT }}>
<Plus size={12} /> {t('collab.polls.addOption')}
</button>
</div>
</div>
{/* Multi choice toggle */}
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<div onClick={() => setMultiChoice(!multiChoice)} style={{
width: 36, height: 20, borderRadius: 10, padding: 2, cursor: 'pointer',
background: multiChoice ? '#007AFF' : 'var(--border-primary)', transition: 'background 0.2s',
display: 'flex', alignItems: 'center',
}}>
<div style={{ width: 16, height: 16, borderRadius: '50%', background: '#fff', transition: 'transform 0.2s', transform: multiChoice ? 'translateX(16px)' : 'translateX(0)' }} />
</div>
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontFamily: FONT }}>{t('collab.polls.multiChoice')}</span>
</label>
{/* Submit */}
<button type="submit" disabled={!canSubmit} style={{
width: '100%', borderRadius: 99, padding: '9px 14px', background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)', fontSize: 13, fontWeight: 600, border: 'none', cursor: canSubmit ? 'pointer' : 'default', fontFamily: FONT,
}}>
{submitting ? '...' : t('collab.polls.create')}
</button>
</div>
</form>
</div>,
document.body
)
}
// ── Voter Chip with custom tooltip ────────────────────────────────────────────
function VoterChip({ voter, offset }) {
const [hover, setHover] = useState(false)
const ref = React.useRef(null)
const [pos, setPos] = useState({ top: 0, left: 0 })
return (
<>
<div ref={ref}
onMouseEnter={() => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect()
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
}
setHover(true)
}}
onMouseLeave={() => setHover(false)}
style={{
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 7, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden',
border: '1.5px solid var(--bg-card)', marginLeft: offset ? -5 : 0, flexShrink: 0,
}}>
{voter.avatar_url ? <img src={voter.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (voter.username || '?')[0].toUpperCase()}
</div>
{hover && ReactDOM.createPortal(
<div style={{
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
background: 'var(--bg-card)', color: 'var(--text-primary)',
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)',
}}>
{voter.username}
</div>,
document.body
)}
</>
)
}
// ── Poll Card ────────────────────────────────────────────────────────────────
function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }) {
const total = totalVotes(poll)
const isClosed = poll.is_closed || isExpired(poll.deadline)
const remaining = timeRemaining(poll.deadline)
const hasVoted = (poll.options || []).some(o => (o.voters || []).some(v => String(v.user_id) === String(currentUser.id)))
return (
<div style={{
borderRadius: 14, border: '1px solid var(--border-faint)', overflow: 'hidden',
background: 'var(--bg-card)', fontFamily: FONT,
}}>
{/* Header */}
<div style={{
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 8,
background: isClosed ? 'var(--bg-secondary)' : 'transparent',
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}>
{poll.question}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
{isClosed && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
<Lock size={8} /> {t('collab.polls.closed')}
</span>
)}
{remaining && !isClosed && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, fontWeight: 600, color: '#f59e0b', background: '#f59e0b18', padding: '2px 7px', borderRadius: 99 }}>
<Clock size={8} /> {remaining}
</span>
)}
{poll.multiple_choice && (
<span style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
{t('collab.polls.multiChoice')}
</span>
)}
<span style={{ fontSize: 9, color: 'var(--text-faint)' }}>
{total} {total === 1 ? 'vote' : 'votes'}
</span>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{!isClosed && (
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Lock size={12} />
</button>
)}
<button onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={12} />
</button>
</div>
</div>
{/* Options */}
<div style={{ padding: '4px 12px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{(poll.options || []).map((opt, idx) => {
const count = opt.voters?.length || 0
const pct = total > 0 ? Math.round((count / total) * 100) : 0
const myVote = (opt.voters || []).some(v => String(v.user_id) === String(currentUser.id))
const isWinner = isClosed && count === Math.max(...(poll.options || []).map(o => o.voters?.length || 0)) && count > 0
return (
<button key={idx} onClick={() => !isClosed && onVote(poll.id, idx)}
disabled={isClosed}
style={{
position: 'relative', display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 12px', borderRadius: 10, border: 'none', cursor: isClosed ? 'default' : 'pointer',
background: 'var(--bg-secondary)', fontFamily: FONT, textAlign: 'left', width: '100%',
overflow: 'hidden', transition: 'transform 0.1s',
}}
onMouseEnter={e => { if (!isClosed) e.currentTarget.style.transform = 'scale(1.01)' }}
onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'}
>
{/* Progress bar background */}
<div style={{
position: 'absolute', left: 0, top: 0, bottom: 0,
width: `${pct}%`, borderRadius: 10,
background: myVote ? '#007AFF20' : isWinner ? '#10b98118' : 'var(--bg-tertiary)',
transition: 'width 0.4s ease',
}} />
{/* Check circle */}
<div style={{
width: 20, height: 20, borderRadius: '50%', flexShrink: 0, position: 'relative',
border: myVote ? '2px solid #007AFF' : '2px solid var(--border-primary)',
background: myVote ? '#007AFF' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 0.2s',
}}>
{myVote && <Check size={11} color="#fff" strokeWidth={3} />}
</div>
{/* Label */}
<span style={{
flex: 1, fontSize: 13, fontWeight: myVote || isWinner ? 600 : 400,
color: 'var(--text-primary)', position: 'relative', zIndex: 1,
}}>
{typeof opt === 'string' ? opt : opt.label || opt}
</span>
{/* Voter avatars */}
{(opt.voters || []).length > 0 && (hasVoted || isClosed) && (
<div style={{ display: 'flex', position: 'relative', zIndex: 1 }}>
{(opt.voters || []).slice(0, 3).map((v, vi) => (
<VoterChip key={v.user_id || vi} voter={v} offset={vi > 0} />
))}
</div>
)}
{/* Percentage */}
{(hasVoted || isClosed) && (
<span style={{
fontSize: 12, fontWeight: 700, color: myVote ? '#007AFF' : 'var(--text-muted)',
position: 'relative', zIndex: 1, minWidth: 32, textAlign: 'right',
}}>
{pct}%
</span>
)}
</button>
)
})}
</div>
</div>
)
}
// ── Main Component ───────────────────────────────────────────────────────────
export default function CollabPolls({ tripId, currentUser }) {
const { t } = useTranslation()
const [polls, setPolls] = useState([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
useEffect(() => {
collabApi.getPolls(tripId).then(data => {
setPolls(Array.isArray(data) ? data : data.polls || [])
}).catch(() => {}).finally(() => setLoading(false))
}, [tripId])
// WebSocket
useEffect(() => {
const handler = (msg) => {
if (!msg?.type) return
if (msg.type === 'collab:poll:created' && msg.poll) {
setPolls(prev => prev.some(p => p.id === msg.poll.id) ? prev : [msg.poll, ...prev])
}
if (msg.type === 'collab:poll:voted' && msg.poll) {
setPolls(prev => prev.map(p => p.id === msg.poll.id ? msg.poll : p))
}
if (msg.type === 'collab:poll:closed' && msg.poll) {
setPolls(prev => prev.map(p => p.id === msg.poll.id ? { ...p, ...msg.poll, is_closed: true } : p))
}
if (msg.type === 'collab:poll:deleted') {
const id = msg.pollId || msg.poll?.id
if (id) setPolls(prev => prev.filter(p => p.id !== id))
}
}
addListener(handler)
return () => removeListener(handler)
}, [])
const handleCreate = useCallback(async (data) => {
const result = await collabApi.createPoll(tripId, data)
const created = result.poll || result
setPolls(prev => prev.some(p => p.id === created.id) ? prev : [created, ...prev])
setShowForm(false)
}, [tripId])
const handleVote = useCallback(async (pollId, optionIndex) => {
try {
const result = await collabApi.votePoll(tripId, pollId, optionIndex)
const updated = result.poll || result
setPolls(prev => prev.map(p => p.id === updated.id ? updated : p))
} catch {}
}, [tripId])
const handleClose = useCallback(async (pollId) => {
try {
await collabApi.closePoll(tripId, pollId)
setPolls(prev => prev.map(p => p.id === pollId ? { ...p, is_closed: true } : p))
} catch {}
}, [tripId])
const handleDelete = useCallback(async (pollId) => {
try {
await collabApi.deletePoll(tripId, pollId)
setPolls(prev => prev.filter(p => p.id !== pollId))
} catch {}
}, [tripId])
const activePolls = polls.filter(p => !p.is_closed && !isExpired(p.deadline))
const closedPolls = polls.filter(p => p.is_closed || isExpired(p.deadline))
// Deadline ticker
const [, setTick] = useState(0)
useEffect(() => {
if (!polls.some(p => p.deadline && !p.is_closed)) return
const iv = setInterval(() => setTick(t => t + 1), 30000)
return () => clearInterval(iv)
}, [polls])
if (loading) {
return (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: FONT }}>
<div style={{ width: 20, height: 20, border: '2px solid var(--border-primary)', borderTopColor: 'var(--text-primary)', borderRadius: '50%', animation: 'collab-poll-spin 0.7s linear infinite' }} />
<style>{`@keyframes collab-poll-spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
<h3 style={{ margin: 0, fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase' }}>
<BarChart3 size={14} color="var(--text-faint)" />
{t('collab.polls.title')}
</h3>
<button onClick={() => setShowForm(true)} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
fontFamily: FONT, border: 'none', cursor: 'pointer',
}}>
<Plus size={12} /> {t('collab.polls.new')}
</button>
</div>
{/* Content */}
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 12px 12px' }}>
{polls.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '48px 20px', textAlign: 'center', height: '100%' }}>
<BarChart3 size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.polls.empty')}</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.polls.emptyHint')}</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{activePolls.length > 0 && activePolls.map(poll => (
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
))}
{closedPolls.length > 0 && (
<>
{activePolls.length > 0 && (
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, padding: '8px 0 2px' }}>
{t('collab.polls.closedSection') || 'Closed'}
</div>
)}
{closedPolls.map(poll => (
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
))}
</>
)}
</div>
)}
</div>
{/* Create Modal */}
{showForm && <CreatePollModal onClose={() => setShowForm(false)} onCreate={handleCreate} t={t} />}
</div>
)
}
@@ -0,0 +1,189 @@
import React, { useMemo } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { MapPin, Clock, Calendar, Users, Sparkles } from 'lucide-react'
function formatTime(timeStr, is12h) {
if (!timeStr) return ''
const [h, m] = timeStr.split(':').map(Number)
if (is12h) {
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
return `${h12}:${String(m).padStart(2, '0')} ${period}`
}
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
function formatDayLabel(date, t, locale) {
const d = new Date(date + 'T00:00:00')
const now = new Date()
const tomorrow = new Date(); tomorrow.setDate(now.getDate() + 1)
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today'
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short' })
}
export default function WhatsNextWidget({ tripMembers = [] }) {
const { days, assignments } = useTripStore()
const { t, locale } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const upcoming = useMemo(() => {
const now = new Date()
const nowDate = now.toISOString().split('T')[0]
const nowTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
const items = []
for (const day of (days || [])) {
if (!day.date) continue
const dayAssignments = assignments[String(day.id)] || []
for (const a of dayAssignments) {
if (!a.place) continue
// Include: today (future times) + all future days
const isFutureDay = day.date > nowDate
const isTodayFuture = day.date === nowDate && (!a.place.place_time || a.place.place_time >= nowTime)
if (isFutureDay || isTodayFuture) {
items.push({
id: a.id,
name: a.place.name,
time: a.place.place_time,
endTime: a.place.end_time,
date: day.date,
dayTitle: day.title,
category: a.place.category,
participants: (a.participants && a.participants.length > 0)
? a.participants
: tripMembers.map(m => ({ user_id: m.id, username: m.username, avatar: m.avatar })),
address: a.place.address,
})
}
}
}
items.sort((a, b) => {
const da = a.date + (a.time || '99:99')
const db = b.date + (b.time || '99:99')
return da.localeCompare(db)
})
return items.slice(0, 8)
}, [days, assignments, tripMembers])
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<div style={{
padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 7, flexShrink: 0,
}}>
<Sparkles size={14} color="var(--text-faint)" />
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.3, textTransform: 'uppercase' }}>
{t('collab.whatsNext.title') || "What's Next"}
</span>
</div>
{/* List */}
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 10px' }}>
{upcoming.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: '48px 20px', textAlign: 'center' }}>
<Calendar size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.whatsNext.empty')}</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{upcoming.map((item, idx) => {
const prevItem = upcoming[idx - 1]
const showDayHeader = !prevItem || prevItem.date !== item.date
return (
<React.Fragment key={item.id}>
{showDayHeader && (
<div style={{
fontSize: 10, fontWeight: 500, color: 'var(--text-faint)',
textTransform: 'uppercase', letterSpacing: 0.5,
padding: idx === 0 ? '0 4px 4px' : '8px 4px 4px',
}}>
{formatDayLabel(item.date, t, locale)}
{item.dayTitle ? `${item.dayTitle}` : ''}
</div>
)}
<div style={{
display: 'flex', gap: 10, padding: '8px 10px', borderRadius: 10,
background: 'var(--bg-secondary)', transition: 'background 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
>
{/* Time column */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 44, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
{item.time ? formatTime(item.time, is12h) : 'TBD'}
</span>
{item.endTime && (
<>
<span style={{ fontSize: 7, color: 'var(--text-faint)', fontWeight: 600, letterSpacing: 0.3, margin: '2px 0', textTransform: 'uppercase' }}>
{t('collab.whatsNext.until') || 'bis'}
</span>
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
{formatTime(item.endTime, is12h)}
</span>
</>
)}
</div>
{/* Divider */}
<div style={{ width: 1, alignSelf: 'stretch', background: 'var(--border-faint)', flexShrink: 0, margin: '2px 0' }} />
{/* Details */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.name}
</div>
{item.address && (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 2 }}>
<MapPin size={9} color="var(--text-faint)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.address}
</span>
</div>
)}
{/* Participants */}
{item.participants.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 5 }}>
{item.participants.map(p => (
<div key={p.user_id} style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 3px',
borderRadius: 99, background: 'var(--bg-tertiary)', border: '1px solid var(--border-faint)',
}}>
<div style={{
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-secondary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 7, fontWeight: 700, color: 'var(--text-muted)',
overflow: 'hidden', flexShrink: 0,
}}>
{p.avatar
? <img src={`/uploads/avatars/${p.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: p.username?.[0]?.toUpperCase()
}
</div>
<span style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)' }}>{p.username}</span>
</div>
))}
</div>
)}
</div>
</div>
</React.Fragment>
)
})}
</div>
)}
</div>
</div>
)
}
@@ -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)
+48 -10
View File
@@ -1,6 +1,7 @@
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, StickyNote } from 'lucide-react'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
@@ -76,7 +77,7 @@ function SourceBadge({ icon: Icon, label }) {
) )
} }
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId }) { export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId, allowedFileTypes }) {
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [filterType, setFilterType] = useState('all') const [filterType, setFilterType] = useState('all')
const [lightboxFile, setLightboxFile] = useState(null) const [lightboxFile, setLightboxFile] = useState(null)
@@ -106,10 +107,28 @@ 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)
if (filterType === 'doc') return (f.mime_type || '').includes('word') || (f.mime_type || '').includes('excel') || (f.mime_type || '').includes('text') if (filterType === 'doc') return (f.mime_type || '').includes('word') || (f.mime_type || '').includes('excel') || (f.mime_type || '').includes('text')
if (filterType === 'collab') return !!f.note_id
return true return true
}) })
@@ -134,18 +153,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 +195,8 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</p> </p>
</object> </object>
</div> </div>
</div> </div>,
document.body
)} )}
{/* Header */} {/* Header */}
@@ -210,6 +230,9 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
<> <>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p> <p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p> <p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
<p style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 6, opacity: 0.7 }}>
{(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
</p>
</> </>
)} )}
</div> </div>
@@ -221,6 +244,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{ id: 'pdf', label: t('files.filterPdf') }, { id: 'pdf', label: t('files.filterPdf') },
{ id: 'image', label: t('files.filterImages') }, { id: 'image', label: t('files.filterImages') },
{ id: 'doc', label: t('files.filterDocs') }, { id: 'doc', label: t('files.filterDocs') },
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
].map(tab => ( ].map(tab => (
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{ <button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12, padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
@@ -251,7 +275,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
const linkedReservation = file.reservation_id const linkedReservation = file.reservation_id
? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title }) ? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title })
: null : null
const fileUrl = file.url || `/uploads/files/${file.filename}` const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
return ( return (
<div key={file.id} style={{ <div key={file.id} style={{
@@ -274,7 +298,15 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
> >
{isImage(file.mime_type) {isImage(file.mime_type)
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> ? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <FileIcon size={16} style={{ color: 'var(--text-muted)' }} /> : (() => {
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
const isPdf = file.mime_type === 'application/pdf'
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
<span style={{ fontSize: 9, fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div>
)
})()
} }
</div> </div>
@@ -303,6 +335,12 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
label={`${t('files.sourceBooking')} · ${linkedReservation.title || t('files.sourceBooking')}`} label={`${t('files.sourceBooking')} · ${linkedReservation.title || t('files.sourceBooking')}`}
/> />
)} )}
{file.note_id && (
<SourceBadge
icon={StickyNote}
label={t('files.sourceCollab') || 'Collab Notes'}
/>
)}
</div> </div>
{file.description && !linkedReservation && ( {file.description && !linkedReservation && (
+7 -6
View File
@@ -5,7 +5,7 @@ import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { 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 }
@@ -18,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) {
@@ -46,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 (
@@ -139,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)'}
+108 -32
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState, useMemo } from 'react'
import ReactDOM from 'react-dom'
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'
@@ -24,7 +25,7 @@ function escAttr(s) {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;') return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
} }
function createPlaceIcon(place, orderNumber, isSelected) { 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
@@ -34,20 +35,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({
@@ -89,19 +93,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.panTo([place.lat, place.lng], { 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
} }
@@ -121,7 +132,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)
@@ -131,9 +142,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,12 +159,58 @@ function MapClickHandler({ onClick }) {
return null return null
} }
// ── Route travel time label ──
function RouteLabel({ midpoint, walkingText, drivingText }) {
const map = useMap()
const [visible, setVisible] = useState(map ? map.getZoom() >= 16 : false)
useEffect(() => {
if (!map) return
const check = () => setVisible(map.getZoom() >= 16)
check()
map.on('zoomend', check)
return () => map.off('zoomend', check)
}, [map])
if (!visible || !midpoint) return null
const icon = L.divIcon({
className: 'route-info-pill',
html: `<div style="
display:flex;align-items:center;gap:5px;
background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);
color:#fff;border-radius:99px;padding:3px 9px;
font-size:9px;font-weight:600;white-space:nowrap;
font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
box-shadow:0 2px 12px rgba(0,0,0,0.3);
pointer-events:none;
position:relative;left:-50%;top:-50%;
">
<span style="display:flex;align-items:center;gap:2px">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="4" r="2"/><path d="M7 21l3-7"/><path d="M10 14l5-5"/><path d="M15 9l-4 7"/><path d="M18 18l-3-7"/></svg>
${walkingText}
</span>
<span style="opacity:0.3">|</span>
<span style="display:flex;align-items:center;gap:2px">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H7L5 10l-2.5 1.1C1.7 11.3 1 12.1 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>
${drivingText}
</span>
</div>`,
iconSize: [0, 0],
iconAnchor: [0, 0],
})
return <Marker position={midpoint} icon={icon} interactive={false} zIndexOffset={2000} />
}
// Module-level photo cache shared with PlaceAvatar // Module-level photo cache shared with PlaceAvatar
const mapPhotoCache = new Map() const mapPhotoCache = new Map()
export function MapView({ export function MapView({
places = [], places = [],
dayPlaces = [],
route = null, route = null,
routeSegments = [],
selectedPlaceId = null, selectedPlaceId = null,
onMarkerClick, onMarkerClick,
onMapClick, onMapClick,
@@ -162,7 +219,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
@@ -200,8 +270,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
@@ -211,6 +281,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
@@ -227,8 +298,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
@@ -272,13 +343,18 @@ export function MapView({
</MarkerClusterGroup> </MarkerClusterGroup>
{route && route.length > 1 && ( {route && route.length > 1 && (
<Polyline <>
positions={route} <Polyline
color="#111827" positions={route}
weight={3} color="#111827"
opacity={0.9} weight={3}
dashArray="6, 5" opacity={0.9}
/> dashArray="6, 5"
/>
{routeSegments.map((seg, i) => (
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
))}
</>
)} )}
</MapContainer> </MapContainer>
) )
+20 -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]
@@ -41,12 +41,17 @@ export async function calculateRoute(waypoints, profile = 'driving') {
duration = route.duration // driving: use OSRM value duration = route.duration // driving: use OSRM value
} }
const walkingDuration = distance / (5000 / 3600) // 5 km/h
const drivingDuration = route.duration // OSRM driving value
return { return {
coordinates, coordinates,
distance, distance,
duration, duration,
distanceText: formatDistance(distance), distanceText: formatDistance(distance),
durationText: formatDuration(duration), durationText: formatDuration(duration),
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(drivingDuration),
} }
} }
@@ -74,20 +79,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 +111,7 @@ function formatDuration(seconds) {
const h = Math.floor(seconds / 3600) const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60) const m = Math.floor((seconds % 3600) / 60)
if (h > 0) { if (h > 0) {
return `${h} Std. ${m} Min.` return `${h} h ${m} min`
} }
return `${m} Min.` return `${m} min`
} }
+1 -6
View File
@@ -144,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)}" />`
@@ -157,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 `
@@ -352,7 +347,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
? `<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>
@@ -447,17 +447,8 @@ export default function PlaceFormModal({
)} )}
</div> </div>
{/* Time & Reservation */} {/* Reservation */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Visit Time</label>
<input
type="time"
value={formData.place_time}
onChange={e => update('place_time', e.target.value)}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation</label> <label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation</label>
<select <select
@@ -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 */}
@@ -128,7 +117,7 @@ export default function AssignedPlaceItem({ assignment, dayId, onRemove, onEdit
<div className="flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"> <div className="flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{onEdit && ( {onEdit && (
<button <button
onClick={() => onEdit(place)} onClick={() => onEdit(place, assignment.id)}
className="p-1 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded transition-colors" className="p-1 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded transition-colors"
title="Edit place" title="Edit place"
> >
@@ -0,0 +1,565 @@
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: '', place_id: null })
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 handleSelectPlace = (placeId) => {
setHotelForm(f => ({ ...f, place_id: placeId }))
}
const handleSaveAccommodation = async () => {
if (!hotelForm.place_id) return
try {
const data = await accommodationsApi.create(tripId, {
place_id: hotelForm.place_id,
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: '', place_id: null })
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 || '', place_id: accommodation.place_id }); setHotelDayRange({ start: accommodation.start_day_id, end: accommodation.end_day_id }); 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 */}
<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>
{/* 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={() => handleSelectPlace(p.id)} style={{
display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '10px 18px',
border: 'none', borderBottom: '1px solid var(--border-faint)',
background: hotelForm.place_id === p.id ? 'var(--bg-hover)' : 'none',
cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
transition: 'background 0.1s',
outline: hotelForm.place_id === p.id ? '2px solid var(--accent)' : 'none',
outlineOffset: -2, borderRadius: hotelForm.place_id === p.id ? 8 : 0,
}}
onMouseEnter={e => { if (hotelForm.place_id !== p.id) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (hotelForm.place_id !== p.id) 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>
{/* Save / Cancel */}
<div style={{ padding: '12px 18px', borderTop: '1px solid var(--border-faint)', display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setShowHotelPicker(false)} style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button onClick={async () => {
if (showHotelPicker === 'edit' && accommodation) {
// Update existing
await accommodationsApi.update(tripId, accommodation.id, {
place_id: hotelForm.place_id,
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,
})
setShowHotelPicker(false)
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
// Reload
accommodationsApi.list(tripId).then(d => {
setAccommodations(d.accommodations || [])
const acc = (d.accommodations || []).find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
setAccommodation(acc || null)
})
onAccommodationChange?.()
} else {
await handleSaveAccommodation()
}
}} disabled={!hotelForm.place_id} style={{
padding: '7px 20px', borderRadius: 8, border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
background: hotelForm.place_id ? 'var(--text-primary)' : 'var(--bg-tertiary)',
color: hotelForm.place_id ? 'var(--bg-card)' : 'var(--text-faint)',
}}>
{t('common.save')}
</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>
)
}
+118 -64
View File
@@ -1,9 +1,12 @@
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, Lock } 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'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import WeatherWidget from '../Weather/WeatherWidget' import WeatherWidget from '../Weather/WeatherWidget'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { getCategoryIcon } from '../shared/categoryIcons' import { getCategoryIcon } from '../shared/categoryIcons'
@@ -21,7 +24,10 @@ function formatDate(dateStr, locale) {
function formatTime(timeStr, locale, timeFormat) { function formatTime(timeStr, locale, timeFormat) {
if (!timeStr) return '' if (!timeStr) return ''
try { try {
const [h, m] = timeStr.split(':').map(Number) const parts = timeStr.split(':')
const h = Number(parts[0]) || 0
const m = Number(parts[1]) || 0
if (isNaN(h)) return timeStr
if (timeFormat === '12h') { if (timeFormat === '12h') {
const period = h >= 12 ? 'PM' : 'AM' const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
@@ -71,29 +77,30 @@ 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, onRemoveAssignment, onEditPlace, onDeletePlace,
reservations = [], reservations = [],
onAddReservation, onAddReservation,
}) { }) {
const toast = useToast() const toast = useToast()
const { t, language, locale } = useTranslation() const { t, language, locale } = useTranslation()
const ctxMenu = useContextMenu()
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(() => {
try {
const saved = sessionStorage.getItem(`day-expanded-${tripId}`)
if (saved) return new Set(JSON.parse(saved))
} catch {}
return 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)
@@ -127,9 +134,20 @@ export default function DayPlanSidebar({
return { placeId, assignmentId: '', noteId: '', fromDayId: 0 } return { placeId, assignmentId: '', noteId: '', fromDayId: 0 }
} }
// Only auto-expand genuinely new days (not on initial load from storage)
const prevDayCount = React.useRef(days.length)
useEffect(() => { useEffect(() => {
setExpandedDays(prev => new Set([...prev, ...days.map(d => d.id)])) if (days.length > prevDayCount.current) {
}, [days.length]) // New days added — expand only those
setExpandedDays(prev => {
const n = new Set(prev)
days.forEach(d => { if (!prev.has(d.id)) n.add(d.id) })
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
return n
})
}
prevDayCount.current = days.length
}, [days.length, tripId])
useEffect(() => { useEffect(() => {
if (editingDayId && inputRef.current) inputRef.current.focus() if (editingDayId && inputRef.current) inputRef.current.focus()
@@ -153,6 +171,7 @@ export default function DayPlanSidebar({
setExpandedDays(prev => { setExpandedDays(prev => {
const n = new Set(prev) const n = new Set(prev)
n.has(dayId) ? n.delete(dayId) : n.add(dayId) n.has(dayId) ? n.delete(dayId) : n.add(dayId)
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
return n return n
}) })
} }
@@ -284,7 +303,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 })
@@ -315,12 +334,13 @@ export default function DayPlanSidebar({
else unlocked.push(a) else unlocked.push(a)
}) })
// Optimize only unlocked places // Optimize only unlocked assignments (work on assignments, not places)
const unlockedWithCoords = unlocked.map(a => a.place).filter(p => p?.lat && p?.lng) const unlockedWithCoords = unlocked.filter(a => a.place?.lat && a.place?.lng)
const optimized = unlockedWithCoords.length >= 2 ? optimizeRoute(unlockedWithCoords) : unlockedWithCoords const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng)
const optimizedQueue = optimized.map(p => unlocked.find(a => a.place?.id === p.id)).filter(Boolean) const optimizedAssignments = unlockedWithCoords.length >= 2
// Add unlocked without coords at the end ? optimizeRoute(unlockedWithCoords.map(a => ({ ...a.place, _assignmentId: a.id }))).map(p => unlockedWithCoords.find(a => a.id === p._assignmentId)).filter(Boolean)
for (const a of unlocked) { if (!optimizedQueue.includes(a)) optimizedQueue.push(a) } : unlockedWithCoords
const optimizedQueue = [...optimizedAssignments, ...unlockedNoCoords]
// Merge: locked stay at their index, fill gaps with optimized // Merge: locked stay at their index, fill gaps with optimized
const result = new Array(da.length) const result = new Array(da.length)
@@ -447,7 +467,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(day.id); if (onDayDetail) onDayDetail(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)}
@@ -455,7 +475,7 @@ export default function DayPlanSidebar({
display: 'flex', alignItems: 'center', gap: 10, display: 'flex', alignItems: 'center', gap: 10,
padding: '11px 14px 11px 16px', padding: '11px 14px 11px 16px',
cursor: 'pointer', cursor: 'pointer',
background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-hover)' : 'transparent'), background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-tertiary)' : 'transparent'),
transition: 'background 0.12s', transition: 'background 0.12s',
userSelect: 'none', userSelect: 'none',
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none', outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
@@ -493,8 +513,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
@@ -503,11 +523,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
@@ -580,9 +610,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)
@@ -639,7 +667,15 @@ 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, true) }} onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
onContextMenu={e => ctxMenu.open(e, [
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
{ divider: true },
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => { if (confirm(t('trip.confirm.deletePlace'))) onDeletePlace(place.id) } },
])}
onMouseEnter={() => setHoveredId(assignment.id)} onMouseEnter={() => setHoveredId(assignment.id)}
onMouseLeave={() => setHoveredId(null)} onMouseLeave={() => setHoveredId(null)}
style={{ style={{
@@ -651,9 +687,7 @@ export default function DayPlanSidebar({
: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'), : isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
borderLeft: lockedIds.has(assignment.id) borderLeft: lockedIds.has(assignment.id)
? '3px solid #dc2626' ? '3px solid #dc2626'
: hasReservation : '3px solid transparent',
? `3px solid ${isConfirmed ? '#10b981' : '#f59e0b'}`
: '3px solid transparent',
transition: 'background 0.15s, border-color 0.15s', transition: 'background 0.15s, border-color 0.15s',
opacity: isDraggingThis ? 0.4 : 1, opacity: isDraggingThis ? 0.4 : 1,
}} }}
@@ -689,8 +723,8 @@ export default function DayPlanSidebar({
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
}}> }}>
{lockedIds.has(assignment.id) {lockedIds.has(assignment.id)
? (language === 'de' ? 'Klicken zum Entsperren' : 'Click to unlock') ? t('planner.clickToUnlock')
: (language === 'de' ? 'Position bei Routenoptimierung beibehalten' : 'Keep position during route optimization')} : t('planner.keepPosition')}
</div> </div>
)} )}
</div> </div>
@@ -706,26 +740,51 @@ 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> }}>
{(() => { 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>
)
})()}
{assignment.participants?.length > 0 && (
<div style={{ marginTop: 3, display: 'flex', alignItems: 'center', gap: -4 }}>
{assignment.participants.slice(0, 5).map((p, pi) => (
<div key={p.user_id} style={{
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-tertiary)', border: '1.5px solid var(--bg-card)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 7, fontWeight: 700, color: 'var(--text-muted)',
marginLeft: pi > 0 ? -4 : 0, flexShrink: 0,
overflow: 'hidden',
}}>
{p.avatar ? <img src={p.avatar} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : p.username?.[0]?.toUpperCase()}
</div>
))}
{assignment.participants.length > 5 && (
<span style={{ fontSize: 8, color: 'var(--text-faint)', marginLeft: 2 }}>+{assignment.participants.length - 5}</span>
)}
</div> </div>
)} )}
</div> </div>
@@ -776,6 +835,11 @@ export default function DayPlanSidebar({
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id) handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
} }
}} }}
onContextMenu={e => ctxMenu.open(e, [
{ label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
{ divider: true },
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
])}
onMouseEnter={() => setHoveredId(`note-${note.id}`)} onMouseEnter={() => setHoveredId(`note-${note.id}`)}
onMouseLeave={() => setHoveredId(null)} onMouseLeave={() => setHoveredId(null)}
style={{ style={{
@@ -796,12 +860,11 @@ export default function DayPlanSidebar({
<NoteIcon size={13} strokeWidth={1.8} color="var(--text-muted)" /> <NoteIcon size={13} strokeWidth={1.8} color="var(--text-muted)" />
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', <span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', wordBreak: 'break-word' }}>
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{note.text} {note.text}
</span> </span>
{note.time && ( {note.time && (
<div style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.2', marginTop: 2 }}>{note.time}</div> <div style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}>{note.time}</div>
)} )}
</div> </div>
<div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}> <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
@@ -855,18 +918,6 @@ export default function DayPlanSidebar({
{/* 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>
@@ -935,14 +986,16 @@ export default function DayPlanSidebar({
placeholder={t('dayplan.noteTitle')} placeholder={t('dayplan.noteTitle')}
style={{ fontSize: 13, fontWeight: 500, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }} style={{ fontSize: 13, fontWeight: 500, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
/> />
<input <textarea
type="text"
value={ui.time} value={ui.time}
maxLength={150}
rows={3}
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))} onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }} onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
placeholder={t('dayplan.noteSubtitle')} placeholder={t('dayplan.noteSubtitle')}
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }} style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)', resize: 'none', lineHeight: 1.4 }}
/> />
<div style={{ textAlign: 'right', fontSize: 9, color: (ui.time?.length || 0) >= 140 ? '#d97706' : 'var(--text-faint)', marginTop: -2 }}>{ui.time?.length || 0}/150</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}> <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => cancelNote(Number(dayId))} style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>{t('common.cancel')}</button> <button onClick={() => cancelNote(Number(dayId))} style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={() => saveNote(Number(dayId))} style={{ fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit' }}> <button onClick={() => saveNote(Number(dayId))} style={{ fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit' }}>
@@ -961,6 +1014,7 @@ export default function DayPlanSidebar({
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{totalCost.toFixed(2)} {currency}</span> <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{totalCost.toFixed(2)} {currency}</span>
</div> </div>
)} )}
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
</div> </div>
) )
} }
+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}
+133 -63
View File
@@ -1,20 +1,12 @@
import React, { useState, useEffect } 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 { 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, AlertTriangle } 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,17 +16,15 @@ 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: '',
} }
export default function PlaceFormModal({ export default function PlaceFormModal({
isOpen, onClose, onSave, place, tripId, categories, isOpen, onClose, onSave, place, tripId, categories,
onCategoryCreated, onCategoryCreated, assignmentId, dayAssignments = [],
}) { }) {
const [form, setForm] = useState(DEFAULT_FORM) const [form, setForm] = useState(DEFAULT_FORM)
const [mapsSearch, setMapsSearch] = useState('') const [mapsSearch, setMapsSearch] = useState('')
@@ -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,32 @@ 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 hasTimeError = place && form.place_time && form.end_time && form.place_time.length >= 5 && form.end_time.length >= 5 && form.end_time <= form.place_time
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault()
if (!form.name.trim()) { if (!form.name.trim()) {
@@ -124,6 +141,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 +158,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 && (
@@ -277,14 +295,17 @@ export default function PlaceFormModal({
)} )}
</div> </div>
{/* Time */} {/* Time — only shown when editing, not when creating */}
<div> {place && (
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formTime')}</label> <TimeSection
<CustomTimePicker form={form}
value={form.place_time} handleChange={handleChange}
onChange={v => handleChange('place_time', v)} assignmentId={assignmentId}
dayAssignments={dayAssignments}
hasTimeError={hasTimeError}
t={t}
/> />
</div> )}
{/* Website */} {/* Website */}
<div> <div>
@@ -298,45 +319,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">
@@ -349,7 +360,7 @@ export default function PlaceFormModal({
</button> </button>
<button <button
type="submit" type="submit"
disabled={isSaving} disabled={isSaving || hasTimeError}
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium" className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
> >
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')} {isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
@@ -359,3 +370,62 @@ export default function PlaceFormModal({
</Modal> </Modal>
) )
} }
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, t }) {
const collisions = useMemo(() => {
if (!assignmentId || !form.place_time || form.place_time.length < 5) return []
// Find the day_id for the current assignment
const current = dayAssignments.find(a => a.id === assignmentId)
if (!current) return []
const myStart = form.place_time
const myEnd = form.end_time && form.end_time.length >= 5 ? form.end_time : null
return dayAssignments.filter(a => {
if (a.id === assignmentId) return false
if (a.day_id !== current.day_id) return false
const aStart = a.place?.place_time
const aEnd = a.place?.end_time
if (!aStart) return false
// Check overlap: two intervals overlap if start < otherEnd AND otherStart < end
const s1 = myStart, e1 = myEnd || myStart
const s2 = aStart, e2 = aEnd || aStart
return s1 < (e2 || '23:59') && s2 < (e1 || '23:59') && s1 !== e2 && s2 !== e1
})
}, [assignmentId, dayAssignments, form.place_time, form.end_time])
return (
<div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.startTime')}</label>
<CustomTimePicker
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>
{hasTimeError && (
<div className="flex items-center gap-1.5 mt-2 px-2.5 py-1.5 rounded-lg text-xs" style={{ background: 'var(--bg-warning, #fef3c7)', color: 'var(--text-warning, #92400e)' }}>
<AlertTriangle size={13} className="shrink-0" />
{t('places.endTimeBeforeStart')}
</div>
)}
{collisions.length > 0 && (
<div className="flex items-start gap-1.5 mt-2 px-2.5 py-1.5 rounded-lg text-xs" style={{ background: 'var(--bg-warning, #fef3c7)', color: 'var(--text-warning, #92400e)' }}>
<AlertTriangle size={13} className="shrink-0 mt-0.5" />
<span>
{t('places.timeCollision')}{' '}
{collisions.map(a => a.place?.name).filter(Boolean).join(', ')}
</span>
</div>
)}
</div>
)
}
+199 -66
View File
@@ -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, Users } 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'
@@ -75,7 +75,10 @@ function convertHoursLine(line, timeFormat) {
function formatTime(timeStr, locale, timeFormat) { function formatTime(timeStr, locale, timeFormat) {
if (!timeStr) return '' if (!timeStr) return ''
try { try {
const [h, m] = timeStr.split(':').map(Number) const parts = timeStr.split(':')
const h = Number(parts[0]) || 0
const m = Number(parts[1]) || 0
if (isNaN(h)) return timeStr
if (timeFormat === '12h') { if (timeFormat === '12h') {
const period = h >= 12 ? 'PM' : 'AM' const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
@@ -86,16 +89,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,9 +98,9 @@ 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, tripMembers = [], onSetParticipants,
}) { }) {
const { t, locale, language } = useTranslation() const { t, locale, language } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
@@ -212,7 +205,7 @@ export default function PlaceInspector({
padding: '2px 8px', borderRadius: 99, padding: '2px 8px', borderRadius: 99,
}}> }}>
<CatIcon size={10} /> <CatIcon size={10} />
{category.name} <span className="hidden sm:inline">{category.name}</span>
</span> </span>
) )
})()} })()}
@@ -220,17 +213,17 @@ export default function PlaceInspector({
{place.address && ( {place.address && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, marginTop: 6 }}> <div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, marginTop: 6 }}>
<MapPin size={11} color="var(--text-faint)" style={{ flexShrink: 0, marginTop: 2 }} /> <MapPin size={11} color="var(--text-faint)" style={{ flexShrink: 0, marginTop: 2 }} />
<span style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.4' }}>{place.address}</span> <span style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.4', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{place.address}</span>
</div> </div>
)} )}
{place.place_time && ( {place.place_time && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 3 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 3 }}>
<Clock size={10} color="var(--text-faint)" style={{ flexShrink: 0 }} /> <Clock size={10} color="var(--text-faint)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{formatTime(place.place_time, locale, timeFormat)}</span> <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` ${formatTime(place.end_time, locale, timeFormat)}` : ''}</span>
</div> </div>
)} )}
{place.lat && place.lng && ( {place.lat && place.lng && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4, fontVariantNumeric: 'tabular-nums' }}> <div className="hidden sm:block" style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4, fontVariantNumeric: 'tabular-nums' }}>
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)} {Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
</div> </div>
)} )}
@@ -248,8 +241,8 @@ export default function PlaceInspector({
{/* Content — scrollable */} {/* Content — scrollable */}
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Info-Chips */} {/* Info-Chips — hidden on mobile, shown on desktop */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}> <div className="hidden sm:flex" style={{ flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
{googleDetails?.rating && (() => { {googleDetails?.rating && (() => {
const shortReview = (googleDetails.reviews || []).find(r => r.text && r.text.length > 5) const shortReview = (googleDetails.reviews || []).find(r => r.text && r.text.length > 5)
return ( return (
@@ -279,46 +272,80 @@ 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>
)} )}
{/* Opening hours */} {/* Reservation + Participants — side by side */}
{(() => {
const res = selectedAssignmentId ? reservations.find(r => r.assignment_id === selectedAssignmentId) : null
const assignment = selectedAssignmentId ? (assignments[String(selectedDayId)] || []).find(a => a.id === selectedAssignmentId) : null
const currentParticipants = assignment?.participants || []
const participantIds = currentParticipants.map(p => p.user_id)
const allJoined = currentParticipants.length === 0
const showParticipants = selectedAssignmentId && tripMembers.length > 1
if (!res && !showParticipants) return null
return (
<div className={`grid ${res && showParticipants ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'} gap-2`}>
{/* Reservation */}
{res && (() => {
const confirmed = res.status === 'confirmed'
return (
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: confirmed ? 'rgba(22,163,74,0.08)' : 'rgba(217,119,6,0.08)' }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
<span style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706' }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
<span style={{ flex: 1 }} />
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
</div>
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{res.reservation_time && (
<div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
<div style={{ fontSize: 10, 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: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
<div style={{ fontSize: 10, 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: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{res.confirmation_number}</div>
</div>
)}
</div>
{res.notes && <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}>{res.notes}</div>}
</div>
)
})()}
{/* Participants */}
{showParticipants && (
<ParticipantsBox
tripMembers={tripMembers}
participantIds={participantIds}
allJoined={allJoined}
onSetParticipants={onSetParticipants}
selectedAssignmentId={selectedAssignmentId}
selectedDayId={selectedDayId}
t={t}
/>
)}
</div>
)
})()}
{/* Opening hours + Files — side by side on desktop only if both exist */}
<div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}>
{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' }}>
<button <button
@@ -380,24 +407,17 @@ export default function PlaceInspector({
{filesExpanded && placeFiles.length > 0 && ( {filesExpanded && placeFiles.length > 0 && (
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}> <div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{placeFiles.map(f => ( {placeFiles.map(f => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <a key={f.id} href={`/uploads/files/${f.filename}`} target="_blank" rel="noopener noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer' }}>
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />} {(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span> <span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>} {f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
<a </a>
href={`/uploads/files/${f.filename}`}
target="_blank"
rel="noopener noreferrer"
style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex' }}
>
<ExternalLink size={11} />
</a>
</div>
))} ))}
</div> </div>
)} )}
</div> </div>
)} )}
</div>
</div> </div>
@@ -470,3 +490,116 @@ function ActionButton({ onClick, variant, icon, label }) {
</button> </button>
) )
} }
function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticipants, selectedAssignmentId, selectedDayId, t }) {
const [showAdd, setShowAdd] = React.useState(false)
const [hoveredId, setHoveredId] = React.useState(null)
// Active participants: if allJoined, show all members; otherwise show only those in participantIds
const activeMembers = allJoined ? tripMembers : tripMembers.filter(m => participantIds.includes(m.id))
const availableToAdd = allJoined ? [] : tripMembers.filter(m => !participantIds.includes(m.id))
const handleRemove = (userId) => {
if (!onSetParticipants) return
let newIds
if (allJoined) {
newIds = tripMembers.filter(m => m.id !== userId).map(m => m.id)
} else {
newIds = participantIds.filter(id => id !== userId)
}
if (newIds.length === tripMembers.length) newIds = []
onSetParticipants(selectedAssignmentId, selectedDayId, newIds)
}
const handleAdd = (userId) => {
if (!onSetParticipants) return
const newIds = [...participantIds, userId]
if (newIds.length === tripMembers.length) {
onSetParticipants(selectedAssignmentId, selectedDayId, [])
} else {
onSetParticipants(selectedAssignmentId, selectedDayId, newIds)
}
setShowAdd(false)
}
return (
<div style={{ borderRadius: 12, border: '1px solid var(--border-faint)', padding: '8px 10px' }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 6, display: 'flex', alignItems: 'center', gap: 4 }}>
<Users size={10} /> {t('inspector.participants')}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, alignItems: 'center' }}>
{activeMembers.map(member => {
const isHovered = hoveredId === member.id
const canRemove = activeMembers.length > 1
return (
<div key={member.id}
onMouseEnter={() => setHoveredId(member.id)}
onMouseLeave={() => setHoveredId(null)}
onClick={() => { if (canRemove) handleRemove(member.id) }}
style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '2px 7px 2px 3px', borderRadius: 99,
border: `1.5px solid ${isHovered && canRemove ? 'rgba(239,68,68,0.4)' : 'var(--accent)'}`,
background: isHovered && canRemove ? 'rgba(239,68,68,0.06)' : 'var(--bg-hover)',
fontSize: 10, fontWeight: 500,
color: isHovered && canRemove ? '#ef4444' : 'var(--text-primary)',
cursor: canRemove ? 'pointer' : 'default',
transition: 'all 0.15s',
}}>
<div style={{
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 7, fontWeight: 700,
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
}}>
{(member.avatar_url || member.avatar) ? <img src={member.avatar_url || `/uploads/avatars/${member.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : member.username?.[0]?.toUpperCase()}
</div>
<span style={{ textDecoration: isHovered && canRemove ? 'line-through' : 'none' }}>{member.username}</span>
</div>
)
})}
{/* Add button */}
{availableToAdd.length > 0 && (
<div style={{ position: 'relative' }}>
<button onClick={() => setShowAdd(!showAdd)} style={{
width: 22, height: 22, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--text-faint)', fontSize: 12, transition: 'all 0.12s',
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}
>+</button>
{showAdd && (
<div style={{
position: 'absolute', top: 26, left: 0, zIndex: 100,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 140,
}}>
{availableToAdd.map(member => (
<button key={member.id} onClick={() => handleAdd(member.id)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
borderRadius: 6, border: 'none', background: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', 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: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
}}>
{(member.avatar_url || member.avatar) ? <img src={member.avatar_url || `/uploads/avatars/${member.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : member.username?.[0]?.toUpperCase()}
</div>
{member.username}
</button>
))}
</div>
)}
</div>
)}
</div>
</div>
)
}
@@ -1,16 +1,18 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { Search, Plus, X, CalendarDays } from 'lucide-react' import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar' import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons' import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
export default function PlacesSidebar({ export default function PlacesSidebar({
places, categories, assignments, selectedDayId, selectedPlaceId, places, categories, assignments, selectedDayId, selectedPlaceId,
onPlaceClick, onAddPlace, onAssignToDay, days, isMobile, onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile,
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const ctxMenu = useContextMenu()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [filter, setFilter] = useState('all') const [filter, setFilter] = useState('all')
const [categoryFilter, setCategoryFilter] = useState('') const [categoryFilter, setCategoryFilter] = useState('')
@@ -138,6 +140,14 @@ export default function PlacesSidebar({
onPlaceClick(isSelected ? null : place.id) onPlaceClick(isSelected ? null : place.id)
} }
}} }}
onContextMenu={e => ctxMenu.open(e, [
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
{ divider: true },
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => { if (confirm(t('trip.confirm.deletePlace'))) onDeletePlace(place.id) } },
])}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 10, display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 14px 9px 16px', padding: '9px 14px 9px 16px',
@@ -204,19 +214,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 +238,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>
) )
})} })}
@@ -239,6 +247,7 @@ export default function PlacesSidebar({
</div>, </div>,
document.body document.body
)} )}
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
</div> </div>
) )
} }
@@ -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 className={`grid grid-cols-1 ${r.location && linked ? 'sm:grid-cols-2' : ''} gap-2`} style={{ 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,56 @@ 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 */} {/* Content */}
{showHint && ( <div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px' }}>
<div style={{ margin: '12px 24px 8px', 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)' }} />
<p style={{ fontSize: 11.5, color: 'var(--text-muted)', margin: 0, lineHeight: 1.5, flex: 1 }}>
{t('reservations.placeHint')}
</p>
<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 style={{ flex: 1, overflowY: 'auto', padding: '20px 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">
@@ -144,7 +144,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle })
disabled={adding || !selectedUserId} disabled={adding || !selectedUserId}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '8px 14px', display: 'flex', alignItems: 'center', gap: 5, padding: '8px 14px',
background: 'var(--accent)', color: 'white', border: 'none', borderRadius: 10, background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10,
fontSize: 13, fontWeight: 600, cursor: adding || !selectedUserId ? 'default' : 'pointer', fontSize: 13, fontWeight: 600, cursor: adding || !selectedUserId ? 'default' : 'pointer',
fontFamily: 'inherit', opacity: adding || !selectedUserId ? 0.4 : 1, flexShrink: 0, fontFamily: 'inherit', opacity: adding || !selectedUserId ? 0.4 : 1, flexShrink: 0,
}} }}
@@ -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>
) )
@@ -0,0 +1,83 @@
import React, { useState, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
export function useContextMenu() {
const [menu, setMenu] = useState(null) // { x, y, items }
const open = (e, items) => {
e.preventDefault()
e.stopPropagation()
setMenu({ x: e.clientX, y: e.clientY, items })
}
const close = () => setMenu(null)
return { menu, open, close }
}
export function ContextMenu({ menu, onClose }) {
const ref = useRef(null)
useEffect(() => {
if (!menu) return
const handler = () => onClose()
document.addEventListener('click', handler)
document.addEventListener('contextmenu', handler)
return () => {
document.removeEventListener('click', handler)
document.removeEventListener('contextmenu', handler)
}
}, [menu, onClose])
// Adjust position if menu would overflow viewport
useEffect(() => {
if (!menu || !ref.current) return
const el = ref.current
const rect = el.getBoundingClientRect()
let { x, y } = menu
if (x + rect.width > window.innerWidth - 8) x = window.innerWidth - rect.width - 8
if (y + rect.height > window.innerHeight - 8) y = window.innerHeight - rect.height - 8
if (x !== menu.x || y !== menu.y) {
el.style.left = `${x}px`
el.style.top = `${y}px`
}
}, [menu])
if (!menu) return null
return ReactDOM.createPortal(
<div ref={ref} style={{
position: 'fixed', left: menu.x, top: menu.y, zIndex: 999999,
background: 'var(--bg-card)', borderRadius: 10, padding: '4px',
border: '1px solid var(--border-primary)',
boxShadow: '0 8px 30px rgba(0,0,0,0.15)',
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
minWidth: 160,
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
animation: 'ctxIn 0.1s ease-out',
}}>
{menu.items.filter(Boolean).map((item, i) => {
if (item.divider) return <div key={i} style={{ height: 1, background: 'var(--border-faint)', margin: '3px 6px' }} />
const Icon = item.icon
return (
<button key={i} onClick={() => { item.onClick(); onClose() }} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '7px 10px', borderRadius: 7, border: 'none',
background: 'none', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 12, fontWeight: 500, textAlign: 'left',
color: item.danger ? '#ef4444' : 'var(--text-primary)',
transition: 'background 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.background = item.danger ? 'rgba(239,68,68,0.08)' : 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
>
{Icon && <Icon size={13} style={{ flexShrink: 0, color: item.danger ? '#ef4444' : 'var(--text-faint)' }} />}
<span>{item.label}</span>
</button>
)
})}
<style>{`@keyframes ctxIn { from { opacity: 0; transform: scale(0.95) } to { opacity: 1; transform: scale(1) } }`}</style>
</div>,
document.body
)
}
@@ -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
@@ -85,6 +85,9 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
const h = Math.min(23, Math.max(0, parseInt(s.slice(0, 2)))) const h = Math.min(23, Math.max(0, parseInt(s.slice(0, 2))))
const m = Math.min(59, Math.max(0, parseInt(s.slice(2)))) const m = Math.min(59, Math.max(0, parseInt(s.slice(2))))
onChange(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0')) onChange(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'))
} else if (/^\d{1,2}$/.test(clean)) {
const h = Math.min(23, Math.max(0, parseInt(clean)))
onChange(String(h).padStart(2, '0') + ':00')
} }
} }
+1 -1
View File
@@ -9,7 +9,7 @@ export default function PlaceAvatar({ place, size = 32, category }) {
useEffect(() => { useEffect(() => {
if (place.image_url) { setPhotoSrc(place.image_url); return } if (place.image_url) { setPhotoSrc(place.image_url); return }
if (!place.google_place_id) return if (!place.google_place_id) { setPhotoSrc(null); return }
if (googlePhotoCache.has(place.google_place_id)) { if (googlePhotoCache.has(place.google_place_id)) {
setPhotoSrc(googlePhotoCache.get(place.google_place_id)) setPhotoSrc(googlePhotoCache.get(place.google_place_id))
+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) {
+275 -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,9 +123,13 @@ 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',
'settings.routeCalculation': 'Routenberechnung',
'settings.on': 'An',
'settings.off': 'Aus',
'settings.account': 'Konto', 'settings.account': 'Konto',
'settings.username': 'Benutzername', 'settings.username': 'Benutzername',
'settings.email': 'E-Mail', 'settings.email': 'E-Mail',
@@ -191,6 +198,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',
@@ -248,6 +284,12 @@ const de = {
'admin.oidcIssuerHint': 'Die OpenID Connect Issuer URL des Anbieters. z.B. https://accounts.google.com', 'admin.oidcIssuerHint': 'Die OpenID Connect Issuer URL des Anbieters. z.B. https://accounts.google.com',
'admin.oidcSaved': 'OIDC-Konfiguration gespeichert', 'admin.oidcSaved': 'OIDC-Konfiguration gespeichert',
// File Types
'admin.fileTypes': 'Erlaubte Dateitypen',
'admin.fileTypesHint': 'Konfiguriere welche Dateitypen hochgeladen werden dürfen.',
'admin.fileTypesFormat': 'Kommagetrennte Endungen (z.B. jpg,png,pdf,doc). Verwende * um alle Typen zu erlauben.',
'admin.fileTypesSaved': 'Dateityp-Einstellungen gespeichert',
// Addons // Addons
'admin.tabs.addons': 'Addons', 'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons', 'admin.addons.title': 'Addons',
@@ -263,6 +305,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',
@@ -398,9 +482,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',
@@ -426,7 +507,7 @@ const de = {
'dayplan.pdfError': 'Fehler beim PDF-Export', 'dayplan.pdfError': 'Fehler beim PDF-Export',
// Places Sidebar // Places Sidebar
'places.addPlace': 'Ort hinzufügen', 'places.addPlace': 'Ort/Aktivität hinzufügen',
'places.assignToDay': 'Zu welchem Tag hinzufügen?', 'places.assignToDay': 'Zu welchem Tag hinzufügen?',
'places.all': 'Alle', 'places.all': 'Alle',
'places.unplanned': 'Ungeplant', 'places.unplanned': 'Ungeplant',
@@ -449,6 +530,10 @@ 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.endTimeBeforeStart': 'Endzeit liegt vor der Startzeit',
'places.timeCollision': 'Zeitliche Überschneidung mit:',
'places.formWebsite': 'Website', 'places.formWebsite': 'Website',
'places.formNotesPlaceholder': 'Persönliche Notizen...', 'places.formNotesPlaceholder': 'Persönliche Notizen...',
'places.formReservation': 'Reservierung', 'places.formReservation': 'Reservierung',
@@ -460,11 +545,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',
@@ -478,6 +558,9 @@ 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',
'inspector.participants': 'Teilnehmer',
// Reservations // Reservations
'reservations.title': 'Buchungen', 'reservations.title': 'Buchungen',
@@ -494,6 +577,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...',
@@ -521,7 +606,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',
@@ -530,6 +615,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',
@@ -545,7 +633,7 @@ const de = {
'budget.table.days': 'Tage', 'budget.table.days': 'Tage',
'budget.table.perPerson': 'Pro Person', 'budget.table.perPerson': 'Pro Person',
'budget.table.perDay': 'Pro Tag', 'budget.table.perDay': 'Pro Tag',
'budget.table.perPersonDay': 'Pro Person/Tag', 'budget.table.perPersonDay': 'P. p / Tag',
'budget.table.note': 'Notiz', 'budget.table.note': 'Notiz',
'budget.newEntry': 'Neuer Eintrag', 'budget.newEntry': 'Neuer Eintrag',
'budget.defaultEntry': 'Neuer Eintrag', 'budget.defaultEntry': 'Neuer Eintrag',
@@ -556,6 +644,10 @@ const de = {
'budget.editTooltip': 'Klicken zum Bearbeiten', 'budget.editTooltip': 'Klicken zum Bearbeiten',
'budget.confirm.deleteCategory': 'Möchtest du die Kategorie "{name}" mit {count} Einträgen wirklich löschen?', 'budget.confirm.deleteCategory': 'Möchtest du die Kategorie "{name}" mit {count} Einträgen wirklich löschen?',
'budget.deleteCategory': 'Kategorie löschen', 'budget.deleteCategory': 'Kategorie löschen',
'budget.perPerson': 'Pro Person',
'budget.paid': 'Bezahlt',
'budget.open': 'Offen',
'budget.noMembers': 'Keine Teilnehmer zugewiesen',
// Files // Files
'files.title': 'Dateien', 'files.title': 'Dateien',
@@ -565,11 +657,14 @@ const de = {
'files.uploadError': 'Fehler beim Hochladen', 'files.uploadError': 'Fehler beim Hochladen',
'files.dropzone': 'Dateien hier ablegen', 'files.dropzone': 'Dateien hier ablegen',
'files.dropzoneHint': 'oder klicken zum Auswählen', 'files.dropzoneHint': 'oder klicken zum Auswählen',
'files.allowedTypes': 'Bilder, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
'files.uploading': 'Wird hochgeladen...', 'files.uploading': 'Wird hochgeladen...',
'files.filterAll': 'Alle', 'files.filterAll': 'Alle',
'files.filterPdf': 'PDFs', 'files.filterPdf': 'PDFs',
'files.filterImages': 'Bilder', 'files.filterImages': 'Bilder',
'files.filterDocs': 'Dokumente', 'files.filterDocs': 'Dokumente',
'files.filterCollab': 'Collab Notizen',
'files.sourceCollab': 'Aus Collab Notizen',
'files.empty': 'Keine Dateien vorhanden', 'files.empty': 'Keine Dateien vorhanden',
'files.emptyHint': 'Lade Dateien hoch, um sie mit deiner Reise zu verknüpfen', 'files.emptyHint': 'Lade Dateien hoch, um sie mit deiner Reise zu verknüpfen',
'files.openTab': 'In neuem Tab öffnen', 'files.openTab': 'In neuem Tab öffnen',
@@ -578,6 +673,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',
@@ -730,6 +827,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',
@@ -737,6 +849,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/Aktivität hinzufügen',
'planner.addPlaceShort': '+ Ort/Aktivität 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',
@@ -746,6 +920,96 @@ 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.overview': 'Tagesübersicht',
'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',
// Collab Addon
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notizen',
'collab.tabs.polls': 'Umfragen',
'collab.whatsNext.title': 'Nächste',
'collab.whatsNext.today': 'Heute',
'collab.whatsNext.tomorrow': 'Morgen',
'collab.whatsNext.empty': 'Keine anstehenden Aktivitäten',
'collab.whatsNext.until': 'bis',
'collab.whatsNext.emptyHint': 'Aktivitäten mit Uhrzeit erscheinen hier',
'collab.chat.send': 'Senden',
'collab.chat.placeholder': 'Nachricht eingeben...',
'collab.chat.empty': 'Starte die Unterhaltung',
'collab.chat.emptyHint': 'Nachrichten werden mit allen Reiseteilnehmern geteilt',
'collab.chat.today': 'Heute',
'collab.chat.yesterday': 'Gestern',
'collab.chat.deletedMessage': 'hat eine Nachricht gelöscht',
'collab.chat.loadMore': 'Ältere Nachrichten laden',
'collab.chat.justNow': 'gerade eben',
'collab.chat.minutesAgo': 'vor {n} Min.',
'collab.chat.hoursAgo': 'vor {n} Std.',
'collab.chat.yesterday': 'gestern',
'collab.notes.title': 'Notizen',
'collab.notes.new': 'Neue Notiz',
'collab.notes.empty': 'Noch keine Notizen',
'collab.notes.emptyHint': 'Halte Ideen und Pläne fest',
'collab.notes.all': 'Alle',
'collab.notes.titlePlaceholder': 'Notiztitel',
'collab.notes.contentPlaceholder': 'Schreibe etwas...',
'collab.notes.categoryPlaceholder': 'Kategorie',
'collab.notes.newCategory': 'Neue Kategorie...',
'collab.notes.category': 'Kategorie',
'collab.notes.noCategory': 'Keine Kategorie',
'collab.notes.color': 'Farbe',
'collab.notes.save': 'Speichern',
'collab.notes.cancel': 'Abbrechen',
'collab.notes.edit': 'Bearbeiten',
'collab.notes.delete': 'Löschen',
'collab.notes.pin': 'Anheften',
'collab.notes.unpin': 'Loslösen',
'collab.notes.daysAgo': 'vor {n} T.',
'collab.notes.categorySettings': 'Kategorien verwalten',
'collab.notes.create': 'Erstellen',
'collab.notes.website': 'Website',
'collab.notes.websitePlaceholder': 'https://...',
'collab.notes.attachFiles': 'Dateien anhängen',
'collab.notes.noCategoriesYet': 'Noch keine Kategorien',
'collab.notes.emptyDesc': 'Erstelle eine Notiz um loszulegen',
'collab.polls.title': 'Umfragen',
'collab.polls.new': 'Neue Umfrage',
'collab.polls.empty': 'Noch keine Umfragen',
'collab.polls.emptyHint': 'Frage die Gruppe und stimmt gemeinsam ab',
'collab.polls.question': 'Frage',
'collab.polls.questionPlaceholder': 'Was sollen wir machen?',
'collab.polls.addOption': '+ Option hinzufügen',
'collab.polls.optionPlaceholder': 'Option {n}',
'collab.polls.create': 'Umfrage erstellen',
'collab.polls.close': 'Schließen',
'collab.polls.closed': 'Geschlossen',
'collab.polls.votes': '{n} Stimmen',
'collab.polls.vote': '{n} Stimme',
'collab.polls.multipleChoice': 'Mehrfachauswahl',
'collab.polls.multiChoice': 'Mehrfachauswahl',
'collab.polls.deadline': 'Frist',
'collab.polls.option': 'Option',
'collab.polls.options': 'Optionen',
'collab.polls.delete': 'Löschen',
'collab.polls.closedSection': 'Geschlossen',
} }
export default de export default de
+275 -11
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,9 +123,13 @@ 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',
'settings.routeCalculation': 'Route Calculation',
'settings.on': 'On',
'settings.off': 'Off',
'settings.account': 'Account', 'settings.account': 'Account',
'settings.username': 'Username', 'settings.username': 'Username',
'settings.email': 'Email', 'settings.email': 'Email',
@@ -191,6 +198,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',
@@ -248,6 +284,12 @@ const en = {
'admin.oidcIssuerHint': 'The OpenID Connect Issuer URL of the provider. e.g. https://accounts.google.com', 'admin.oidcIssuerHint': 'The OpenID Connect Issuer URL of the provider. e.g. https://accounts.google.com',
'admin.oidcSaved': 'OIDC configuration saved', 'admin.oidcSaved': 'OIDC configuration saved',
// File Types
'admin.fileTypes': 'Allowed File Types',
'admin.fileTypesHint': 'Configure which file types users can upload.',
'admin.fileTypesFormat': 'Comma-separated extensions (e.g. jpg,png,pdf,doc). Use * to allow all types.',
'admin.fileTypesSaved': 'File type settings saved',
// Addons // Addons
'admin.tabs.addons': 'Addons', 'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons', 'admin.addons.title': 'Addons',
@@ -263,6 +305,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',
@@ -398,9 +482,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',
@@ -426,7 +507,7 @@ const en = {
'dayplan.pdfError': 'Failed to export PDF', 'dayplan.pdfError': 'Failed to export PDF',
// Places Sidebar // Places Sidebar
'places.addPlace': 'Add Place', 'places.addPlace': 'Add Place/Activity',
'places.assignToDay': 'Add to which day?', 'places.assignToDay': 'Add to which day?',
'places.all': 'All', 'places.all': 'All',
'places.unplanned': 'Unplanned', 'places.unplanned': 'Unplanned',
@@ -449,6 +530,10 @@ 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.endTimeBeforeStart': 'End time is before start time',
'places.timeCollision': 'Time overlap with:',
'places.formWebsite': 'Website', 'places.formWebsite': 'Website',
'places.formNotesPlaceholder': 'Personal notes...', 'places.formNotesPlaceholder': 'Personal notes...',
'places.formReservation': 'Reservation', 'places.formReservation': 'Reservation',
@@ -460,11 +545,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',
@@ -478,6 +558,9 @@ 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',
'inspector.participants': 'Participants',
// Reservations // Reservations
'reservations.title': 'Bookings', 'reservations.title': 'Bookings',
@@ -494,6 +577,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...',
@@ -517,7 +602,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',
@@ -530,6 +615,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',
@@ -545,7 +633,7 @@ const en = {
'budget.table.days': 'Days', 'budget.table.days': 'Days',
'budget.table.perPerson': 'Per Person', 'budget.table.perPerson': 'Per Person',
'budget.table.perDay': 'Per Day', 'budget.table.perDay': 'Per Day',
'budget.table.perPersonDay': 'Per Person/Day', 'budget.table.perPersonDay': 'P. p / Day',
'budget.table.note': 'Note', 'budget.table.note': 'Note',
'budget.newEntry': 'New Entry', 'budget.newEntry': 'New Entry',
'budget.defaultEntry': 'New Entry', 'budget.defaultEntry': 'New Entry',
@@ -556,6 +644,10 @@ const en = {
'budget.editTooltip': 'Click to edit', 'budget.editTooltip': 'Click to edit',
'budget.confirm.deleteCategory': 'Are you sure you want to delete the category "{name}" with {count} entries?', 'budget.confirm.deleteCategory': 'Are you sure you want to delete the category "{name}" with {count} entries?',
'budget.deleteCategory': 'Delete Category', 'budget.deleteCategory': 'Delete Category',
'budget.perPerson': 'Per Person',
'budget.paid': 'Paid',
'budget.open': 'Open',
'budget.noMembers': 'No members assigned',
// Files // Files
'files.title': 'Files', 'files.title': 'Files',
@@ -565,11 +657,14 @@ const en = {
'files.uploadError': 'Upload failed', 'files.uploadError': 'Upload failed',
'files.dropzone': 'Drop files here', 'files.dropzone': 'Drop files here',
'files.dropzoneHint': 'or click to browse', 'files.dropzoneHint': 'or click to browse',
'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
'files.uploading': 'Uploading...', 'files.uploading': 'Uploading...',
'files.filterAll': 'All', 'files.filterAll': 'All',
'files.filterPdf': 'PDFs', 'files.filterPdf': 'PDFs',
'files.filterImages': 'Images', 'files.filterImages': 'Images',
'files.filterDocs': 'Documents', 'files.filterDocs': 'Documents',
'files.filterCollab': 'Collab Notes',
'files.sourceCollab': 'From Collab Notes',
'files.empty': 'No files yet', 'files.empty': 'No files yet',
'files.emptyHint': 'Upload files to attach them to your trip', 'files.emptyHint': 'Upload files to attach them to your trip',
'files.openTab': 'Open in new tab', 'files.openTab': 'Open in new tab',
@@ -578,6 +673,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',
@@ -730,6 +827,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',
@@ -737,6 +849,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/activity',
'planner.addPlaceShort': '+ Add place/activity',
'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',
@@ -746,6 +920,96 @@ 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.overview': 'Daily Overview',
'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',
// Collab Addon
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notes',
'collab.tabs.polls': 'Polls',
'collab.whatsNext.title': "What's Next",
'collab.whatsNext.today': 'Today',
'collab.whatsNext.tomorrow': 'Tomorrow',
'collab.whatsNext.empty': 'No upcoming activities',
'collab.whatsNext.until': 'to',
'collab.whatsNext.emptyHint': 'Activities with times will appear here',
'collab.chat.send': 'Send',
'collab.chat.placeholder': 'Type a message...',
'collab.chat.empty': 'Start the conversation',
'collab.chat.emptyHint': 'Messages are shared with all trip members',
'collab.chat.today': 'Today',
'collab.chat.yesterday': 'Yesterday',
'collab.chat.deletedMessage': 'deleted a message',
'collab.chat.loadMore': 'Load older messages',
'collab.chat.justNow': 'just now',
'collab.chat.minutesAgo': '{n}m ago',
'collab.chat.hoursAgo': '{n}h ago',
'collab.chat.yesterday': 'yesterday',
'collab.notes.title': 'Notes',
'collab.notes.new': 'New Note',
'collab.notes.empty': 'No notes yet',
'collab.notes.emptyHint': 'Start capturing ideas and plans',
'collab.notes.all': 'All',
'collab.notes.titlePlaceholder': 'Note title',
'collab.notes.contentPlaceholder': 'Write something...',
'collab.notes.categoryPlaceholder': 'Category',
'collab.notes.newCategory': 'New category...',
'collab.notes.category': 'Category',
'collab.notes.noCategory': 'No category',
'collab.notes.color': 'Color',
'collab.notes.save': 'Save',
'collab.notes.cancel': 'Cancel',
'collab.notes.edit': 'Edit',
'collab.notes.delete': 'Delete',
'collab.notes.pin': 'Pin',
'collab.notes.unpin': 'Unpin',
'collab.notes.daysAgo': '{n}d ago',
'collab.notes.categorySettings': 'Manage Categories',
'collab.notes.create': 'Create',
'collab.notes.website': 'Website',
'collab.notes.websitePlaceholder': 'https://...',
'collab.notes.attachFiles': 'Attach files',
'collab.notes.noCategoriesYet': 'No categories yet',
'collab.notes.emptyDesc': 'Create a note to get started',
'collab.polls.title': 'Polls',
'collab.polls.new': 'New Poll',
'collab.polls.empty': 'No polls yet',
'collab.polls.emptyHint': 'Ask the group and vote together',
'collab.polls.question': 'Question',
'collab.polls.questionPlaceholder': 'What should we do?',
'collab.polls.addOption': '+ Add option',
'collab.polls.optionPlaceholder': 'Option {n}',
'collab.polls.create': 'Create Poll',
'collab.polls.close': 'Close',
'collab.polls.closed': 'Closed',
'collab.polls.votes': '{n} votes',
'collab.polls.vote': '{n} vote',
'collab.polls.multipleChoice': 'Multiple choice',
'collab.polls.multiChoice': 'Multiple choice',
'collab.polls.deadline': 'Deadline',
'collab.polls.option': 'Option',
'collab.polls.options': 'Options',
'collab.polls.delete': 'Delete',
'collab.polls.closedSection': 'Closed',
} }
export default en export default en
+10
View File
@@ -290,6 +290,11 @@ body {
width: 6px; width: 6px;
height: 6px; height: 6px;
} }
::-webkit-scrollbar-button {
display: none;
height: 0;
width: 0;
}
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: var(--scrollbar-track); background: var(--scrollbar-track);
@@ -305,6 +310,11 @@ body {
background: var(--scrollbar-hover); background: var(--scrollbar-hover);
} }
.route-info-pill { background: none !important; border: none !important; box-shadow: none !important; width: auto !important; height: auto !important; margin: 0 !important; }
.chat-scroll { overflow-y: auto !important; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
.chat-scroll::-webkit-scrollbar { width: 0; background: transparent; }
/* Einheitliche Formular-Inputs */ /* Einheitliche Formular-Inputs */
.form-input { .form-input {
width: 100%; width: 100%;
+312 -48
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')
@@ -41,6 +43,10 @@ export default function AdminPage() {
// Registration toggle // Registration toggle
const [allowRegistration, setAllowRegistration] = useState(true) const [allowRegistration, setAllowRegistration] = useState(true)
// File types
const [allowedFileTypes, setAllowedFileTypes] = useState('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
const [savingFileTypes, setSavingFileTypes] = useState(false)
// API Keys // API Keys
const [mapsKey, setMapsKey] = useState('') const [mapsKey, setMapsKey] = useState('')
const [weatherKey, setWeatherKey] = useState('') const [weatherKey, setWeatherKey] = useState('')
@@ -49,6 +55,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 +70,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 () => {
@@ -80,6 +95,7 @@ export default function AdminPage() {
try { try {
const config = await authApi.getAppConfig() const config = await authApi.getAppConfig()
setAllowRegistration(config.allow_registration) setAllowRegistration(config.allow_registration)
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
} catch (err) { } catch (err) {
// ignore // ignore
} }
@@ -95,6 +111,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 {
@@ -222,6 +258,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">
@@ -415,6 +498,39 @@ export default function AdminPage() {
</div> </div>
</div> </div>
{/* Allowed File Types */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">{t('admin.fileTypes')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.fileTypesHint')}</p>
</div>
<div className="p-6">
<input
type="text"
value={allowedFileTypes}
onChange={e => setAllowedFileTypes(e.target.value)}
placeholder="jpg,png,pdf,doc,docx,xls,xlsx,txt,csv"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs text-slate-400 mt-2">{t('admin.fileTypesFormat')}</p>
<button
onClick={async () => {
setSavingFileTypes(true)
try {
await authApi.updateAppSettings({ allowed_file_types: allowedFileTypes })
toast.success(t('admin.fileTypesSaved'))
} catch { toast.error(t('common.error')) }
finally { setSavingFileTypes(false) }
}}
disabled={savingFileTypes}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400 mt-3"
>
{savingFileTypes ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
{t('common.save')}
</button>
</div>
</div>
{/* API Keys */} {/* API Keys */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden"> <div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100"> <div className="px-6 py-4 border-b border-slate-100">
@@ -426,7 +542,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 +591,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 +703,8 @@ export default function AdminPage() {
)} )}
{activeTab === 'backup' && <BackupPanel />} {activeTab === 'backup' && <BackupPanel />}
{activeTab === 'github' && <GitHubPanel />}
</div> </div>
</div> </div>
@@ -744,6 +843,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>
) )
} }
+2 -1
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)
+3 -1
View File
@@ -388,7 +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 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 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
@@ -425,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'))
} }
+3 -1
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()
@@ -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>
+13 -12
View File
@@ -44,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')
@@ -59,9 +59,10 @@ 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)
} }
@@ -172,7 +173,7 @@ export default function LoginPage() {
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
}}> }}>
<img src="/logo-light.svg" alt="NOMAD" style={{ height: 72 }} /> <img src="/logo-light.svg" alt="NOMAD" style={{ height: 72 }} />
<p style={{ margin: 0, fontSize: 20, color: 'rgba(255,255,255,0.6)', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase' }}>{t('login.tagline')}</p> <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> </div>
@@ -267,7 +268,7 @@ export default function LoginPage() {
onMouseLeave={e => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'} onMouseLeave={e => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'}
> >
<Globe size={14} /> <Globe size={14} />
{language === 'en' ? 'DE' : 'EN'} {language === 'en' ? 'EN' : 'DE'}
</button> </button>
{/* Left — branding */} {/* Left — branding */}
@@ -415,7 +416,7 @@ export default function LoginPage() {
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>
<img src="/logo-dark.svg" alt="NOMAD" style={{ height: 48 }} /> <img src="/logo-dark.svg" alt="NOMAD" style={{ height: 48 }} />
<p style={{ margin: 0, fontSize: 18, color: '#9ca3af', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase' }}>{t('login.tagline')}</p> <p style={{ margin: 0, fontSize: 16, color: '#9ca3af', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase', whiteSpace: 'nowrap' }}>{t('login.tagline')}</p>
</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)' }}>
@@ -516,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"
@@ -533,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>
</> </>
)} )}
@@ -554,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} />
{language === 'de' ? 'Demo ausprobieren — ohne Registrierung' : 'Try the demo — no registration needed'} {t('login.demoHint')}
</button> </button>
)} )}
</div> </div>
+3 -1
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()
@@ -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>
+59 -25
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 = [
@@ -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>
@@ -325,6 +330,35 @@ export default function SettingsPage() {
))} ))}
</div> </div>
</div> </div>
{/* Route Calculation */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.routeCalculation')}</label>
<div className="flex gap-3">
{[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
].map(opt => (
<button
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('route_calculation', opt.value) }
catch (e) { toast.error(e.message) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: (settings.route_calculation !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: (settings.route_calculation !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
</div>
</Section> </Section>
{/* Account */} {/* Account */}
+219 -43
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'
@@ -15,12 +16,14 @@ import ReservationsPanel from '../components/Planner/ReservationsPanel'
import PackingListPanel from '../components/Packing/PackingListPanel' import PackingListPanel from '../components/Packing/PackingListPanel'
import FileManager from '../components/Files/FileManager' import FileManager from '../components/Files/FileManager'
import BudgetPanel from '../components/Budget/BudgetPanel' import BudgetPanel from '../components/Budget/BudgetPanel'
import CollabPanel from '../components/Collab/CollabPanel'
import Navbar from '../components/Layout/Navbar' import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast' 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, authApi, tripsApi, assignmentsApi } from '../api/client'
import { calculateRoute } from '../components/Map/RouteCalculator'
const MIN_SIDEBAR = 200 const MIN_SIDEBAR = 200
const MAX_SIDEBAR = 520 const MAX_SIDEBAR = 520
@@ -35,12 +38,22 @@ 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 [allowedFileTypes, setAllowedFileTypes] = useState(null)
const [tripMembers, setTripMembers] = 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 => {
const map = {} const map = {}
data.addons.forEach(a => { map[a.id] = true }) data.addons.forEach(a => { map[a.id] = true })
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents }) setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab })
}).catch(() => {})
authApi.getAppConfig().then(config => {
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
}).catch(() => {}) }).catch(() => {})
}, []) }, [])
@@ -50,12 +63,17 @@ export default function TripPlannerPage() {
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []), ...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []), ...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []), ...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: 'Collab' }] : []),
] ]
const [activeTab, setActiveTab] = useState('plan') const [activeTab, setActiveTab] = useState(() => {
const saved = sessionStorage.getItem(`trip-tab-${tripId}`)
return saved || 'plan'
})
const handleTabChange = (tabId) => { const handleTabChange = (tabId) => {
setActiveTab(tabId) setActiveTab(tabId)
sessionStorage.setItem(`trip-tab-${tripId}`, tabId)
if (tabId === 'finanzplan') tripStore.loadBudgetItems?.(tripId) if (tabId === 'finanzplan') tripStore.loadBudgetItems?.(tripId)
if (tabId === 'dateien' && (!files || files.length === 0)) tripStore.loadFiles?.(tripId) if (tabId === 'dateien' && (!files || files.length === 0)) tripStore.loadFiles?.(tripId)
} }
@@ -63,18 +81,34 @@ 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 [editingAssignmentId, setEditingAssignmentId] = useState(null)
const [showTripForm, setShowTripForm] = useState(false) const [showTripForm, setShowTripForm] = useState(false)
const [showMembersModal, setShowMembersModal] = useState(false) const [showMembersModal, setShowMembersModal] = useState(false)
const [showReservationModal, setShowReservationModal] = useState(false) const [showReservationModal, setShowReservationModal] = useState(false)
const [editingReservation, setEditingReservation] = useState(null) const [editingReservation, setEditingReservation] = useState(null)
const [route, setRoute] = useState(null) const [route, setRoute] = useState(null)
const [routeInfo, setRouteInfo] = useState(null) const [routeInfo, setRouteInfo] = useState(null) // unused legacy
const [routeSegments, setRouteSegments] = useState([]) // { from, to, walkingText, drivingText }
const [fitKey, setFitKey] = useState(0) const [fitKey, setFitKey] = useState(0)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(null) // 'left' | 'right' | null const [mobileSidebarOpen, setMobileSidebarOpen] = useState(null) // 'left' | 'right' | null
@@ -83,6 +117,12 @@ 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()
tripsApi.getMembers(tripId).then(d => {
// Combine owner + members into one list
const all = [d.owner, ...(d.members || [])].filter(Boolean)
setTripMembers(all)
}).catch(() => {})
} }
}, [tripId]) }, [tripId])
@@ -96,9 +136,21 @@ export default function TripPlannerPage() {
const handler = useTripStore.getState().handleRemoteEvent const handler = useTripStore.getState().handleRemoteEvent
joinTrip(tripId) joinTrip(tripId)
addListener(handler) addListener(handler)
// Reload files when collab notes change (attachments sync) from WebSocket (other users)
const collabFileSync = (event) => {
if (event?.type === 'collab:note:deleted' || event?.type === 'collab:note:updated') {
tripStore.loadFiles?.(tripId)
}
}
addListener(collabFileSync)
// Reload files when local collab actions change files (own user)
const localFileSync = () => tripStore.loadFiles?.(tripId)
window.addEventListener('collab-files-changed', localFileSync)
return () => { return () => {
leaveTrip(tripId) leaveTrip(tripId)
removeListener(handler) removeListener(handler)
removeListener(collabFileSync)
window.removeEventListener('collab-files-changed', localFileSync)
} }
}, [tripId]) }, [tripId])
@@ -133,17 +185,34 @@ export default function TripPlannerPage() {
return places.filter(p => p.lat && p.lng) return places.filter(p => p.lat && p.lng)
}, [places]) }, [places])
const updateRouteForDay = useCallback((dayId) => { const routeCalcEnabled = useSettingsStore(s => s.settings.route_calculation) !== false
if (!dayId) { setRoute(null); setRouteInfo(null); return }
const updateRouteForDay = useCallback(async (dayId) => {
if (!dayId) { setRoute(null); setRouteSegments([]); return }
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) {
setRoute(waypoints.map(p => [p.lat, p.lng])) setRoute(waypoints.map(p => [p.lat, p.lng]))
if (!routeCalcEnabled) { setRouteSegments([]); return }
// Calculate per-segment travel times
const segments = []
for (let i = 0; i < waypoints.length - 1; i++) {
const from = [waypoints[i].lat, waypoints[i].lng]
const to = [waypoints[i + 1].lat, waypoints[i + 1].lng]
const mid = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
try {
const result = await calculateRoute([{ lat: from[0], lng: from[1] }, { lat: to[0], lng: to[1] }], 'walking')
segments.push({ mid, from, to, walkingText: result.walkingText, drivingText: result.drivingText })
} catch {
segments.push({ mid, from, to, walkingText: '?', drivingText: '?' })
}
}
setRouteSegments(segments)
} else { } else {
setRoute(null) setRoute(null)
setRouteSegments([])
} }
setRouteInfo(null) }, [tripStore, routeCalcEnabled])
}, [tripStore])
const handleSelectDay = useCallback((dayId, skipFit) => { const handleSelectDay = useCallback((dayId, skipFit) => {
const changed = dayId !== selectedDayId const changed = dayId !== selectedDayId
@@ -153,11 +222,15 @@ export default function TripPlannerPage() {
updateRouteForDay(dayId) updateRouteForDay(dayId)
}, [tripStore, updateRouteForDay, selectedDayId]) }, [tripStore, updateRouteForDay, selectedDayId])
const handlePlaceClick = useCallback((placeId) => { const handlePlaceClick = useCallback((placeId, assignmentId) => {
setSelectedPlaceId(placeId) if (assignmentId) {
if (placeId) { setLeftCollapsed(false); setRightCollapsed(false) } selectAssignment(assignmentId, placeId)
} else {
setSelectedPlaceId(placeId)
}
if (placeId) { setShowDayDetail(null); setLeftCollapsed(false); setRightCollapsed(false) }
updateRouteForDay(selectedDayId) updateRouteForDay(selectedDayId)
}, [selectedDayId, updateRouteForDay]) }, [selectedDayId, updateRouteForDay, selectAssignment, setSelectedPlaceId])
const handleMarkerClick = useCallback((placeId) => { const handleMarkerClick = useCallback((placeId) => {
const opening = placeId !== undefined const opening = placeId !== undefined
@@ -170,14 +243,40 @@ 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) // Always strip time fields from place update time is per-assignment only
const { place_time, end_time, ...placeData } = data
await tripStore.updatePlace(tripId, editingPlace.id, placeData)
// If editing from assignment context, save time per-assignment
if (editingAssignmentId) {
await assignmentsApi.updateTime(tripId, editingAssignmentId, { place_time: place_time || null, end_time: end_time || null })
await tripStore.refreshDays(tripId)
}
// 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, editingAssignmentId, tripId, tripStore, toast])
const handleDeletePlace = useCallback(async (placeId) => { const handleDeletePlace = useCallback(async (placeId) => {
if (!confirm(t('trip.confirm.deletePlace'))) return if (!confirm(t('trip.confirm.deletePlace'))) return
@@ -201,15 +300,14 @@ export default function TripPlannerPage() {
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => { const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
try { try {
await tripStore.removeAssignment(tripId, dayId, assignmentId) await tripStore.removeAssignment(tripId, dayId, assignmentId)
updateRouteForDay(dayId)
} }
catch (err) { toast.error(err.message) } catch (err) { toast.error(err.message) }
}, [tripId, tripStore, toast, updateRouteForDay]) }, [tripId, tripStore, toast, updateRouteForDay])
const handleReorder = useCallback(async (dayId, orderedIds) => { const handleReorder = useCallback((dayId, orderedIds) => {
try { try {
await tripStore.reorderAssignments(tripId, dayId, orderedIds) tripStore.reorderAssignments(tripId, dayId, orderedIds).catch(() => {})
// Build route directly from orderedIds to avoid stale closure // Update route immediately from orderedIds
const dayItems = tripStore.assignments[String(dayId)] || [] const dayItems = tripStore.assignments[String(dayId)] || []
const ordered = orderedIds.map(id => dayItems.find(a => a.id === id)).filter(Boolean) 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) const waypoints = ordered.map(a => a.place).filter(p => p?.lat && p?.lng)
@@ -254,10 +352,33 @@ 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])
// Auto-update route when assignments change
useEffect(() => {
if (!selectedDayId) return
const da = (assignments[String(selectedDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const waypoints = da.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)
}
}, [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
@@ -323,7 +444,9 @@ export default function TripPlannerPage() {
<div style={{ position: 'absolute', inset: 0 }}> <div style={{ position: 'absolute', inset: 0 }}>
<MapView <MapView
places={mapPlaces()} places={mapPlaces()}
dayPlaces={dayPlaces}
route={route} route={route}
routeSegments={routeSegments}
selectedPlaceId={selectedPlaceId} selectedPlaceId={selectedPlaceId}
onMarkerClick={handleMarkerClick} onMarkerClick={handleMarkerClick}
onMapClick={handleMapClick} onMapClick={handleMapClick}
@@ -332,26 +455,16 @@ 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 && (
<div style={{
position: 'absolute', bottom: selectedPlace ? 180 : 20, left: '50%', transform: 'translateX(-50%)',
background: 'rgba(255,255,255,0.95)', backdropFilter: 'blur(20px)',
borderRadius: 99, padding: '6px 20px', zIndex: 30,
boxShadow: '0 2px 16px rgba(0,0,0,0.1)',
display: 'flex', gap: 12, fontSize: 13, color: '#374151',
}}>
<span>{routeInfo.distance}</span>
<span style={{ color: '#d1d5db' }}>·</span>
<span>{routeInfo.duration}</span>
</div>
)}
<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(var(--nav-h) + 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',
@@ -383,14 +496,20 @@ 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}
onUpdateDayTitle={handleUpdateDayTitle} onUpdateDayTitle={handleUpdateDayTitle}
onAssignToDay={handleAssignToDay} onAssignToDay={handleAssignToDay}
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, walkingText: r.walkingText, drivingText: r.drivingText }) } 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) }}
onRemoveAssignment={handleRemoveAssignment}
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
accommodations={tripAccommodations}
/> />
{!leftCollapsed && ( {!leftCollapsed && (
<div <div
@@ -406,7 +525,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(var(--nav-h) + 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',
@@ -447,6 +566,8 @@ export default function TripPlannerPage() {
onPlaceClick={handlePlaceClick} onPlaceClick={handlePlaceClick}
onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }}
onAssignToDay={handleAssignToDay} onAssignToDay={handleAssignToDay}
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
/> />
</div> </div>
</div> </div>
@@ -467,20 +588,68 @@ 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={() => {
// When editing from assignment context, use assignment-level times
if (selectedAssignmentId) {
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
setEditingPlace(placeWithAssignmentTimes)
} else {
setEditingPlace(selectedPlace)
}
setEditingAssignmentId(selectedAssignmentId || null)
setShowPlaceForm(true)
}}
onDelete={() => handleDeletePlace(selectedPlace.id)} onDelete={() => handleDeletePlace(selectedPlace.id)}
onAssignToDay={handleAssignToDay} onAssignToDay={handleAssignToDay}
onRemoveAssignment={handleRemoveAssignment} onRemoveAssignment={handleRemoveAssignment}
files={files} files={files}
onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileUpload={(fd) => tripStore.addFile(tripId, fd)}
tripMembers={tripMembers}
onSetParticipants={async (assignmentId, dayId, userIds) => {
try {
const data = await assignmentsApi.setParticipants(tripId, assignmentId, userIds)
useTripStore.setState(state => ({
assignments: {
...state.assignments,
[String(dayId)]: (state.assignments[String(dayId)] || []).map(a =>
a.id === assignmentId ? { ...a, participants: data.participants } : a
),
}
}))
} catch {}
}}
/> />
)} )}
@@ -495,7 +664,7 @@ export default function TripPlannerPage() {
</div> </div>
<div style={{ flex: 1, overflow: 'auto' }}> <div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left' {mobileSidebarOpen === 'left'
? <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) }} /> ? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} 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) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} />
: <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>
@@ -529,8 +698,8 @@ export default function TripPlannerPage() {
)} )}
{activeTab === 'finanzplan' && ( {activeTab === 'finanzplan' && (
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1400, margin: '0 auto', width: '100%', padding: '8px 0' }}> <div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1800, margin: '0 auto', width: '100%', padding: '8px 0' }}>
<BudgetPanel tripId={tripId} /> <BudgetPanel tripId={tripId} tripMembers={tripMembers} />
</div> </div>
)} )}
@@ -544,15 +713,22 @@ export default function TripPlannerPage() {
places={places} places={places}
reservations={reservations} reservations={reservations}
tripId={tripId} tripId={tripId}
allowedFileTypes={allowedFileTypes}
/> />
</div> </div>
)} )}
{activeTab === 'collab' && (
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
<CollabPanel tripId={tripId} tripMembers={tripMembers} />
</div>
)}
</div> </div>
<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); setEditingAssignmentId(null) }} onSave={handleSavePlace} place={editingPlace} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} 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>
) )
} }
+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')
} }
}, },
})) }))
+109 -35
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 {
@@ -190,6 +201,20 @@ export const useTripStore = create((set, get) => ({
return { return {
budgetItems: state.budgetItems.filter(i => i.id !== payload.itemId), budgetItems: state.budgetItems.filter(i => i.id !== payload.itemId),
} }
case 'budget:members-updated':
return {
budgetItems: state.budgetItems.map(i =>
i.id === payload.itemId ? { ...i, members: payload.members, persons: payload.persons } : i
),
}
case 'budget:member-paid-updated':
return {
budgetItems: state.budgetItems.map(i =>
i.id === payload.itemId
? { ...i, members: (i.members || []).map(m => m.user_id === payload.userId ? { ...m, paid: payload.paid } : m) }
: i
),
}
// Reservations // Reservations
case 'reservation:created': case 'reservation:created':
@@ -264,6 +289,21 @@ export const useTripStore = create((set, get) => ({
} }
}, },
refreshDays: async (tripId) => {
try {
const daysData = await daysApi.list(tripId)
const assignmentsMap = {}
const dayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
} catch (err) {
console.error('Failed to refresh days:', err)
}
},
refreshPlaces: async (tripId) => { refreshPlaces: async (tripId) => {
try { try {
const data = await placesApi.list(tripId) const data = await placesApi.list(tripId)
@@ -279,7 +319,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')
} }
}, },
@@ -291,13 +331,13 @@ export const useTripStore = create((set, get) => ({
assignments: Object.fromEntries( assignments: Object.fromEntries(
Object.entries(state.assignments).map(([dayId, items]) => [ Object.entries(state.assignments).map(([dayId, items]) => [
dayId, dayId,
items.map(a => a.place?.id === placeId ? { ...a, place: data.place } : a) items.map(a => a.place?.id === placeId ? { ...a, place: { ...data.place, place_time: a.place.place_time, end_time: a.place.end_time } } : a)
]) ])
), ),
})) }))
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 +354,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 +363,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 +384,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 +429,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 +447,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 +470,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 +503,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 +537,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 +551,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 +563,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 +574,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 +602,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 +613,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 +623,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 +633,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 +651,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 +670,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 +682,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,10 +693,31 @@ 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')
} }
}, },
setBudgetItemMembers: async (tripId, itemId, userIds) => {
const result = await budgetApi.setMembers(tripId, itemId, userIds);
set(state => ({
budgetItems: state.budgetItems.map(item =>
item.id === itemId ? { ...item, members: result.members, persons: result.item.persons } : item
)
}));
return result;
},
toggleBudgetMemberPaid: async (tripId, itemId, userId, paid) => {
await budgetApi.togglePaid(tripId, itemId, userId, paid);
set(state => ({
budgetItems: state.budgetItems.map(item =>
item.id === itemId
? { ...item, members: (item.members || []).map(m => m.user_id === userId ? { ...m, paid } : m) }
: item
)
}));
},
loadFiles: async (tripId) => { loadFiles: async (tripId) => {
try { try {
const data = await filesApi.list(tripId) const data = await filesApi.list(tripId)
@@ -673,7 +733,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 +742,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 +761,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 +773,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 +797,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 +841,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 +857,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')
} }
}, },
})) }))
+2 -2
View File
@@ -1,6 +1,6 @@
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"
@@ -14,7 +14,7 @@ services:
- ./uploads:/app/uploads - ./uploads:/app/uploads
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/auth/me"] test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
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

+364 -2
View File
@@ -1,15 +1,16 @@
{ {
"name": "nomad-server", "name": "nomad-server",
"version": "2.5.1", "version": "2.5.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nomad-server", "name": "nomad-server",
"version": "2.5.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",
@@ -217,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",
@@ -236,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",
@@ -292,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",
@@ -387,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",
@@ -540,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",
@@ -559,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",
@@ -648,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",
@@ -702,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",
@@ -754,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",
@@ -803,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",
@@ -884,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",
@@ -1037,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",
@@ -1061,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",
@@ -1342,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",
@@ -1379,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",
@@ -1404,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",
@@ -1413,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",
@@ -1581,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",
@@ -1607,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",
@@ -1646,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",
@@ -1870,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",
@@ -1920,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",
@@ -1933,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",
@@ -2001,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",
+3 -2
View File
@@ -1,14 +1,15 @@
{ {
"name": "nomad-server", "name": "nomad-server",
"version": "2.5.2", "version": "2.6.0",
"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",
+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');
+321 -63
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,67 @@ 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
);
-- Collab addon tables
CREATE TABLE IF NOT EXISTS collab_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category TEXT DEFAULT 'General',
title TEXT NOT NULL,
content TEXT,
color TEXT DEFAULT '#6366f1',
pinned INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS collab_polls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
question TEXT NOT NULL,
options TEXT NOT NULL,
multiple INTEGER DEFAULT 0,
closed INTEGER DEFAULT 0,
deadline TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS collab_poll_votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
option_index INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(poll_id, user_id, option_index)
);
CREATE TABLE IF NOT EXISTS collab_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
text TEXT NOT NULL,
reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id);
CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id);
CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id);
`); `);
// Create indexes for performance // Create indexes for performance
@@ -307,55 +384,238 @@ 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);
CREATE TABLE IF NOT EXISTS assignment_participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(assignment_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id);
`); `);
// Migrations // Versioned migrations — each runs exactly once
const migrations = [ _db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)');
`ALTER TABLE users ADD COLUMN unsplash_api_key TEXT`, const versionRow = _db.prepare('SELECT version FROM schema_version').get();
`ALTER TABLE users ADD COLUMN openweather_api_key TEXT`, let currentVersion = versionRow?.version ?? 0;
`ALTER TABLE places ADD COLUMN duration_minutes INTEGER DEFAULT 60`,
`ALTER TABLE places ADD COLUMN notes TEXT`,
`ALTER TABLE places ADD COLUMN image_url TEXT`,
`ALTER TABLE places ADD COLUMN transport_mode TEXT DEFAULT 'walking'`,
`ALTER TABLE days ADD COLUMN title TEXT`,
`ALTER TABLE reservations ADD COLUMN status TEXT DEFAULT 'pending'`,
`ALTER TABLE trip_files ADD COLUMN reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL`,
`ALTER TABLE reservations ADD COLUMN type TEXT DEFAULT 'other'`,
`ALTER TABLE trips ADD COLUMN cover_image TEXT`,
`ALTER TABLE day_notes ADD COLUMN icon TEXT DEFAULT '📝'`,
`ALTER TABLE trips ADD COLUMN is_archived INTEGER DEFAULT 0`,
`ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL`,
`ALTER TABLE users ADD COLUMN avatar TEXT`,
`ALTER TABLE users ADD COLUMN oidc_sub TEXT`,
`ALTER TABLE users ADD COLUMN oidc_issuer TEXT`,
`ALTER TABLE users ADD COLUMN last_login DATETIME`,
];
// Recreate budget_items to allow NULL persons (SQLite can't ALTER NOT NULL) // Existing or fresh DBs may already have columns the migrations add.
try { // Detect by checking for a column from migration 1 (unsplash_api_key).
const hasNotNull = _db.prepare("SELECT sql FROM sqlite_master WHERE name = 'budget_items'").get() if (currentVersion === 0) {
if (hasNotNull?.sql?.includes('NOT NULL DEFAULT 1')) { 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 = [
// 118: ALTER TABLE additions
() => _db.exec('ALTER TABLE users ADD COLUMN unsplash_api_key TEXT'),
() => _db.exec('ALTER TABLE users ADD COLUMN openweather_api_key TEXT'),
() => _db.exec('ALTER TABLE places ADD COLUMN duration_minutes INTEGER DEFAULT 60'),
() => _db.exec('ALTER TABLE places ADD COLUMN notes TEXT'),
() => _db.exec('ALTER TABLE places ADD COLUMN image_url TEXT'),
() => _db.exec("ALTER TABLE places ADD COLUMN transport_mode TEXT DEFAULT 'walking'"),
() => _db.exec('ALTER TABLE days ADD COLUMN title TEXT'),
() => _db.exec("ALTER TABLE reservations ADD COLUMN status TEXT DEFAULT 'pending'"),
() => _db.exec('ALTER TABLE trip_files ADD COLUMN reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL'),
() => _db.exec("ALTER TABLE reservations ADD COLUMN type TEXT DEFAULT 'other'"),
() => _db.exec('ALTER TABLE trips ADD COLUMN cover_image TEXT'),
() => _db.exec("ALTER TABLE day_notes ADD COLUMN icon TEXT DEFAULT '📝'"),
() => _db.exec('ALTER TABLE trips ADD COLUMN is_archived INTEGER DEFAULT 0'),
() => _db.exec('ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'),
() => _db.exec('ALTER TABLE users ADD COLUMN avatar TEXT'),
() => _db.exec('ALTER TABLE users ADD COLUMN oidc_sub TEXT'),
() => _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 {}
},
// 24: Assignment participants (who's joining which activity)
() => {
_db.exec(` _db.exec(`
CREATE TABLE budget_items_new ( CREATE TABLE IF NOT EXISTS assignment_participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(assignment_id, user_id)
)
`);
},
// 25: Collab addon tables
() => {
_db.exec(`
CREATE TABLE IF NOT EXISTS collab_notes (
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', user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL, category TEXT DEFAULT 'General',
total_price REAL NOT NULL DEFAULT 0, title TEXT NOT NULL,
persons INTEGER DEFAULT NULL, content TEXT,
days INTEGER DEFAULT NULL, color TEXT DEFAULT '#6366f1',
note TEXT, pinned INTEGER DEFAULT 0,
sort_order INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS collab_polls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
question TEXT NOT NULL,
options TEXT NOT NULL,
multiple INTEGER DEFAULT 0,
closed INTEGER DEFAULT 0,
deadline TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
INSERT INTO budget_items_new SELECT * FROM budget_items; CREATE TABLE IF NOT EXISTS collab_poll_votes (
DROP TABLE budget_items; id INTEGER PRIMARY KEY AUTOINCREMENT,
ALTER TABLE budget_items_new RENAME TO budget_items; poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE,
`) user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
option_index INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(poll_id, user_id, option_index)
);
CREATE TABLE IF NOT EXISTS collab_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
text TEXT NOT NULL,
reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id);
CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id);
CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id);
`);
// Ensure collab addon exists for existing installations
try {
_db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collab', 'Collab', 'Notes, polls, and live chat for trip collaboration', 'trip', 'Users', 1, 6)").run();
} catch {}
},
// 26: Per-assignment times (instead of shared place times)
() => {
try { _db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_time TEXT'); } catch {}
try { _db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_end_time TEXT'); } catch {}
// Copy existing place times to assignments as initial values
try {
_db.exec(`
UPDATE day_assignments SET
assignment_time = (SELECT place_time FROM places WHERE places.id = day_assignments.place_id),
assignment_end_time = (SELECT end_time FROM places WHERE places.id = day_assignments.place_id)
`);
} catch {}
},
// 27: Budget item members (per-person expense tracking)
() => {
_db.exec(`
CREATE TABLE IF NOT EXISTS budget_item_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
budget_item_id INTEGER NOT NULL REFERENCES budget_items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
paid INTEGER NOT NULL DEFAULT 0,
UNIQUE(budget_item_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_budget_item_members_item ON budget_item_members(budget_item_id);
CREATE INDEX IF NOT EXISTS idx_budget_item_members_user ON budget_item_members(user_id);
`);
},
// 28: Message reactions
() => {
_db.exec(`
CREATE TABLE IF NOT EXISTS collab_message_reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES collab_messages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(message_id, user_id, emoji)
);
CREATE INDEX IF NOT EXISTS idx_collab_reactions_msg ON collab_message_reactions(message_id);
`);
},
// 29: Soft-delete for chat messages
() => {
try { _db.exec('ALTER TABLE collab_messages ADD COLUMN deleted INTEGER DEFAULT 0'); } catch {}
},
// 30: Note attachments + website field
() => {
try { _db.exec('ALTER TABLE trip_files ADD COLUMN note_id INTEGER REFERENCES collab_notes(id) ON DELETE SET NULL'); } catch {}
try { _db.exec('ALTER TABLE collab_notes ADD COLUMN website TEXT'); } catch {}
},
// Future migrations go here (append only, never reorder)
];
if (currentVersion < migrations.length) {
for (let i = currentVersion; i < migrations.length; i++) {
console.log(`[DB] Running migration ${i + 1}/${migrations.length}`);
migrations[i]();
} }
} 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 +627,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);
@@ -384,21 +644,19 @@ function initDb() {
console.error('Error seeding categories:', err.message); console.error('Error seeding categories:', err.message);
} }
// Seed: default addons // Seed: default addons (INSERT OR IGNORE so migration-inserted rows don't block seeding)
try { try {
const existingAddons = _db.prepare('SELECT COUNT(*) as count FROM addons').get(); const defaultAddons = [
if (existingAddons.count === 0) { { id: 'packing', name: 'Packing List', description: 'Pack your bags with checklists per trip', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
const defaultAddons = [ { id: 'budget', name: 'Budget Planner', description: 'Track expenses and plan your travel budget', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
{ id: 'packing', name: 'Packing List', description: 'Pack your bags with checklists per trip', type: 'trip', icon: 'ListChecks', sort_order: 0 }, { id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
{ id: 'budget', name: 'Budget Planner', description: 'Track expenses and plan your travel budget', type: 'trip', icon: 'Wallet', sort_order: 1 }, { id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', sort_order: 2 }, { id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', sort_order: 10 }, { id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', sort_order: 11 }, ];
]; const insertAddon = _db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
const insertAddon = _db.prepare('INSERT INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, 1, ?)'); for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.sort_order); console.log('Default addons seeded');
console.log('Default addons seeded');
}
} catch (err) { } catch (err) {
console.error('Error seeding addons:', err.message); console.error('Error seeding addons:', err.message);
} }
+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);
+26
View File
@@ -57,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');
@@ -70,6 +71,7 @@ const dayNotesRoutes = require('./routes/dayNotes');
const weatherRoutes = require('./routes/weather'); const weatherRoutes = require('./routes/weather');
const settingsRoutes = require('./routes/settings'); const settingsRoutes = require('./routes/settings');
const budgetRoutes = require('./routes/budget'); const budgetRoutes = require('./routes/budget');
const collabRoutes = require('./routes/collab');
const backupRoutes = require('./routes/backup'); const backupRoutes = require('./routes/backup');
const oidcRoutes = require('./routes/oidc'); const oidcRoutes = require('./routes/oidc');
@@ -77,12 +79,15 @@ 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);
app.use('/api/trips/:tripId/budget', budgetRoutes); app.use('/api/trips/:tripId/budget', budgetRoutes);
app.use('/api/trips/:tripId/collab', collabRoutes);
app.use('/api/trips/:tripId/reservations', reservationsRoutes); app.use('/api/trips/:tripId/reservations', reservationsRoutes);
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes); app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
app.use('/api', assignmentsRoutes); app.use('/api', assignmentsRoutes);
app.use('/api/tags', tagsRoutes); app.use('/api/tags', tagsRoutes);
app.use('/api/categories', categoriesRoutes); app.use('/api/categories', categoriesRoutes);
@@ -135,4 +140,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) => {
+102 -23
View File
@@ -13,7 +13,9 @@ 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, COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as 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
@@ -30,11 +32,19 @@ function getAssignmentWithPlace(assignmentId) {
WHERE pt.place_id = ? WHERE pt.place_id = ?
`).all(a.place_id); `).all(a.place_id);
const participants = db.prepare(`
SELECT ap.user_id, u.username, u.avatar
FROM assignment_participants ap
JOIN users u ON ap.user_id = u.id
WHERE ap.assignment_id = ?
`).all(a.id);
return { return {
id: a.id, id: a.id,
day_id: a.day_id, day_id: a.day_id,
order_index: a.order_index, order_index: a.order_index,
notes: a.notes, notes: a.notes,
participants,
created_at: a.created_at, created_at: a.created_at,
place: { place: {
id: a.place_id, id: a.place_id,
@@ -46,10 +56,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 +81,17 @@ 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, COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as 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
@@ -107,12 +117,25 @@ router.get('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) =>
} }
} }
// Load all participants for this day's assignments in one query
const assignmentIds = assignments.map(a => a.id)
const allParticipants = assignmentIds.length > 0
? db.prepare(`SELECT ap.assignment_id, ap.user_id, u.username, u.avatar FROM assignment_participants ap JOIN users u ON ap.user_id = u.id WHERE ap.assignment_id IN (${assignmentIds.map(() => '?').join(',')})`)
.all(...assignmentIds)
: []
const participantsByAssignment = {}
for (const p of allParticipants) {
if (!participantsByAssignment[p.assignment_id]) participantsByAssignment[p.assignment_id] = []
participantsByAssignment[p.assignment_id].push({ user_id: p.user_id, username: p.username, avatar: p.avatar })
}
const result = assignments.map(a => { const result = assignments.map(a => {
return { return {
id: a.id, id: a.id,
day_id: a.day_id, day_id: a.day_id,
order_index: a.order_index, order_index: a.order_index,
notes: a.notes, notes: a.notes,
participants: participantsByAssignment[a.id] || [],
created_at: a.created_at, created_at: a.created_at,
place: { place: {
id: a.place_id, id: a.place_id,
@@ -124,9 +147,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 +175,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 +200,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 +219,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 +245,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 +253,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);
@@ -249,4 +266,66 @@ router.put('/trips/:tripId/assignments/:id/move', authenticate, (req, res) => {
broadcast(tripId, 'assignment:moved', { assignment: updated, oldDayId: Number(oldDayId), newDayId: Number(new_day_id) }, req.headers['x-socket-id']); broadcast(tripId, 'assignment:moved', { assignment: updated, oldDayId: Number(oldDayId), newDayId: Number(new_day_id) }, req.headers['x-socket-id']);
}); });
// GET /api/trips/:tripId/assignments/:id/participants
router.get('/trips/:tripId/assignments/:id/participants', authenticate, (req, res) => {
const { tripId, id } = req.params;
if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const participants = db.prepare(`
SELECT ap.user_id, u.username, u.avatar
FROM assignment_participants ap
JOIN users u ON ap.user_id = u.id
WHERE ap.assignment_id = ?
`).all(id);
res.json({ participants });
});
// PUT /api/trips/:tripId/assignments/:id/time — update per-assignment time
router.put('/trips/:tripId/assignments/:id/time', authenticate, (req, res) => {
const { tripId, id } = req.params;
if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const assignment = db.prepare(`
SELECT da.* FROM day_assignments da
JOIN days d ON da.day_id = d.id
WHERE da.id = ? AND d.trip_id = ?
`).get(id, tripId);
if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
const { place_time, end_time } = req.body;
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
.run(place_time ?? null, end_time ?? null, id);
const updated = getAssignmentWithPlace(id);
res.json({ assignment: updated });
broadcast(Number(tripId), 'assignment:updated', { assignment: updated }, req.headers['x-socket-id']);
});
// PUT /api/trips/:tripId/assignments/:id/participants — set participants (replace all)
router.put('/trips/:tripId/assignments/:id/participants', authenticate, (req, res) => {
const { tripId, id } = req.params;
if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const { user_ids } = req.body; // array of user IDs, empty array = everyone
if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' });
// Delete existing and insert new
db.prepare('DELETE FROM assignment_participants WHERE assignment_id = ?').run(id);
if (user_ids.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)');
for (const userId of user_ids) insert.run(id, userId);
}
const participants = db.prepare(`
SELECT ap.user_id, u.username, u.avatar
FROM assignment_participants ap
JOIN users u ON ap.user_id = u.id
WHERE ap.assignment_id = ?
`).all(id);
res.json({ participants });
broadcast(Number(tripId), 'assignment:participants', { assignmentId: Number(id), participants }, req.headers['x-socket-id']);
});
module.exports = router; module.exports = router;
+10 -6
View File
@@ -80,6 +80,7 @@ router.get('/app-config', (req, res) => {
has_maps_key: hasGoogleKey, has_maps_key: hasGoogleKey,
oidc_configured: oidcConfigured, oidc_configured: oidcConfigured,
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined, oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
allowed_file_types: db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get()?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
demo_mode: isDemo, demo_mode: isDemo,
demo_email: isDemo ? 'demo@nomad.app' : undefined, demo_email: isDemo ? 'demo@nomad.app' : undefined,
demo_password: isDemo ? 'demo12345' : undefined, demo_password: isDemo ? 'demo12345' : undefined,
@@ -145,7 +146,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 +155,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 +182,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) } });
@@ -368,10 +369,13 @@ router.put('/app-settings', authenticate, (req, res) => {
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user.id);
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' }); if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
const { allow_registration } = req.body; const { allow_registration, allowed_file_types } = req.body;
if (allow_registration !== undefined) { if (allow_registration !== undefined) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', ?)").run(String(allow_registration)); db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', ?)").run(String(allow_registration));
} }
if (allowed_file_types !== undefined) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', ?)").run(String(allowed_file_types));
}
res.json({ success: true }); res.json({ success: true });
}); });
+9 -9
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,7 +132,7 @@ 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)
@@ -173,7 +173,7 @@ async function restoreFromZip(zipPath, res) {
} 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' });
} }
} }
@@ -185,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);
}); });
@@ -195,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);
@@ -235,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);
+114 -8
View File
@@ -9,29 +9,81 @@ function verifyTripOwnership(tripId, userId) {
return canAccessTrip(tripId, userId); return canAccessTrip(tripId, userId);
} }
function loadItemMembers(itemId) {
return db.prepare(`
SELECT bm.user_id, bm.paid, u.username, u.avatar
FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id = ?
`).all(itemId);
}
function avatarUrl(user) {
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
}
// GET /api/trips/:tripId/budget // GET /api/trips/:tripId/budget
router.get('/', authenticate, (req, res) => { 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'
).all(tripId); ).all(tripId);
// Batch-load all members
const itemIds = items.map(i => i.id);
const membersByItem = {};
if (itemIds.length > 0) {
const allMembers = db.prepare(`
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id IN (${itemIds.map(() => '?').join(',')})
`).all(...itemIds);
for (const m of allMembers) {
if (!membersByItem[m.budget_item_id]) membersByItem[m.budget_item_id] = [];
membersByItem[m.budget_item_id].push({
user_id: m.user_id, paid: m.paid, username: m.username, avatar_url: avatarUrl(m)
});
}
}
items.forEach(item => { item.members = membersByItem[item.id] || []; });
res.json({ items }); res.json({ items });
}); });
// GET /api/trips/:tripId/budget/summary/per-person (must be before /:id routes)
router.get('/summary/per-person', authenticate, (req, res) => {
const { tripId } = req.params;
if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const summary = db.prepare(`
SELECT bm.user_id, u.username, u.avatar,
SUM(bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) as total_assigned,
SUM(CASE WHEN bm.paid = 1 THEN bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id) ELSE 0 END) as total_paid,
COUNT(bi.id) as items_count
FROM budget_item_members bm
JOIN budget_items bi ON bm.budget_item_id = bi.id
JOIN users u ON bm.user_id = u.id
WHERE bi.trip_id = ?
GROUP BY bm.user_id
`).all(tripId);
res.json({ summary: summary.map(s => ({ ...s, avatar_url: avatarUrl(s) })) });
});
// POST /api/trips/:tripId/budget // POST /api/trips/:tripId/budget
router.post('/', authenticate, (req, res) => { router.post('/', authenticate, (req, res) => {
const { tripId } = req.params; const { tripId } = req.params;
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 +92,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,
@@ -50,6 +102,7 @@ router.post('/', authenticate, (req, res) => {
); );
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid); const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid);
item.members = [];
res.status(201).json({ item }); res.status(201).json({ item });
broadcast(tripId, 'budget:created', { item }, req.headers['x-socket-id']); broadcast(tripId, 'budget:created', { item }, req.headers['x-socket-id']);
}); });
@@ -60,10 +113,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
@@ -87,19 +140,72 @@ router.put('/:id', authenticate, (req, res) => {
); );
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id); const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id);
updated.members = loadItemMembers(id);
res.json({ item: updated }); res.json({ item: updated });
broadcast(tripId, 'budget:updated', { item: updated }, req.headers['x-socket-id']); broadcast(tripId, 'budget:updated', { item: updated }, req.headers['x-socket-id']);
}); });
// PUT /api/trips/:tripId/budget/:id/members
router.put('/:id/members', authenticate, (req, res) => {
const { tripId, id } = req.params;
if (!canAccessTrip(Number(tripId), req.user.id)) 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);
if (!item) return res.status(404).json({ error: 'Budget item not found' });
const { user_ids } = req.body;
if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' });
// Preserve paid status for existing members
const existingPaid = {};
const existing = db.prepare('SELECT user_id, paid FROM budget_item_members WHERE budget_item_id = ?').all(id);
for (const e of existing) existingPaid[e.user_id] = e.paid;
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
if (user_ids.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, ?)');
for (const userId of user_ids) insert.run(id, userId, existingPaid[userId] || 0);
// Auto-update persons count
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(user_ids.length, id);
} else {
db.prepare('UPDATE budget_items SET persons = NULL WHERE id = ?').run(id);
}
const members = loadItemMembers(id).map(m => ({ ...m, avatar_url: avatarUrl(m) }));
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id);
res.json({ members, item: updated });
broadcast(Number(tripId), 'budget:members-updated', { itemId: Number(id), members, persons: updated.persons }, req.headers['x-socket-id']);
});
// PUT /api/trips/:tripId/budget/:id/members/:userId/paid
router.put('/:id/members/:userId/paid', authenticate, (req, res) => {
const { tripId, id, userId } = req.params;
if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const { paid } = req.body;
db.prepare('UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?')
.run(paid ? 1 : 0, id, userId);
const member = db.prepare(`
SELECT bm.user_id, bm.paid, u.username, u.avatar
FROM budget_item_members bm JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id = ? AND bm.user_id = ?
`).get(id, userId);
const result = member ? { ...member, avatar_url: avatarUrl(member) } : null;
res.json({ member: result });
broadcast(Number(tripId), 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, req.headers['x-socket-id']);
});
// DELETE /api/trips/:tripId/budget/:id // DELETE /api/trips/:tripId/budget/:id
router.delete('/:id', authenticate, (req, res) => { 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 });
+486
View File
@@ -0,0 +1,486 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const { broadcast } = require('../websocket');
const filesDir = path.join(__dirname, '../../uploads/files');
const noteUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => { if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true }); cb(null, filesDir) },
filename: (req, file, cb) => { cb(null, `${uuidv4()}${path.extname(file.originalname)}`) },
}),
limits: { fileSize: 50 * 1024 * 1024 },
});
const router = express.Router({ mergeParams: true });
function verifyTripAccess(tripId, userId) {
return canAccessTrip(tripId, userId);
}
function avatarUrl(user) {
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
}
function formatNote(note) {
const attachments = db.prepare('SELECT id, filename, original_name, file_size, mime_type FROM trip_files WHERE note_id = ?').all(note.id);
return {
...note,
avatar_url: avatarUrl(note),
attachments: attachments.map(a => ({ ...a, url: `/uploads/${a.filename}` })),
};
}
function loadReactions(messageId) {
return db.prepare(`
SELECT r.emoji, r.user_id, u.username
FROM collab_message_reactions r
JOIN users u ON r.user_id = u.id
WHERE r.message_id = ?
`).all(messageId);
}
function groupReactions(reactions) {
const map = {};
for (const r of reactions) {
if (!map[r.emoji]) map[r.emoji] = [];
map[r.emoji].push({ user_id: r.user_id, username: r.username });
}
return Object.entries(map).map(([emoji, users]) => ({ emoji, users, count: users.length }));
}
function formatMessage(msg, reactions) {
return { ...msg, user_avatar: avatarUrl(msg), avatar_url: avatarUrl(msg), reactions: reactions || [] };
}
// ─── NOTES ───────────────────────────────────────────────────────────────────
// GET /notes
router.get('/notes', authenticate, (req, res) => {
const { tripId } = req.params;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const notes = db.prepare(`
SELECT n.*, u.username, u.avatar
FROM collab_notes n
JOIN users u ON n.user_id = u.id
WHERE n.trip_id = ?
ORDER BY n.pinned DESC, n.updated_at DESC
`).all(tripId);
res.json({ notes: notes.map(formatNote) });
});
// POST /notes
router.post('/notes', authenticate, (req, res) => {
const { tripId } = req.params;
const { title, content, category, color, website } = req.body;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!title) return res.status(400).json({ error: 'Title is required' });
const result = db.prepare(`
INSERT INTO collab_notes (trip_id, user_id, title, content, category, color, website)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(tripId, req.user.id, title, content || null, category || 'General', color || '#6366f1', website || null);
const note = db.prepare(`
SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?
`).get(result.lastInsertRowid);
const formatted = formatNote(note);
res.status(201).json({ note: formatted });
broadcast(tripId, 'collab:note:created', { note: formatted }, req.headers['x-socket-id']);
});
// PUT /notes/:id
router.put('/notes/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { title, content, category, color, pinned, website } = req.body;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const existing = db.prepare('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existing) return res.status(404).json({ error: 'Note not found' });
db.prepare(`
UPDATE collab_notes SET
title = COALESCE(?, title),
content = CASE WHEN ? THEN ? ELSE content END,
category = COALESCE(?, category),
color = COALESCE(?, color),
pinned = CASE WHEN ? IS NOT NULL THEN ? ELSE pinned END,
website = CASE WHEN ? THEN ? ELSE website END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
title || null,
content !== undefined ? 1 : 0, content !== undefined ? content : null,
category || null,
color || null,
pinned !== undefined ? 1 : null, pinned ? 1 : 0,
website !== undefined ? 1 : 0, website !== undefined ? website : null,
id
);
const note = db.prepare(`
SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?
`).get(id);
const formatted = formatNote(note);
res.json({ note: formatted });
broadcast(tripId, 'collab:note:updated', { note: formatted }, req.headers['x-socket-id']);
});
// DELETE /notes/:id
router.delete('/notes/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const existing = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existing) return res.status(404).json({ error: 'Note not found' });
// Delete attached files (physical + DB)
const noteFiles = db.prepare('SELECT id, filename FROM trip_files WHERE note_id = ?').all(id);
for (const f of noteFiles) {
const filePath = path.join(__dirname, '../../uploads', f.filename);
try { fs.unlinkSync(filePath) } catch {}
}
db.prepare('DELETE FROM trip_files WHERE note_id = ?').run(id);
db.prepare('DELETE FROM collab_notes WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'collab:note:deleted', { noteId: Number(id) }, req.headers['x-socket-id']);
});
// POST /notes/:id/files — upload attachment to note
router.post('/notes/:id/files', authenticate, noteUpload.single('file'), (req, res) => {
const { tripId, id } = req.params;
if (!verifyTripAccess(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const note = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!note) return res.status(404).json({ error: 'Note not found' });
const result = db.prepare(
'INSERT INTO trip_files (trip_id, note_id, filename, original_name, file_size, mime_type) VALUES (?, ?, ?, ?, ?, ?)'
).run(tripId, id, `files/${req.file.filename}`, req.file.originalname, req.file.size, req.file.mimetype);
const file = db.prepare('SELECT * FROM trip_files WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ file: { ...file, url: `/uploads/${file.filename}` } });
broadcast(Number(tripId), 'collab:note:updated', { note: formatNote(db.prepare('SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?').get(id)) }, req.headers['x-socket-id']);
});
// DELETE /notes/:id/files/:fileId — remove attachment
router.delete('/notes/:id/files/:fileId', authenticate, (req, res) => {
const { tripId, id, fileId } = req.params;
if (!verifyTripAccess(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND note_id = ?').get(fileId, id);
if (!file) return res.status(404).json({ error: 'File not found' });
// Delete physical file
const filePath = path.join(__dirname, '../../uploads', file.filename);
try { fs.unlinkSync(filePath) } catch {}
db.prepare('DELETE FROM trip_files WHERE id = ?').run(fileId);
res.json({ success: true });
broadcast(Number(tripId), 'collab:note:updated', { note: formatNote(db.prepare('SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?').get(id)) }, req.headers['x-socket-id']);
});
// ─── POLLS ───────────────────────────────────────────────────────────────────
function getPollWithVotes(pollId) {
const poll = db.prepare(`
SELECT p.*, u.username, u.avatar
FROM collab_polls p
JOIN users u ON p.user_id = u.id
WHERE p.id = ?
`).get(pollId);
if (!poll) return null;
const options = JSON.parse(poll.options);
const votes = db.prepare(`
SELECT v.option_index, v.user_id, u.username, u.avatar
FROM collab_poll_votes v
JOIN users u ON v.user_id = u.id
WHERE v.poll_id = ?
`).all(pollId);
// Transform: nest voters into each option (frontend expects options[i].voters)
const formattedOptions = options.map((label, idx) => ({
label: typeof label === 'string' ? label : label.label || label,
voters: votes
.filter(v => v.option_index === idx)
.map(v => ({ id: v.user_id, user_id: v.user_id, username: v.username, avatar: v.avatar, avatar_url: avatarUrl(v) })),
}));
return {
...poll,
avatar_url: avatarUrl(poll),
options: formattedOptions,
is_closed: !!poll.closed,
multiple_choice: !!poll.multiple,
};
}
// GET /polls
router.get('/polls', authenticate, (req, res) => {
const { tripId } = req.params;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const rows = db.prepare(`
SELECT id FROM collab_polls WHERE trip_id = ? ORDER BY created_at DESC
`).all(tripId);
const polls = rows.map(row => getPollWithVotes(row.id)).filter(Boolean);
res.json({ polls });
});
// POST /polls
router.post('/polls', authenticate, (req, res) => {
const { tripId } = req.params;
const { question, options, multiple, multiple_choice, deadline } = req.body;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!question) return res.status(400).json({ error: 'Question is required' });
if (!Array.isArray(options) || options.length < 2) {
return res.status(400).json({ error: 'At least 2 options are required' });
}
// Accept both 'multiple' and 'multiple_choice' from frontend
const isMultiple = multiple || multiple_choice;
const result = db.prepare(`
INSERT INTO collab_polls (trip_id, user_id, question, options, multiple, deadline)
VALUES (?, ?, ?, ?, ?, ?)
`).run(tripId, req.user.id, question, JSON.stringify(options), isMultiple ? 1 : 0, deadline || null);
const poll = getPollWithVotes(result.lastInsertRowid);
res.status(201).json({ poll });
broadcast(tripId, 'collab:poll:created', { poll }, req.headers['x-socket-id']);
});
// POST /polls/:id/vote
router.post('/polls/:id/vote', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { option_index } = req.body;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
if (poll.closed) return res.status(400).json({ error: 'Poll is closed' });
const options = JSON.parse(poll.options);
if (option_index < 0 || option_index >= options.length) {
return res.status(400).json({ error: 'Invalid option index' });
}
// Toggle: if vote exists, remove it; otherwise add it
const existingVote = db.prepare(
'SELECT id FROM collab_poll_votes WHERE poll_id = ? AND user_id = ? AND option_index = ?'
).get(id, req.user.id, option_index);
if (existingVote) {
db.prepare('DELETE FROM collab_poll_votes WHERE id = ?').run(existingVote.id);
} else {
if (!poll.multiple) {
db.prepare('DELETE FROM collab_poll_votes WHERE poll_id = ? AND user_id = ?').run(id, req.user.id);
}
db.prepare('INSERT INTO collab_poll_votes (poll_id, user_id, option_index) VALUES (?, ?, ?)').run(id, req.user.id, option_index);
}
const updatedPoll = getPollWithVotes(id);
res.json({ poll: updatedPoll });
broadcast(tripId, 'collab:poll:voted', { poll: updatedPoll }, req.headers['x-socket-id']);
});
// PUT /polls/:id/close
router.put('/polls/:id/close', authenticate, (req, res) => {
const { tripId, id } = req.params;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
db.prepare('UPDATE collab_polls SET closed = 1 WHERE id = ?').run(id);
const updatedPoll = getPollWithVotes(id);
res.json({ poll: updatedPoll });
broadcast(tripId, 'collab:poll:closed', { poll: updatedPoll }, req.headers['x-socket-id']);
});
// DELETE /polls/:id
router.delete('/polls/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const poll = db.prepare('SELECT id FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
db.prepare('DELETE FROM collab_polls WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'collab:poll:deleted', { pollId: Number(id) }, req.headers['x-socket-id']);
});
// ─── MESSAGES (CHAT) ────────────────────────────────────────────────────────
// GET /messages
router.get('/messages', authenticate, (req, res) => {
const { tripId } = req.params;
const { before } = req.query;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const query = `
SELECT m.*, u.username, u.avatar,
rm.text AS reply_text, ru.username AS reply_username
FROM collab_messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN collab_messages rm ON m.reply_to = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.trip_id = ?${before ? ' AND m.id < ?' : ''}
ORDER BY m.id DESC
LIMIT 100
`;
const messages = before
? db.prepare(query).all(tripId, before)
: db.prepare(query).all(tripId);
messages.reverse();
// Batch-load reactions
const msgIds = messages.map(m => m.id);
const reactionsByMsg = {};
if (msgIds.length > 0) {
const allReactions = db.prepare(`
SELECT r.message_id, r.emoji, r.user_id, u.username
FROM collab_message_reactions r
JOIN users u ON r.user_id = u.id
WHERE r.message_id IN (${msgIds.map(() => '?').join(',')})
`).all(...msgIds);
for (const r of allReactions) {
if (!reactionsByMsg[r.message_id]) reactionsByMsg[r.message_id] = [];
reactionsByMsg[r.message_id].push(r);
}
}
res.json({ messages: messages.map(m => formatMessage(m, groupReactions(reactionsByMsg[m.id] || []))) });
});
// POST /messages
router.post('/messages', authenticate, (req, res) => {
const { tripId } = req.params;
const { text, reply_to } = req.body;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!text || !text.trim()) return res.status(400).json({ error: 'Message text is required' });
if (reply_to) {
const replyMsg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(reply_to, tripId);
if (!replyMsg) return res.status(400).json({ error: 'Reply target message not found' });
}
const result = db.prepare(`
INSERT INTO collab_messages (trip_id, user_id, text, reply_to) VALUES (?, ?, ?, ?)
`).run(tripId, req.user.id, text.trim(), reply_to || null);
const message = db.prepare(`
SELECT m.*, u.username, u.avatar,
rm.text AS reply_text, ru.username AS reply_username
FROM collab_messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN collab_messages rm ON m.reply_to = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.id = ?
`).get(result.lastInsertRowid);
const formatted = formatMessage(message);
res.status(201).json({ message: formatted });
broadcast(tripId, 'collab:message:created', { message: formatted }, req.headers['x-socket-id']);
});
// POST /messages/:id/react — toggle emoji reaction
router.post('/messages/:id/react', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { emoji } = req.body;
if (!verifyTripAccess(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!emoji) return res.status(400).json({ error: 'Emoji is required' });
const msg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!msg) return res.status(404).json({ error: 'Message not found' });
const existing = db.prepare('SELECT id FROM collab_message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?').get(id, req.user.id, emoji);
if (existing) {
db.prepare('DELETE FROM collab_message_reactions WHERE id = ?').run(existing.id);
} else {
db.prepare('INSERT INTO collab_message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(id, req.user.id, emoji);
}
const reactions = groupReactions(loadReactions(id));
res.json({ reactions });
broadcast(Number(tripId), 'collab:message:reacted', { messageId: Number(id), reactions }, req.headers['x-socket-id']);
});
// DELETE /messages/:id (soft-delete)
router.delete('/messages/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const message = db.prepare('SELECT * FROM collab_messages WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!message) return res.status(404).json({ error: 'Message not found' });
if (Number(message.user_id) !== Number(req.user.id)) return res.status(403).json({ error: 'You can only delete your own messages' });
db.prepare('UPDATE collab_messages SET deleted = 1 WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'collab:message:deleted', { messageId: Number(id), username: message.username || req.user.username }, req.headers['x-socket-id']);
});
// ─── LINK PREVIEW ────────────────────────────────────────────────────────────
router.get('/link-preview', authenticate, (req, res) => {
const { url } = req.query;
if (!url) return res.status(400).json({ error: 'URL is required' });
try {
const fetch = require('node-fetch');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
fetch(url, {
signal: controller.signal,
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NOMAD/1.0; +https://github.com/mauriceboe/NOMAD)' },
})
.then(r => {
clearTimeout(timeout);
if (!r.ok) throw new Error('Fetch failed');
return r.text();
})
.then(html => {
const get = (prop) => {
const m = html.match(new RegExp(`<meta[^>]*property=["']og:${prop}["'][^>]*content=["']([^"']*)["']`, 'i'))
|| html.match(new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*property=["']og:${prop}["']`, 'i'));
return m ? m[1] : null;
};
const titleTag = html.match(/<title[^>]*>([^<]*)<\/title>/i);
const descMeta = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/i)
|| html.match(/<meta[^>]*content=["']([^"']*)["'][^>]*name=["']description["']/i);
res.json({
title: get('title') || (titleTag ? titleTag[1].trim() : null),
description: get('description') || (descMeta ? descMeta[1].trim() : null),
image: get('image') || null,
site_name: get('site_name') || null,
url,
});
})
.catch(() => {
clearTimeout(timeout);
res.json({ title: null, description: null, image: null, url });
});
} catch {
res.json({ title: null, description: null, image: null, url });
}
});
module.exports = router;
+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 });
+172 -14
View File
@@ -13,7 +13,9 @@ 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, COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as 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 +48,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 +75,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 +91,9 @@ 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, COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as 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
@@ -119,6 +121,18 @@ router.get('/', authenticate, (req, res) => {
// Group assignments by day_id // Group assignments by day_id
const assignmentsByDayId = {}; const assignmentsByDayId = {};
// Load all participants for all assignments
const allAssignmentIds = allAssignments.map(a => a.id)
const allParticipants = allAssignmentIds.length > 0
? db.prepare(`SELECT ap.assignment_id, ap.user_id, u.username, u.avatar FROM assignment_participants ap JOIN users u ON ap.user_id = u.id WHERE ap.assignment_id IN (${allAssignmentIds.map(() => '?').join(',')})`)
.all(...allAssignmentIds)
: []
const participantsByAssignment = {}
for (const p of allParticipants) {
if (!participantsByAssignment[p.assignment_id]) participantsByAssignment[p.assignment_id] = []
participantsByAssignment[p.assignment_id].push({ user_id: p.user_id, username: p.username, avatar: p.avatar })
}
for (const a of allAssignments) { for (const a of allAssignments) {
if (!assignmentsByDayId[a.day_id]) assignmentsByDayId[a.day_id] = []; if (!assignmentsByDayId[a.day_id]) assignmentsByDayId[a.day_id] = [];
assignmentsByDayId[a.day_id].push({ assignmentsByDayId[a.day_id].push({
@@ -126,6 +140,7 @@ router.get('/', authenticate, (req, res) => {
day_id: a.day_id, day_id: a.day_id,
order_index: a.order_index, order_index: a.order_index,
notes: a.notes, notes: a.notes,
participants: participantsByAssignment[a.id] || [],
created_at: a.created_at, created_at: a.created_at,
place: { place: {
id: a.place_id, id: a.place_id,
@@ -137,10 +152,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 +197,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 +222,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 +245,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 +258,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;
+23 -21
View File
@@ -22,28 +22,30 @@ const storage = multer.diskStorage({
}, },
}); });
const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv';
const BLOCKED_EXTENSIONS = ['.svg', '.html', '.htm', '.xml'];
function getAllowedExtensions() {
try {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get();
return row?.value || DEFAULT_ALLOWED_EXTENSIONS;
} catch { return DEFAULT_ALLOWED_EXTENSIONS; }
}
const upload = multer({ const upload = multer({
storage, storage,
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB limits: { fileSize: 50 * 1024 * 1024 }, // 50MB
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
const allowed = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
'text/csv',
];
const ext = path.extname(file.originalname).toLowerCase(); const ext = path.extname(file.originalname).toLowerCase();
const blockedExts = ['.svg', '.html', '.htm', '.xml']; if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) {
if (blockedExts.includes(ext) || file.mimetype.includes('svg')) {
return cb(new Error('File type not allowed')); return cb(new Error('File type not allowed'));
} }
if (allowed.includes(file.mimetype) || file.mimetype.startsWith('image/')) { const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
const fileExt = ext.replace('.', '');
if (allowed.includes(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) {
cb(null, true); cb(null, true);
} else { } else {
cb(new Error('Dateityp nicht erlaubt')); cb(new Error('File type not allowed'));
} }
}, },
}); });
@@ -55,7 +57,7 @@ function verifyTripOwnership(tripId, userId) {
function formatFile(file) { function formatFile(file) {
return { return {
...file, ...file,
url: `/uploads/files/${file.filename}`, url: file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`,
}; };
} }
@@ -64,7 +66,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
@@ -84,11 +86,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(`
@@ -121,10 +123,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
@@ -154,10 +156,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' });
} }
}); });
+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) => {
+7 -7
View File
@@ -48,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];
@@ -78,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(`
@@ -122,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
@@ -149,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 });
+18 -18
View File
@@ -79,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)
@@ -102,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;
@@ -146,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(/^\//, ''));
@@ -169,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 });
@@ -181,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(`
@@ -208,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);
@@ -234,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 });
}); });
+364 -55
View File
@@ -1,6 +1,5 @@
const express = require('express'); const express = require('express');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const { db } = require('../db/database');
const { authenticate } = require('../middleware/auth'); const { authenticate } = require('../middleware/auth');
const router = express.Router(); const router = express.Router();
@@ -10,11 +9,12 @@ const weatherCache = new Map();
const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour
const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes
const TTL_CLIMATE_MS = 24 * 60 * 60 * 1000; // 24 hours (historical data doesn't change)
function cacheKey(lat, lng, date, units) { function cacheKey(lat, lng, date) {
const rlat = parseFloat(lat).toFixed(2); const rlat = parseFloat(lat).toFixed(2);
const rlng = parseFloat(lng).toFixed(2); const rlng = parseFloat(lng).toFixed(2);
return `${rlat}_${rlng}_${date || 'current'}_${units}`; return `${rlat}_${rlng}_${date || 'current'}`;
} }
function getCached(key) { function getCached(key) {
@@ -30,46 +30,123 @@ function getCached(key) {
function setCache(key, data, ttlMs) { function setCache(key, data, ttlMs) {
weatherCache.set(key, { data, expiresAt: Date.now() + ttlMs }); weatherCache.set(key, { data, expiresAt: Date.now() + ttlMs });
} }
// WMO weather code mapping → condition string used by client icon map
const WMO_MAP = {
0: 'Clear',
1: 'Clear', // mainly clear
2: 'Clouds', // partly cloudy
3: 'Clouds', // overcast
45: 'Fog',
48: 'Fog',
51: 'Drizzle',
53: 'Drizzle',
55: 'Drizzle',
56: 'Drizzle', // freezing drizzle
57: 'Drizzle',
61: 'Rain',
63: 'Rain',
65: 'Rain', // heavy rain
66: 'Rain', // freezing rain
67: 'Rain',
71: 'Snow',
73: 'Snow',
75: 'Snow',
77: 'Snow', // snow grains
80: 'Rain', // rain showers
81: 'Rain',
82: 'Rain',
85: 'Snow', // snow showers
86: 'Snow',
95: 'Thunderstorm',
96: 'Thunderstorm',
99: 'Thunderstorm',
};
const WMO_DESCRIPTION_DE = {
0: 'Klar',
1: 'Überwiegend klar',
2: 'Teilweise bewölkt',
3: 'Bewölkt',
45: 'Nebel',
48: 'Nebel mit Reif',
51: 'Leichter Nieselregen',
53: 'Nieselregen',
55: 'Starker Nieselregen',
56: 'Gefrierender Nieselregen',
57: 'Starker gefr. Nieselregen',
61: 'Leichter Regen',
63: 'Regen',
65: 'Starker Regen',
66: 'Gefrierender Regen',
67: 'Starker gefr. Regen',
71: 'Leichter Schneefall',
73: 'Schneefall',
75: 'Starker Schneefall',
77: 'Schneekörner',
80: 'Leichte Regenschauer',
81: 'Regenschauer',
82: 'Starke Regenschauer',
85: 'Leichte Schneeschauer',
86: 'Starke Schneeschauer',
95: 'Gewitter',
96: 'Gewitter mit Hagel',
99: 'Starkes Gewitter mit Hagel',
};
const WMO_DESCRIPTION_EN = {
0: 'Clear sky',
1: 'Mainly clear',
2: 'Partly cloudy',
3: 'Overcast',
45: 'Fog',
48: 'Rime fog',
51: 'Light drizzle',
53: 'Drizzle',
55: 'Heavy drizzle',
56: 'Freezing drizzle',
57: 'Heavy freezing drizzle',
61: 'Light rain',
63: 'Rain',
65: 'Heavy rain',
66: 'Freezing rain',
67: 'Heavy freezing rain',
71: 'Light snowfall',
73: 'Snowfall',
75: 'Heavy snowfall',
77: 'Snow grains',
80: 'Light rain showers',
81: 'Rain showers',
82: 'Heavy rain showers',
85: 'Light snow showers',
86: 'Heavy snow showers',
95: 'Thunderstorm',
96: 'Thunderstorm with hail',
99: 'Severe thunderstorm with hail',
};
// Estimate weather condition from average temperature + precipitation
function estimateCondition(tempAvg, precipMm) {
if (precipMm > 5) return tempAvg <= 0 ? 'Snow' : 'Rain';
if (precipMm > 1) return tempAvg <= 0 ? 'Snow' : 'Drizzle';
if (precipMm > 0.3) return 'Clouds';
return tempAvg > 15 ? 'Clear' : 'Clouds';
}
// ------------------------------------------------------- // -------------------------------------------------------
function formatItem(item) { // GET /api/weather?lat=&lng=&date=&lang=de
return {
temp: Math.round(item.main.temp),
feels_like: Math.round(item.main.feels_like),
humidity: item.main.humidity,
main: item.weather[0]?.main || '',
description: item.weather[0]?.description || '',
icon: item.weather[0]?.icon || '',
};
}
// GET /api/weather?lat=&lng=&date=&units=metric
router.get('/', authenticate, async (req, res) => { router.get('/', authenticate, async (req, res) => {
const { lat, lng, date, units = 'metric' } = req.query; const { lat, lng, date, lang = 'de' } = req.query;
if (!lat || !lng) { if (!lat || !lng) {
return res.status(400).json({ error: 'Breiten- und Längengrad sind erforderlich' }); return res.status(400).json({ error: 'Latitude and longitude are required' });
} }
// User's own key, or fall back to admin's key const ck = cacheKey(lat, lng, date);
let key = null;
const user = db.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(req.user.id);
if (user?.openweather_api_key) {
key = user.openweather_api_key;
} else {
const admin = db.prepare("SELECT openweather_api_key FROM users WHERE role = 'admin' AND openweather_api_key IS NOT NULL AND openweather_api_key != '' LIMIT 1").get();
key = admin?.openweather_api_key || null;
}
if (!key) {
return res.status(400).json({ error: 'Kein API-Schlüssel konfiguriert' });
}
const ck = cacheKey(lat, lng, date, units);
try { try {
// If a date is requested, try the 5-day forecast first // ── Forecast for a specific date ──
if (date) { if (date) {
// Check cache
const cached = getCached(ck); const cached = getCached(ck);
if (cached) return res.json(cached); if (cached) return res.json(cached);
@@ -77,54 +154,286 @@ router.get('/', authenticate, async (req, res) => {
const now = new Date(); const now = new Date();
const diffDays = (targetDate - now) / (1000 * 60 * 60 * 24); const diffDays = (targetDate - now) / (1000 * 60 * 60 * 24);
// Within 5-day forecast window // Within 16-day forecast window → real forecast
if (diffDays >= -1 && diffDays <= 5) { if (diffDays >= -1 && diffDays <= 16) {
const url = `https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lng}&appid=${key}&units=${units}&lang=de`; const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto&forecast_days=16`;
const response = await fetch(url); const response = await fetch(url);
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok || data.error) {
return res.status(response.status).json({ error: data.message || 'OpenWeatherMap API Fehler' }); return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo API error' });
} }
const filtered = (data.list || []).filter(item => { const dateStr = targetDate.toISOString().slice(0, 10);
const itemDate = new Date(item.dt * 1000); const idx = (data.daily?.time || []).indexOf(dateStr);
return itemDate.toDateString() === targetDate.toDateString();
}); if (idx !== -1) {
const code = data.daily.weathercode[idx];
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
const result = {
temp: Math.round((data.daily.temperature_2m_max[idx] + data.daily.temperature_2m_min[idx]) / 2),
temp_max: Math.round(data.daily.temperature_2m_max[idx]),
temp_min: Math.round(data.daily.temperature_2m_min[idx]),
main: WMO_MAP[code] || 'Clouds',
description: descriptions[code] || '',
type: 'forecast',
};
if (filtered.length > 0) {
const midday = filtered.find(item => {
const hour = new Date(item.dt * 1000).getHours();
return hour >= 11 && hour <= 14;
}) || filtered[0];
const result = formatItem(midday);
setCache(ck, result, TTL_FORECAST_MS); setCache(ck, result, TTL_FORECAST_MS);
return res.json(result); return res.json(result);
} }
// Forecast didn't include this date — fall through to climate
} }
// Outside forecast window — no data available // Beyond forecast range or forecast gap → historical climate average
if (diffDays > -1) {
const month = targetDate.getMonth() + 1;
const day = targetDate.getDate();
// Query a 5-day window around the target date for smoother averages (using last year as reference)
const refYear = targetDate.getFullYear() - 1;
const startDate = new Date(refYear, month - 1, day - 2);
const endDate = new Date(refYear, month - 1, day + 2);
const startStr = startDate.toISOString().slice(0, 10);
const endStr = endDate.toISOString().slice(0, 10);
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${startStr}&end_date=${endStr}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=auto`;
const response = await fetch(url);
const data = await response.json();
if (!response.ok || data.error) {
return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo Climate API error' });
}
const daily = data.daily;
if (!daily || !daily.time || daily.time.length === 0) {
return res.json({ error: 'no_forecast' });
}
// Average across the window
let sumMax = 0, sumMin = 0, sumPrecip = 0, count = 0;
for (let i = 0; i < daily.time.length; i++) {
if (daily.temperature_2m_max[i] != null && daily.temperature_2m_min[i] != null) {
sumMax += daily.temperature_2m_max[i];
sumMin += daily.temperature_2m_min[i];
sumPrecip += daily.precipitation_sum[i] || 0;
count++;
}
}
if (count === 0) {
return res.json({ error: 'no_forecast' });
}
const avgMax = sumMax / count;
const avgMin = sumMin / count;
const avgTemp = (avgMax + avgMin) / 2;
const avgPrecip = sumPrecip / count;
const main = estimateCondition(avgTemp, avgPrecip);
const result = {
temp: Math.round(avgTemp),
temp_max: Math.round(avgMax),
temp_min: Math.round(avgMin),
main,
description: '',
type: 'climate',
};
setCache(ck, result, TTL_CLIMATE_MS);
return res.json(result);
}
// Past dates beyond yesterday
return res.json({ error: 'no_forecast' }); return res.json({ error: 'no_forecast' });
} }
// No date — return current weather // ── Current weather (no date) ──
const cached = getCached(ck); const cached = getCached(ck);
if (cached) return res.json(cached); if (cached) return res.json(cached);
const url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lng}&appid=${key}&units=${units}&lang=de`; const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=temperature_2m,weathercode&timezone=auto`;
const response = await fetch(url); const response = await fetch(url);
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok || data.error) {
return res.status(response.status).json({ error: data.message || 'OpenWeatherMap API Fehler' }); return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo API error' });
} }
const result = formatItem(data); const code = data.current.weathercode;
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
const result = {
temp: Math.round(data.current.temperature_2m),
main: WMO_MAP[code] || 'Clouds',
description: descriptions[code] || '',
type: 'current',
};
setCache(ck, result, TTL_CURRENT_MS); setCache(ck, result, TTL_CURRENT_MS);
res.json(result); res.json(result);
} catch (err) { } catch (err) {
console.error('Weather error:', err); console.error('Weather error:', err);
res.status(500).json({ error: 'Fehler beim Abrufen der Wetterdaten' }); res.status(500).json({ error: 'Error fetching weather data' });
}
});
// GET /api/weather/detailed?lat=&lng=&date=&lang=de
router.get('/detailed', authenticate, async (req, res) => {
const { lat, lng, date, lang = 'de' } = req.query;
if (!lat || !lng || !date) {
return res.status(400).json({ error: 'Latitude, longitude, and date are required' });
}
const ck = `detailed_${cacheKey(lat, lng, date)}`;
try {
const cached = getCached(ck);
if (cached) return res.json(cached);
const targetDate = new Date(date);
const now = new Date();
const diffDays = (targetDate - now) / (1000 * 60 * 60 * 24);
const dateStr = targetDate.toISOString().slice(0, 10);
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
// Beyond 16-day forecast window → archive API with hourly data from same date last year
if (diffDays > 16) {
const refYear = targetDate.getFullYear() - 1;
const refDateStr = `${refYear}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}`;
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}`
+ `&start_date=${refDateStr}&end_date=${refDateStr}`
+ `&hourly=temperature_2m,precipitation,weathercode,windspeed_10m,relativehumidity_2m`
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum,windspeed_10m_max,sunrise,sunset`
+ `&timezone=auto`;
const response = await fetch(url);
const data = await response.json();
if (!response.ok || data.error) {
return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo Climate API error' });
}
const daily = data.daily;
const hourly = data.hourly;
if (!daily || !daily.time || daily.time.length === 0) {
return res.json({ error: 'no_forecast' });
}
const idx = 0;
const code = daily.weathercode?.[idx];
const avgMax = daily.temperature_2m_max[idx];
const avgMin = daily.temperature_2m_min[idx];
// Build hourly array
const hourlyData = [];
if (hourly?.time) {
for (let i = 0; i < hourly.time.length; i++) {
const hour = new Date(hourly.time[i]).getHours();
const hCode = hourly.weathercode?.[i];
hourlyData.push({
hour,
temp: Math.round(hourly.temperature_2m[i]),
precipitation: hourly.precipitation?.[i] || 0,
precipitation_probability: 0, // archive has no probability
main: WMO_MAP[hCode] || 'Clouds',
wind: Math.round(hourly.windspeed_10m?.[i] || 0),
humidity: hourly.relativehumidity_2m?.[i] || 0,
});
}
}
// Format sunrise/sunset
let sunrise = null, sunset = null;
if (daily.sunrise?.[idx]) sunrise = daily.sunrise[idx].split('T')[1]?.slice(0, 5);
if (daily.sunset?.[idx]) sunset = daily.sunset[idx].split('T')[1]?.slice(0, 5);
const result = {
type: 'climate',
temp: Math.round((avgMax + avgMin) / 2),
temp_max: Math.round(avgMax),
temp_min: Math.round(avgMin),
main: WMO_MAP[code] || estimateCondition((avgMax + avgMin) / 2, daily.precipitation_sum?.[idx] || 0),
description: descriptions[code] || '',
precipitation_sum: Math.round((daily.precipitation_sum?.[idx] || 0) * 10) / 10,
wind_max: Math.round(daily.windspeed_10m_max?.[idx] || 0),
sunrise,
sunset,
hourly: hourlyData,
};
setCache(ck, result, TTL_CLIMATE_MS);
return res.json(result);
}
// Within 16-day forecast window → full forecast with hourly data
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}`
+ `&hourly=temperature_2m,precipitation_probability,precipitation,weathercode,windspeed_10m,relativehumidity_2m`
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,sunrise,sunset,precipitation_probability_max,precipitation_sum,windspeed_10m_max`
+ `&timezone=auto&start_date=${dateStr}&end_date=${dateStr}`;
const response = await fetch(url);
const data = await response.json();
if (!response.ok || data.error) {
return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo API error' });
}
const daily = data.daily;
const hourly = data.hourly;
if (!daily || !daily.time || daily.time.length === 0) {
return res.json({ error: 'no_forecast' });
}
const dayIdx = 0; // We requested a single day
const code = daily.weathercode[dayIdx];
// Parse sunrise/sunset to HH:MM
const formatTime = (isoStr) => {
if (!isoStr) return '';
const d = new Date(isoStr);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
// Build hourly array
const hourlyData = [];
if (hourly && hourly.time) {
for (let i = 0; i < hourly.time.length; i++) {
const h = new Date(hourly.time[i]).getHours();
hourlyData.push({
hour: h,
temp: Math.round(hourly.temperature_2m[i]),
precipitation_probability: hourly.precipitation_probability[i] || 0,
precipitation: hourly.precipitation[i] || 0,
main: WMO_MAP[hourly.weathercode[i]] || 'Clouds',
wind: Math.round(hourly.windspeed_10m[i] || 0),
humidity: Math.round(hourly.relativehumidity_2m[i] || 0),
});
}
}
const result = {
type: 'forecast',
temp: Math.round((daily.temperature_2m_max[dayIdx] + daily.temperature_2m_min[dayIdx]) / 2),
temp_max: Math.round(daily.temperature_2m_max[dayIdx]),
temp_min: Math.round(daily.temperature_2m_min[dayIdx]),
main: WMO_MAP[code] || 'Clouds',
description: descriptions[code] || '',
sunrise: formatTime(daily.sunrise[dayIdx]),
sunset: formatTime(daily.sunset[dayIdx]),
precipitation_sum: daily.precipitation_sum[dayIdx] || 0,
precipitation_probability_max: daily.precipitation_probability_max[dayIdx] || 0,
wind_max: Math.round(daily.windspeed_10m_max[dayIdx] || 0),
hourly: hourlyData,
};
setCache(ck, result, TTL_FORECAST_MS);
return res.json(result);
} catch (err) {
console.error('Detailed weather error:', err);
res.status(500).json({ error: 'Error fetching detailed weather data' });
} }
}); });
+12 -7
View File
@@ -55,9 +55,9 @@ async function runBackup() {
if (fs.existsSync(uploadsDir)) archive.directory(uploadsDir, 'uploads'); if (fs.existsSync(uploadsDir)) archive.directory(uploadsDir, 'uploads');
archive.finalize(); archive.finalize();
}); });
console.log(`[Auto-Backup] Erstellt: ${filename}`); console.log(`[Auto-Backup] Created: ${filename}`);
} catch (err) { } catch (err) {
console.error('[Auto-Backup] Fehler:', err.message); console.error('[Auto-Backup] Error:', err.message);
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath); if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
return; return;
} }
@@ -77,11 +77,11 @@ function cleanupOldBackups(keepDays) {
const stat = fs.statSync(filePath); const stat = fs.statSync(filePath);
if (stat.birthtimeMs < cutoff) { if (stat.birthtimeMs < cutoff) {
fs.unlinkSync(filePath); fs.unlinkSync(filePath);
console.log(`[Auto-Backup] Altes Backup gelöscht: ${file}`); console.log(`[Auto-Backup] Old backup deleted: ${file}`);
} }
} }
} catch (err) { } catch (err) {
console.error('[Auto-Backup] Bereinigungsfehler:', err.message); console.error('[Auto-Backup] Cleanup error:', err.message);
} }
} }
@@ -93,13 +93,13 @@ function start() {
const settings = loadSettings(); const settings = loadSettings();
if (!settings.enabled) { if (!settings.enabled) {
console.log('[Auto-Backup] Deaktiviert'); console.log('[Auto-Backup] Disabled');
return; return;
} }
const expression = CRON_EXPRESSIONS[settings.interval] || CRON_EXPRESSIONS.daily; const expression = CRON_EXPRESSIONS[settings.interval] || CRON_EXPRESSIONS.daily;
currentTask = cron.schedule(expression, runBackup); currentTask = cron.schedule(expression, runBackup);
console.log(`[Auto-Backup] Geplant: ${settings.interval} (${expression}), Aufbewahrung: ${settings.keep_days === 0 ? 'immer' : settings.keep_days + ' Tage'}`); console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
} }
// Demo mode: hourly reset of demo user data // Demo mode: hourly reset of demo user data
@@ -120,4 +120,9 @@ function startDemoReset() {
console.log('[Demo] Hourly reset scheduled (at :00 every hour)'); console.log('[Demo] Hourly reset scheduled (at :00 every hour)');
} }
module.exports = { start, startDemoReset, loadSettings, saveSettings, VALID_INTERVALS }; function stop() {
if (currentTask) { currentTask.stop(); currentTask = null; }
if (demoTask) { demoTask.stop(); demoTask = null; }
}
module.exports = { start, stop, startDemoReset, loadSettings, saveSettings, VALID_INTERVALS };
+3 -1
View File
@@ -141,10 +141,12 @@ function broadcast(tripId, eventType, payload, excludeSid) {
} }
} }
function broadcastToUser(userId, payload) { function broadcastToUser(userId, payload, excludeSid) {
if (!wss) return; if (!wss) return;
const excludeNum = excludeSid ? Number(excludeSid) : null;
for (const ws of wss.clients) { for (const ws of wss.clients) {
if (ws.readyState !== 1) continue; if (ws.readyState !== 1) continue;
if (excludeNum && socketId.get(ws) === excludeNum) continue;
const user = socketUser.get(ws); const user = socketUser.get(ws);
if (user && user.id === userId) { if (user && user.id === userId) {
ws.send(JSON.stringify(payload)); ws.send(JSON.stringify(payload));