Compare commits

...

97 Commits

Author SHA1 Message Date
Maurice 1a992b7b4e fix: allow PDF iframe embedding in CSP 2026-03-27 21:41:06 +01:00
Maurice 8396a75223 refactoring: TypeScript migration, security fixes, 2026-03-27 18:40:18 +01:00
Maurice 510475a46f Hide divider when no reservations in day detail view 2026-03-26 22:36:41 +01:00
Maurice cb080954c9 Reservation end time, route perf overhaul, assignment search fix
- Add reservation_end_time field (DB migration, API, UI)
- Split reservation form: separate date, start time, end time, status fields
- Fix DateTimePicker forcing 00:00 when no time selected
- Show end time across all reservation displays
- Link-to-assignment and date on same row (50/50 layout)
- Assignment search now shows day headers for filtered results
- Auto-fill date when selecting a day assignment
- Route segments: single OSRM request instead of N separate calls (~6s → ~1s)
- Route labels visible from zoom level 12 (was 16)
- Fix stale route labels after place deletion (useEffect triggers recalc)
- AbortController cancels outdated route calculations
2026-03-26 22:32:15 +01:00
Maurice 35275e209d Fix double delete confirm, inline place name editing, preserve assignments on trip extend
- Replace double browser confirm() with single custom ConfirmDialog for place deletion
- Add inline name editing via double-click in PlaceInspector
- Rewrite generateDays() to preserve existing days/assignments when extending trips
- Use UTC date math to avoid timezone-related day count errors
- Add missing collab.chat.emptyDesc translation (en/de)
2026-03-26 22:08:44 +01:00
mauriceboe feb2a8a5f2 add funding
Added funding options for Ko-fi and Buy Me a Coffee.
2026-03-26 17:28:05 +01:00
mauriceboe fae8473319 delete funding 2026-03-26 17:27:37 +01:00
mauriceboe 93d7e965cc Update funding configuration for Buy Me a Coffee 2026-03-26 17:16:04 +01:00
mauriceboe 6c470f5de3 Add Buy Me a Coffee username for sponsorship 2026-03-26 17:10:32 +01:00
mauriceboe 502fbb2f3f Update README with Collab feature information
Added 'Collab' feature details to the README.
2026-03-26 11:09:40 +01:00
mauriceboe b11f85eda0 Update issue templates 2026-03-26 09:25:01 +01:00
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
Maurice dd3d4263a7 v2.5.2 — PWA, new branding, bug fixes
Progressive Web App:
- Service worker with Workbox caching (map tiles, API, uploads, CDN)
- Web app manifest with standalone display mode
- Custom app icon with PNG generation from SVG
- Apple meta tags, dynamic theme-color for dark/light mode
- iOS safe area handling

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

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

UI Improvements:
- Mobile navbar shows icon only, desktop shows full logo
- NOMAD version badge in profile dropdown
- Top padding before first item in day planner
- Improved drag & drop stability (larger drop zones, less flickering)
2026-03-22 02:50:13 +01:00
Maurice 5f4e7f9487 Fix mobile note reorder, shorten settings buttons, fix Atlas city/country counting 2026-03-21 23:27:04 +01:00
Maurice e652efee8a Switch to default status bar style — iOS handles safe areas cleanly 2026-03-21 23:10:23 +01:00
Maurice 47028798b1 Fix bottom bar: use transform trick to paint safe area 2026-03-21 23:07:10 +01:00
Maurice 6076a482b8 Fix bottom safe area: use fixed body to cover full screen 2026-03-21 23:01:45 +01:00
Maurice a590db8e10 Truncate long place names in file source badges on mobile 2026-03-21 22:56:09 +01:00
Maurice 502c334cbf Fix bottom safe area: html background + fixed inset for map page 2026-03-21 22:54:20 +01:00
Maurice 031cc3587b Fix hardcoded pixel offsets in TripPlannerPage for safe area 2026-03-21 22:47:48 +01:00
Maurice f55f5ea449 Fix PWA safe area: navbar extends behind status bar, remove bottom black bar 2026-03-21 22:42:21 +01:00
Maurice 557de4cd5a Add PWA support for iOS home screen install
- Web app manifest (standalone display, NOMAD branding, icons)
- Service worker with Workbox caching (map tiles, API, uploads, CDN)
- SVG app icon + PNG generation script via sharp
- Apple meta tags (web-app-capable, black-translucent status bar)
- Dynamic theme-color for dark/light mode
- Safe area insets for notch devices
2026-03-21 22:21:23 +01:00
Maurice 544ac796d5 Auto-route on reorder/assign/remove, lock places for optimization, fix zoom
- Route line auto-updates on day select, reorder, assign, remove (no manual button)
- Remove manual route calculation button (keep optimize + Google Maps)
- Lock places at their position during route optimization (click avatar to toggle)
- Locked places shown with red background, border and lock overlay
- Custom tooltip for lock feature (DE/EN, dark mode)
- Fix map zoom: panTo instead of setView keeps current zoom level
- Fix fitBounds only on actual day change, not on place click
- Missing translations: needTwoPlaces, routeOptimized, noGeoPlaces
2026-03-21 16:12:13 +01:00
Maurice 5b6e3d6c1a Fix drag/drop to bottom of day, add restore warning modal, add missing translations 2026-03-21 15:35:05 +01:00
Maurice df695ee8d8 v2.5.1 — Security hardening, backup restore fix & restore warning modal 2026-03-21 15:13:10 +01:00
Maurice d845057f84 Security hardening, backup restore fix & restore warning modal
- Fix backup restore: try/finally ensures DB always reopens after closeDb
- Fix EBUSY on uploads during restore (in-place overwrite instead of rmSync)
- Add DB proxy null guard for clearer errors during restore window
- Add red warning modal before backup restore (DE/EN, dark mode support)
- JWT secret: empty docker-compose default so auto-generation kicks in
- OIDC: pass token via URL fragment instead of query param (no server logs)
- Block SVG uploads on photos, files and covers (stored XSS prevention)
- Add helmet for security headers (HSTS, X-Frame, nosniff, etc.)
- Explicit express.json body size limit (100kb)
- Fix XSS in Leaflet map markers (escape image_url in HTML)
- Remove verbose WebSocket debug logging from client
2026-03-21 15:09:41 +01:00
Maurice e70fe50ae3 Fix demo banner: i18n for demo button, icon alignment, add addon mgmt & OIDC to full version features 2026-03-21 11:14:53 +01:00
mauriceboe 2000371844 Update README.md 2026-03-20 23:42:49 +01:00
Maurice d45d9c2cfa Fix Atlas labels, update Demo Banner with addons & NOMAD intro (v2.5.0)
- Remove Next Trip from Atlas bottom panel
- Fix label wrapping with whitespace-nowrap on streak/year labels
- Redesign Demo Banner: add addon showcase (Vacay, Atlas, Packing, Budget, Documents, Widgets), "What is NOMAD?" section, 2-column grid layout, compact design
2026-03-20 23:39:12 +01:00
Maurice d24f0b3ccd Fix Atlas: remove Next Trip, fix label wrapping (v2.5.0) 2026-03-20 23:39:12 +01:00
mauriceboe c1fb745627 Update README.md 2026-03-20 23:20:34 +01:00
Maurice 384d583628 v2.5.0 — Addon System, Vacay, Atlas, Dashboard Widgets & Mobile Overhaul
The biggest NOMAD update yet. Introduces a modular addon architecture and three major new features.

Addon System:
- Admin panel addon management with enable/disable toggles
- Trip addons (Packing List, Budget, Documents) dynamically show/hide in trip tabs
- Global addons appear in the main navigation for all users

Vacay — Vacation Day Planner (Global Addon):
- Monthly calendar view with international public holidays (100+ countries via Nager.Date API)
- Company holidays with auto-cleanup of conflicting entries
- User-based system: each NOMAD user is a person in the calendar
- Fusion system: invite other users to share a combined calendar with real-time WebSocket sync
- Vacation entitlement tracking with automatic carry-over to next year
- Full settings: block weekends, public holidays, company holidays, carry-over toggle
- Invite/accept/decline flow with forced confirmation modal
- Color management per user with collision detection on fusion
- Dissolve fusion with preserved entries

Atlas — Travel World Map (Global Addon):
- Fullscreen Leaflet world map with colored country polygons (GeoJSON)
- Glass-effect bottom panel with stats, continent breakdown, streak tracking
- Country tooltips with trip count, places visited, first/last visit dates
- Liquid glass hover effect on the stats panel
- Canvas renderer with tile preloading for maximum performance
- Responsive: mobile stats bars, no zoom controls on touch

Dashboard Widgets:
- Currency converter with 50 currencies, CustomSelect dropdowns, localStorage persistence
- Timezone widget with customizable city list, live updating clock
- Per-user toggle via settings button, bottom sheet on mobile

Admin Panel:
- Consistent dark mode across all tabs (CSS variable overrides)
- Online/offline status badges on user list via WebSocket
- Unified heading sizes and subtitles across all sections
- Responsive tab grid on mobile

Mobile Improvements:
- Vacay: slide-in sidebar drawer, floating toolbar, responsive calendar grid
- Atlas: top/bottom glass stat bars, no popups
- Trip Planner: fixed position content container prevents overscroll, portal-based sidebar buttons
- Dashboard: fixed viewport container, mobile widget bottom sheet
- Admin: responsive tab grid, compact buttons
- Global: overscroll-behavior fixes, modal scroll containment

Other:
- Trip tab labels: Planung→Karte, Packliste→Liste, Buchungen→Buchung (DE mobile)
- Reservation form responsive layout
- Backup panel responsive buttons
2026-03-20 23:14:06 +01:00
Maurice 3edf65957b Block demo user from deleting account and changing password (v2.4.1) 2026-03-20 00:02:53 +01:00
Maurice c887acddee v2.4.0 — OIDC login, OpenStreetMap search, account management
Features:
- Single Sign-On (OIDC) — login with Google, Apple, Authentik, Keycloak
- OpenStreetMap place search as free fallback when no Google API key
- Change password in user settings
- Delete own account (with last-admin protection)
- Last login column in admin user management
- SSO badge and provider info in user settings
- Google API key "Recommended" badge in admin panel

Improvements:
- API keys load correctly after page reload
- Validate auto-saves keys before testing
- Time format respects 12h/24h setting everywhere
- Dark mode fixes for popups and backup buttons
- Admin stats: removed photos, 4-column layout
- Profile picture upload button on avatar overlay
- TravelStats duplicate key fix
- Backup panel dark mode support
2026-03-19 23:49:07 +01:00
Maurice 74be63555d Fix API keys not loading after reload, auto-save before validate (v2.3.5)
- Admin panel now loads API keys from /me/settings endpoint (not /me)
- Validate buttons auto-save keys first so validation uses current values
- Keys persist and display correctly after page reload
2026-03-19 21:27:34 +01:00
Maurice fd6fc9e71f Fix mobile date picker + auto-update end date from start date (v2.3.4)
- Date picker dropdown stays within viewport on mobile (no more overflow)
- Opens above if not enough space below
- Centers on very small screens (<360px)
- End date auto-adjusts when start date changes:
  - If no end date or end < start → end = start
  - If both set → preserves trip duration (shifts end by same delta)
2026-03-19 18:01:41 +01:00
Maurice 22f5623adb Add screenshot gallery to README (v2.3.3) 2026-03-19 17:23:58 +01:00
Maurice 6117b80575 Add app screenshot to README (v2.3.2) 2026-03-19 17:19:00 +01:00
Maurice d98eaaebee Add live demo link to README and repo description (v2.3.1) 2026-03-19 17:02:12 +01:00
Maurice 45d410c1b0 Demo baseline reset: full DB snapshot/restore (v2.3.0)
Hourly reset now restores entire DB from baseline snapshot instead of
just deleting demo trips. This reverts ALL demo user changes including
modifications to shared admin trips. Admin credentials (password, API
keys) are preserved across resets. Admin can save new baseline via
Admin Panel button. Removed demoWriteBlock middleware.
2026-03-19 16:31:27 +01:00
Maurice cd36fba0c9 Add security policy (v2.2.8) 2026-03-19 16:16:47 +01:00
Maurice f93efe9740 Add Nginx WebSocket config to README with reverse proxy docs (v2.2.7) 2026-03-19 16:01:05 +01:00
Maurice 53b1c8617e Add reset countdown timer to demo popup (v2.2.6) 2026-03-19 15:42:22 +01:00
Maurice bf7412d016 Fix PDF export: show trip title instead of 'Meine Reise' (v2.2.5) 2026-03-19 15:31:20 +01:00
Maurice 9b0755debc Demo popup: show on every dashboard visit, add upload notice (v2.2.4)
- Popup now shows every time user visits dashboard (not session-cached)
- Only shows on dashboard, not other pages
- Added upload disabled notice with amber highlight
- Upload listed as first full-version feature
2026-03-19 15:17:31 +01:00
Maurice c582a7b6c8 Block uploads for demo user, restore PDF preview modal (v2.2.3)
- Demo user gets 403 on all upload endpoints (files, photos, cover, avatar)
- Admin uploads still work normally
- PDF export back in modal popup using srcdoc iframe
- Zero behavior change when DEMO_MODE is not set
2026-03-19 15:09:20 +01:00
Maurice 1a5c8cd385 Fix PDF: export opens in new tab, file preview uses object tag (v2.2.2) 2026-03-19 15:01:27 +01:00
Maurice 98f90adb6d Bump version to 2.2.1 2026-03-19 14:57:42 +01:00
Maurice 0935143001 Fix PDF preview: use srcdoc instead of blob URL to avoid X-Frame-Options 2026-03-19 14:53:38 +01:00
Maurice c3535967ee Show app version (v2.2.0) in user menu 2026-03-19 14:49:36 +01:00
Maurice 4d9854062c Fix PDF export: allow same-origin iframes (X-Frame-Options) 2026-03-19 14:44:35 +01:00
Maurice 173d6cd953 Fix travel-stats: wrong JOIN on days table (d.trip_id not d.id) 2026-03-19 14:22:05 +01:00
Maurice da79059576 Replace demo banner with dismissable popup modal
Shows once per session, no layout interference with navbar/map.
Uses sessionStorage so it reappears on next visit.
2026-03-19 14:09:12 +01:00
Maurice f856956428 Remove demo deployment folder (moved to nomad-demo repo) 2026-03-19 14:00:04 +01:00
Maurice 4e33d710ea Fix demo banner: add EN/DE translations, fix navbar overlap
Banner is now in document flow instead of fixed position,
so it no longer covers the navigation. Language follows
the app's i18n setting.
2026-03-19 13:58:27 +01:00
Maurice c2bb9a904c Fix Docker build: add setup-buildx-action for push 2026-03-19 13:41:48 +01:00
Maurice aeac66baff Allow manual workflow trigger for Docker build 2026-03-19 13:39:12 +01:00
Maurice 9a8c24cf7b Add GitHub Actions workflow for Docker Hub auto-build 2026-03-19 13:35:11 +01:00
Maurice e8acbbd129 Add demo mode with hourly reset, example trips & demo banner
DEMO_MODE=true enables: auto-seeded admin + demo user, 3 example trips
(Tokyo, Barcelona, Wien), hourly reset of demo user data, one-click
demo login, visible banner with feature info. Zero behavior change
when DEMO_MODE is not set.
2026-03-19 13:25:37 +01:00
Maurice f8dcce802e Replace MIT license with AGPL-3.0 2026-03-19 13:01:55 +01:00
189 changed files with 28398 additions and 10130 deletions
+2
View File
@@ -0,0 +1,2 @@
ko_fi: mauriceboe
buy_me_a_coffee: mauriceboe
+38
View File
@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE]"
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+25
View File
@@ -0,0 +1,25 @@
name: Build & Push Docker Image
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: mauriceboe/nomad:latest
+3
View File
@@ -4,6 +4,9 @@ node_modules/
# Build output
client/dist/
# Generated PWA icons (built from SVG via prebuild)
client/public/icons/*.png
# Database
*.db
*.db-shm
+8 -5
View File
@@ -11,9 +11,11 @@ FROM node:22-alpine
WORKDIR /app
# Server-Dependencies installieren
# Server-Dependencies installieren (better-sqlite3 braucht Build-Tools)
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
COPY server/ ./
@@ -24,8 +26,9 @@ COPY --from=client-builder /app/client/dist ./public
# Fonts für PDF-Export kopieren
COPY --from=client-builder /app/client/public/fonts ./public/fonts
# Verzeichnisse erstellen
RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers
# Verzeichnisse erstellen + Symlink für Abwärtskompatibilität (alte docker-compose mounten nach /app/server/uploads)
RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data
# Umgebung setzen
ENV NODE_ENV=production
@@ -33,4 +36,4 @@ ENV PORT=3000
EXPOSE 3000
CMD ["node", "--experimental-sqlite", "src/index.js"]
CMD ["node", "--import", "tsx", "src/index.ts"]
+657 -17
View File
@@ -1,21 +1,661 @@
MIT License
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (c) 2026 mauriceboe
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Preamble
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+138 -32
View File
@@ -1,45 +1,92 @@
# NOMAD
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="client/public/logo-light.svg" />
<source media="(prefers-color-scheme: light)" srcset="client/public/logo-dark.svg" />
<img src="client/public/logo-light.svg" alt="NOMAD" height="60" />
</picture>
<br />
<em>Navigation Organizer for Maps, Activities & Destinations</em>
</p>
**Navigation Organizer for Maps, Activities & Destinations**
<p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
<a href="https://hub.docker.com/r/mauriceboe/nomad"><img src="https://img.shields.io/docker/pulls/mauriceboe/nomad" alt="Docker Pulls" /></a>
<a href="https://github.com/mauriceboe/NOMAD"><img src="https://img.shields.io/github/stars/mauriceboe/NOMAD" alt="GitHub Stars" /></a>
<a href="https://github.com/mauriceboe/NOMAD/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/NOMAD" alt="Last Commit" /></a>
</p>
A self-hosted, real-time collaborative travel planner for organizing trips with interactive maps, budgets, packing lists, and more.
<p align="center">
A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more.
<br />
<strong><a href="https://demo-nomad.pakulat.org">Live Demo</a></strong> — Try NOMAD without installing. Resets hourly.
</p>
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](LICENSE)
[![Docker Pulls](https://img.shields.io/docker/pulls/mauriceboe/nomad)](https://hub.docker.com/r/mauriceboe/nomad)
[![GitHub Stars](https://img.shields.io/github/stars/mauriceboe/NOMAD)](https://github.com/mauriceboe/NOMAD)
[![Last Commit](https://img.shields.io/github/last-commit/mauriceboe/NOMAD)](https://github.com/mauriceboe/NOMAD/commits)
![NOMAD Screenshot](docs/screenshot.png)
![NOMAD Screenshot 2](docs/screenshot-2.png)
<details>
<summary>More Screenshots</summary>
| | |
|---|---|
| ![Plan Detail](docs/screenshot-plan-detail.png) | ![Bookings](docs/screenshot-bookings.png) |
| ![Budget](docs/screenshot-budget.png) | ![Packing List](docs/screenshot-packing.png) |
| ![Files](docs/screenshot-files.png) | |
</details>
## Features
- **Real-Time Collaboration** — Plan together via WebSocket live sync — changes appear instantly across all connected users
- **Interactive Map** — Leaflet map with marker clustering, route visualization, and customizable tile sources
- **Google Places Integration** — Search places, auto-fill details including ratings, reviews, opening hours, and photos (requires API key)
### Trip Planning
- **Drag & Drop Planner** — Organize places into day plans with reordering and cross-day moves
- **Weather Forecasts** — Current weather and 5-day forecasts with smart caching (requires API key)
- **Interactive Map** — Leaflet map with photo markers, clustering, route visualization, and customizable tile sources
- **Place Search** — Search via Google Places (with photos, ratings, opening hours) or OpenStreetMap (free, no API key needed)
- **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering
- **Route Optimization** — Auto-optimize place order and export to Google Maps
- **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback
### Travel Management
- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments
- **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support
- **Packing Lists** — Categorized checklists with progress tracking, color coding, and smart suggestions
- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
- **PDF Export** — Export complete trip plans as PDF with images and notes
- **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and NOMAD branding
### Mobile & PWA
- **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed
- **Offline Support** — Service Worker caches map tiles, API data, uploads, and static assets via Workbox
- **Native App Feel** — Fullscreen standalone mode, custom app icon, themed status bar, and splash screen
- **Touch Optimized** — Responsive design with mobile-specific layouts, touch-friendly controls, and safe area handling
### Collaboration
- **Real-Time Sync** — Plan together via WebSocket — changes appear instantly across all connected users
- **Multi-User** — Invite members to collaborate on shared trips with role-based access
- **Admin Panel** — User management, create users, global categories, API key configuration, and backups
- **Auto-Backups** — Scheduled backups with configurable interval and retention
- **Route Optimization** — Auto-optimize place order and export to Google Maps
- **Day Notes** — Add timestamped notes to individual days
- **Dark Mode** — Full light and dark theme support
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
### Addons (modular, admin-toggleable)
- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with live sync, and carry-over tracking
- **Atlas** — Interactive world map with visited countries, travel stats, continent breakdown, streak tracking, and liquid glass UI effects
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
### Customization & Admin
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
- **Multilingual** — English and German (i18n)
- **Mobile Friendly** — Responsive design with touch-optimized controls
- **Admin Panel** — User management, global categories, addon management, API keys, backups, and GitHub release history
- **Auto-Backups** — Scheduled backups with configurable interval and retention
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
## Tech Stack
- **Backend**: Node.js 22 + Express + SQLite (`node:sqlite`)
- **Backend**: Node.js 22 + Express + SQLite (`better-sqlite3`)
- **Frontend**: React 18 + Vite + Tailwind CSS
- **PWA**: vite-plugin-pwa + Workbox
- **Real-Time**: WebSocket (`ws`)
- **State**: Zustand
- **Auth**: JWT
- **Auth**: JWT + OIDC
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
- **Weather**: OpenWeatherMap API (optional)
- **Weather**: Open-Meteo API (free, no key required)
- **Icons**: lucide-react
## Quick Start
@@ -50,6 +97,15 @@ docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads maurice
The app runs on port `3000`. The first user to register becomes the admin.
### Install as App (PWA)
NOMAD works as a Progressive Web App — no App Store needed:
1. Open your NOMAD instance in the browser (HTTPS required)
2. **iOS**: Share button → "Add to Home Screen"
3. **Android**: Menu → "Install app" or "Add to Home Screen"
4. NOMAD launches fullscreen with its own icon, just like a native app
<details>
<summary>Docker Compose (recommended for production)</summary>
@@ -77,21 +133,75 @@ docker compose up -d
### 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
docker pull mauriceboe/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)
For production, put NOMAD behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
Example with **Caddy**:
> **Important:** NOMAD uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path.
<details>
<summary>Nginx</summary>
```nginx
server {
listen 80;
server_name nomad.yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name nomad.yourdomain.com;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
location /ws {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
</details>
<details>
<summary>Caddy</summary>
Caddy handles WebSocket upgrades automatically:
```
nomad.yourdomain.com {
@@ -99,6 +209,8 @@ nomad.yourdomain.com {
}
```
</details>
## Optional API Keys
API keys are configured in the **Admin Panel** after login. Keys set by the admin are automatically shared with all users — no per-user configuration needed.
@@ -110,12 +222,6 @@ API keys are configured in the **Admin Panel** after login. Keys set by the admi
3. Create an API key under Credentials
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
```bash
+26
View File
@@ -0,0 +1,26 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---|---|
| Latest | Yes |
| Older | No |
Only the latest version receives security updates. Please update to the latest release.
## Reporting a Vulnerability
If you discover a security vulnerability, please report it responsibly:
1. **Do not** open a public issue
2. Email: **mauriceboe@icloud.com**
3. Include a description of the vulnerability and steps to reproduce
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
## Scope
This policy covers the NOMAD application and its Docker image (`mauriceboe/nomad`).
Third-party dependencies are monitored via GitHub Dependabot.
+18 -2
View File
@@ -2,13 +2,29 @@
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23111827' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M17.8 19.2 16 11l3.5-3.5C21 6 21 4 19 2c-2-2-4-2-5.5-.5L10 5 1.8 6.2c-.5.1-.9.6-.6 1.1l1.9 2.9 2.5-.9 4-4 2.7 2.7-4 4 .9 2.5 2.9 1.9c.5.3 1 0 1.1-.5z'/></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>NOMAD</title>
<!-- PWA / iOS -->
<meta name="theme-color" content="#09090b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="NOMAD" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4612 -3
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -1,10 +1,11 @@
{
"name": "nomad-client",
"version": "2.0.0",
"version": "2.6.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/generate-icons.mjs",
"build": "vite build",
"preview": "vite preview"
},
@@ -27,10 +28,14 @@
"@types/leaflet": "^1.9.8",
"@types/react": "^18.2.61",
"@types/react-dom": "^18.2.19",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"sharp": "^0.33.0",
"tailwindcss": "^3.4.1",
"vite": "^5.1.4"
"typescript": "^6.0.2",
"vite": "^5.1.4",
"vite-plugin-pwa": "^0.21.0"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="a5b4275efd"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="61932b752f"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.753906 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.753906 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#a5b4275efd)"><g clip-path="url(#61932b752f)"><path fill="#000000" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="ff6253e8fa"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="c6b14a8188"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#ff6253e8fa)"><g clip-path="url(#c6b14a8188)"><path fill="#ffffff" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1e293b"/>
<stop offset="100%" stop-color="#0f172a"/>
</linearGradient>
<clipPath id="icon">
<path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z"/>
</clipPath>
</defs>
<rect width="512" height="512" fill="url(#bg)"/>
<g transform="translate(56,51) scale(0.267)">
<rect width="1500" height="1500" fill="#ffffff" clip-path="url(#icon)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

+29
View File
@@ -0,0 +1,29 @@
/**
* Generates PNG icons for PWA from the master SVG icon.
* Run: node scripts/generate-icons.mjs
* Called automatically via the "prebuild" npm script.
*/
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import sharp from 'sharp';
const __dirname = dirname(fileURLToPath(import.meta.url));
const iconsDir = join(__dirname, '..', 'public', 'icons');
const svgBuffer = readFileSync(join(iconsDir, 'icon.svg'));
const sizes = [
{ name: 'apple-touch-icon-180x180.png', size: 180 },
{ name: 'icon-192x192.png', size: 192 },
{ name: 'icon-512x512.png', size: 512 },
];
for (const { name, size } of sizes) {
await sharp(svgBuffer, { density: 300 })
.resize(size, size)
.png({ compressionLevel: 9 })
.toFile(join(iconsDir, name));
console.log(` \u2713 ${name} (${size}x${size})`);
}
console.log('PWA icons generated.');
+51 -13
View File
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react'
import React, { useEffect, ReactNode } from 'react'
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
@@ -6,22 +6,31 @@ import LoginPage from './pages/LoginPage'
import RegisterPage from './pages/RegisterPage'
import DashboardPage from './pages/DashboardPage'
import TripPlannerPage from './pages/TripPlannerPage'
// PhotosPage removed - replaced by Finanzplan
import FilesPage from './pages/FilesPage'
import AdminPage from './pages/AdminPage'
import SettingsPage from './pages/SettingsPage'
import VacayPage from './pages/VacayPage'
import AtlasPage from './pages/AtlasPage'
import { ToastContainer } from './components/shared/Toast'
import { TranslationProvider } from './i18n'
import { TranslationProvider, useTranslation } from './i18n'
import DemoBanner from './components/Layout/DemoBanner'
import { authApi } from './api/client'
function ProtectedRoute({ children, adminRequired = false }) {
interface ProtectedRouteProps {
children: ReactNode
adminRequired?: boolean
}
function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) {
const { isAuthenticated, user, isLoading } = useAuthStore()
const { t } = useTranslation()
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<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>
<p className="text-slate-500 text-sm">Wird geladen...</p>
<p className="text-slate-500 text-sm">{t('common.loading')}</p>
</div>
</div>
)
@@ -31,11 +40,11 @@ function ProtectedRoute({ children, adminRequired = false }) {
return <Navigate to="/login" replace />
}
if (adminRequired && user?.role !== 'admin') {
if (adminRequired && user && user.role !== 'admin') {
return <Navigate to="/dashboard" replace />
}
return children
return <>{children}</>
}
function RootRedirect() {
@@ -53,13 +62,17 @@ function RootRedirect() {
}
export default function App() {
const { loadUser, token, isAuthenticated } = useAuthStore()
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey } = useAuthStore()
const { loadSettings } = useSettingsStore()
useEffect(() => {
if (token) {
loadUser()
}
authApi.getAppConfig().then((config: { demo_mode?: boolean; has_maps_key?: boolean }) => {
if (config?.demo_mode) setDemoMode(true)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
}).catch(() => {})
}, [])
const { settings } = useSettingsStore()
@@ -70,13 +83,22 @@ export default function App() {
}
}, [isAuthenticated])
// Apply dark mode class to <html>
useEffect(() => {
if (settings.dark_mode) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
const mode = settings.dark_mode
const applyDark = (isDark: boolean) => {
document.documentElement.classList.toggle('dark', isDark)
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff')
}
if (mode === 'auto') {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
applyDark(mq.matches)
const handler = (e: MediaQueryListEvent) => applyDark(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}
applyDark(mode === true || mode === 'dark')
}, [settings.dark_mode])
return (
@@ -126,6 +148,22 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/vacay"
element={
<ProtectedRoute>
<VacayPage />
</ProtectedRoute>
}
/>
<Route
path="/atlas"
element={
<ProtectedRoute>
<AtlasPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</TranslationProvider>
-200
View File
@@ -1,200 +0,0 @@
import axios from 'axios'
import { getSocketId } from './websocket'
const apiClient = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor - add auth token and socket ID
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
const sid = getSocketId()
if (sid) {
config.headers['X-Socket-Id'] = sid
}
return config
},
(error) => Promise.reject(error)
)
// Response interceptor - handle 401
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('auth_token')
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) {
window.location.href = '/login'
}
}
return Promise.reject(error)
}
)
export const authApi = {
register: (data) => apiClient.post('/auth/register', data).then(r => r.data),
login: (data) => apiClient.post('/auth/login', data).then(r => r.data),
me: () => apiClient.get('/auth/me').then(r => r.data),
updateMapsKey: (key) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
updateApiKeys: (data) => apiClient.put('/auth/me/api-keys', data).then(r => r.data),
updateSettings: (data) => apiClient.put('/auth/me/settings', data).then(r => r.data),
getSettings: () => apiClient.get('/auth/me/settings').then(r => r.data),
listUsers: () => apiClient.get('/auth/users').then(r => r.data),
uploadAvatar: (formData) => apiClient.post('/auth/avatar', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
deleteAvatar: () => apiClient.delete('/auth/avatar').then(r => r.data),
getAppConfig: () => apiClient.get('/auth/app-config').then(r => r.data),
updateAppSettings: (data) => apiClient.put('/auth/app-settings', data).then(r => r.data),
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
}
export const tripsApi = {
list: (params) => apiClient.get('/trips', { params }).then(r => r.data),
create: (data) => apiClient.post('/trips', data).then(r => r.data),
get: (id) => apiClient.get(`/trips/${id}`).then(r => r.data),
update: (id, data) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
delete: (id) => apiClient.delete(`/trips/${id}`).then(r => r.data),
uploadCover: (id, formData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
archive: (id) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
unarchive: (id) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
getMembers: (id) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
addMember: (id, identifier) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
removeMember: (id, userId) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
}
export const daysApi = {
list: (tripId) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
create: (tripId, data) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
update: (tripId, dayId, data) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
delete: (tripId, dayId) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
}
export const placesApi = {
list: (tripId, params) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
create: (tripId, data) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
get: (tripId, id) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
searchImage: (tripId, id) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
}
export const assignmentsApi = {
list: (tripId, dayId) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
create: (tripId, dayId, data) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).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),
move: (tripId, assignmentId, newDayId, orderIndex) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
}
export const packingApi = {
list: (tripId) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
create: (tripId, data) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
reorder: (tripId, orderedIds) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
}
export const tagsApi = {
list: () => apiClient.get('/tags').then(r => r.data),
create: (data) => apiClient.post('/tags', data).then(r => r.data),
update: (id, data) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
delete: (id) => apiClient.delete(`/tags/${id}`).then(r => r.data),
}
export const categoriesApi = {
list: () => apiClient.get('/categories').then(r => r.data),
create: (data) => apiClient.post('/categories', data).then(r => r.data),
update: (id, data) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
delete: (id) => apiClient.delete(`/categories/${id}`).then(r => r.data),
}
export const adminApi = {
users: () => apiClient.get('/admin/users').then(r => r.data),
createUser: (data) => apiClient.post('/admin/users', data).then(r => r.data),
updateUser: (id, data) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data),
deleteUser: (id) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
stats: () => apiClient.get('/admin/stats').then(r => r.data),
}
export const mapsApi = {
search: (query, lang) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
details: (placeId, lang) => apiClient.get(`/maps/details/${placeId}`, { params: { lang } }).then(r => r.data),
placePhoto: (placeId) => apiClient.get(`/maps/place-photo/${placeId}`).then(r => r.data),
}
export const budgetApi = {
list: (tripId) => apiClient.get(`/trips/${tripId}/budget`).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),
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
}
export const filesApi = {
list: (tripId) => apiClient.get(`/trips/${tripId}/files`).then(r => r.data),
upload: (tripId, formData) => apiClient.post(`/trips/${tripId}/files`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}).then(r => r.data),
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
}
export const reservationsApi = {
list: (tripId) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
create: (tripId, data) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
}
export const weatherApi = {
get: (lat, lng, date) => apiClient.get('/weather', { params: { lat, lng, date, units: 'metric' } }).then(r => r.data),
}
export const settingsApi = {
get: () => apiClient.get('/settings').then(r => r.data),
set: (key, value) => apiClient.put('/settings', { key, value }).then(r => r.data),
setBulk: (settings) => apiClient.post('/settings/bulk', { settings }).then(r => r.data),
}
export const dayNotesApi = {
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),
update: (tripId, dayId, id, data) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
delete: (tripId, dayId, id) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
}
export const backupApi = {
list: () => apiClient.get('/backup/list').then(r => r.data),
create: () => apiClient.post('/backup/create').then(r => r.data),
download: async (filename) => {
const token = localStorage.getItem('auth_token')
const res = await fetch(`/api/backup/download/${filename}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Download fehlgeschlagen')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
},
delete: (filename) => apiClient.delete(`/backup/${filename}`).then(r => r.data),
restore: (filename) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data),
uploadRestore: (file) => {
const form = new FormData()
form.append('backup', file)
return apiClient.post('/backup/upload-restore', form, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
getAutoSettings: () => apiClient.get('/backup/auto-settings').then(r => r.data),
setAutoSettings: (settings) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
}
export default apiClient
+248
View File
@@ -0,0 +1,248 @@
import axios, { AxiosInstance } from 'axios'
import { getSocketId } from './websocket'
const apiClient: AxiosInstance = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor - add auth token and socket ID
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
const sid = getSocketId()
if (sid) {
config.headers['X-Socket-Id'] = sid
}
return config
},
(error) => Promise.reject(error)
)
// Response interceptor - handle 401
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('auth_token')
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) {
window.location.href = '/login'
}
}
return Promise.reject(error)
}
)
export const authApi = {
register: (data: { username: string; email: string; password: string }) => apiClient.post('/auth/register', data).then(r => r.data),
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
me: () => apiClient.get('/auth/me').then(r => r.data),
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
updateApiKeys: (data: Record<string, string | null>) => apiClient.put('/auth/me/api-keys', data).then(r => r.data),
updateSettings: (data: Record<string, unknown>) => apiClient.put('/auth/me/settings', data).then(r => r.data),
getSettings: () => apiClient.get('/auth/me/settings').then(r => r.data),
listUsers: () => apiClient.get('/auth/users').then(r => r.data),
uploadAvatar: (formData: FormData) => apiClient.post('/auth/avatar', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
deleteAvatar: () => apiClient.delete('/auth/avatar').then(r => r.data),
getAppConfig: () => apiClient.get('/auth/app-config').then(r => r.data),
updateAppSettings: (data: Record<string, unknown>) => apiClient.put('/auth/app-settings', data).then(r => r.data),
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
}
export const tripsApi = {
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
create: (data: Record<string, unknown>) => apiClient.post('/trips', data).then(r => r.data),
get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data),
update: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
}
export const daysApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
}
export const placesApi = {
list: (tripId: number | string, params?: Record<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
}
export const assignmentsApi = {
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
create: (tripId: number | string, dayId: number | string, data: { place_id: number | string }) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data),
move: (tripId: number | string, assignmentId: number, newDayId: number | string, orderIndex: number | null) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data),
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds }).then(r => r.data),
updateTime: (tripId: number | string, id: number, times: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
}
export const packingApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
}
export const tagsApi = {
list: () => apiClient.get('/tags').then(r => r.data),
create: (data: Record<string, unknown>) => apiClient.post('/tags', data).then(r => r.data),
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
delete: (id: number) => apiClient.delete(`/tags/${id}`).then(r => r.data),
}
export const categoriesApi = {
list: () => apiClient.get('/categories').then(r => r.data),
create: (data: Record<string, unknown>) => apiClient.post('/categories', data).then(r => r.data),
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
delete: (id: number) => apiClient.delete(`/categories/${id}`).then(r => r.data),
}
export const adminApi = {
users: () => apiClient.get('/admin/users').then(r => r.data),
createUser: (data: Record<string, unknown>) => apiClient.post('/admin/users', data).then(r => r.data),
updateUser: (id: number, data: Record<string, unknown>) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data),
deleteUser: (id: number) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
stats: () => apiClient.get('/admin/stats').then(r => r.data),
saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data),
getOidc: () => apiClient.get('/admin/oidc').then(r => r.data),
updateOidc: (data: Record<string, unknown>) => apiClient.put('/admin/oidc', data).then(r => r.data),
addons: () => apiClient.get('/admin/addons').then(r => r.data),
updateAddon: (id: number | string, data: Record<string, unknown>) => 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 = {
enabled: () => apiClient.get('/addons').then(r => r.data),
}
export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${placeId}`, { params: { lang } }).then(r => r.data),
placePhoto: (placeId: string) => apiClient.get(`/maps/place-photo/${placeId}`).then(r => r.data),
}
export const budgetApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
}
export const filesApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/files`).then(r => r.data),
upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
}
export const reservationsApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
}
export const weatherApi = {
get: (lat: number, lng: number, date: string) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
}
export const settingsApi = {
get: () => apiClient.get('/settings').then(r => r.data),
set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data),
setBulk: (settings: Record<string, unknown>) => apiClient.post('/settings/bulk', { settings }).then(r => r.data),
}
export const accommodationsApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
}
export const dayNotesApi = {
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data),
create: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
}
export const collabApi = {
getNotes: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data),
createNote: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
updateNote: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
createPoll: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex }).then(r => r.data),
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
sendMessage: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji }).then(r => r.data),
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
}
export const backupApi = {
list: () => apiClient.get('/backup/list').then(r => r.data),
create: () => apiClient.post('/backup/create').then(r => r.data),
download: async (filename: string): Promise<void> => {
const token = localStorage.getItem('auth_token')
const res = await fetch(`/api/backup/download/${filename}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Download failed')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
},
delete: (filename: string) => apiClient.delete(`/backup/${filename}`).then(r => r.data),
restore: (filename: string) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data),
uploadRestore: (file: File) => {
const form = new FormData()
form.append('backup', file)
return apiClient.post('/backup/upload-restore', form, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
getAutoSettings: () => apiClient.get('/backup/auto-settings').then(r => r.data),
setAutoSettings: (settings: Record<string, unknown>) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
}
export default apiClient
@@ -1,47 +1,47 @@
// Singleton WebSocket manager for real-time collaboration
let socket = null
let reconnectTimer = null
type WebSocketListener = (event: Record<string, unknown>) => void
type RefetchCallback = (tripId: string) => void
let socket: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let reconnectDelay = 1000
const MAX_RECONNECT_DELAY = 30000
const listeners = new Set()
const activeTrips = new Set()
let currentToken = null
let refetchCallback = null
let mySocketId = null
const listeners = new Set<WebSocketListener>()
const activeTrips = new Set<string>()
let currentToken: string | null = null
let refetchCallback: RefetchCallback | null = null
let mySocketId: string | null = null
export function getSocketId() {
export function getSocketId(): string | null {
return mySocketId
}
export function setRefetchCallback(fn) {
export function setRefetchCallback(fn: RefetchCallback | null): void {
refetchCallback = fn
}
function getWsUrl(token) {
function getWsUrl(token: string): string {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
return `${protocol}://${location.host}/ws?token=${token}`
}
function handleMessage(event) {
function handleMessage(event: MessageEvent): void {
try {
const parsed = JSON.parse(event.data)
// Store our socket ID from welcome message
if (parsed.type === 'welcome') {
mySocketId = parsed.socketId
console.log('[WS] Got socketId:', mySocketId)
return
}
console.log('[WS] Received:', parsed.type, parsed)
listeners.forEach(fn => {
try { fn(parsed) } catch (err) { console.error('WebSocket listener error:', err) }
try { fn(parsed) } catch (err: unknown) { console.error('WebSocket listener error:', err) }
})
} catch (err) {
} catch (err: unknown) {
console.error('WebSocket message parse error:', err)
}
}
function scheduleReconnect() {
function scheduleReconnect(): void {
if (reconnectTimer) return
reconnectTimer = setTimeout(() => {
reconnectTimer = null
@@ -52,7 +52,7 @@ function scheduleReconnect() {
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
}
function connectInternal(token, isReconnect = false) {
function connectInternal(token: string, _isReconnect = false): void {
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return
}
@@ -61,20 +61,16 @@ function connectInternal(token, isReconnect = false) {
socket = new WebSocket(url)
socket.onopen = () => {
console.log('[WS] Connected', isReconnect ? '(reconnect)' : '(initial)')
reconnectDelay = 1000
// Join active trips on any connect (initial or reconnect)
if (activeTrips.size > 0) {
activeTrips.forEach(tripId => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'join', tripId }))
console.log('[WS] Joined trip', tripId)
}
})
// Refetch trip data for active trips
if (refetchCallback) {
activeTrips.forEach(tripId => {
try { refetchCallback(tripId) } catch (err) {
try { refetchCallback!(tripId) } catch (err: unknown) {
console.error('Failed to refetch trip data on reconnect:', err)
}
})
@@ -96,7 +92,7 @@ function connectInternal(token, isReconnect = false) {
}
}
export function connect(token) {
export function connect(token: string): void {
currentToken = token
reconnectDelay = 1000
if (reconnectTimer) {
@@ -106,7 +102,7 @@ export function connect(token) {
connectInternal(token, false)
}
export function disconnect() {
export function disconnect(): void {
currentToken = null
if (reconnectTimer) {
clearTimeout(reconnectTimer)
@@ -114,30 +110,30 @@ export function disconnect() {
}
activeTrips.clear()
if (socket) {
socket.onclose = null // prevent reconnect
socket.onclose = null
socket.close()
socket = null
}
}
export function joinTrip(tripId) {
export function joinTrip(tripId: number | string): void {
activeTrips.add(String(tripId))
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'join', tripId: String(tripId) }))
}
}
export function leaveTrip(tripId) {
export function leaveTrip(tripId: number | string): void {
activeTrips.delete(String(tripId))
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'leave', tripId: String(tripId) }))
}
}
export function addListener(fn) {
export function addListener(fn: WebSocketListener): void {
listeners.add(fn)
}
export function removeListener(fn) {
export function removeListener(fn: WebSocketListener): void {
listeners.delete(fn)
}
@@ -0,0 +1,189 @@
import { useEffect, useState } from 'react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase } from 'lucide-react'
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase,
}
interface Addon {
id: string
name: string
description: string
icon: string
enabled: boolean
}
interface AddonIconProps {
name: string
size?: number
}
function AddonIcon({ name, size = 20 }: AddonIconProps) {
const Icon = ICON_MAP[name] || Puzzle
return <Icon size={size} />
}
export default function AddonManager() {
const { t } = useTranslation()
const dm = useSettingsStore(s => s.settings.dark_mode)
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const toast = useToast()
const [addons, setAddons] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
loadAddons()
}, [])
const loadAddons = async () => {
setLoading(true)
try {
const data = await adminApi.addons()
setAddons(data.addons)
} catch (err: unknown) {
toast.error(t('admin.addons.toast.error'))
} finally {
setLoading(false)
}
}
const handleToggle = async (addon) => {
const newEnabled = !addon.enabled
// Optimistic update
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
try {
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
window.dispatchEvent(new Event('addons-changed'))
toast.success(t('admin.addons.toast.updated'))
} catch (err: unknown) {
// Rollback
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a))
toast.error(t('admin.addons.toast.error'))
}
}
const tripAddons = addons.filter(a => a.type === 'trip')
const globalAddons = addons.filter(a => a.type === 'global')
if (loading) {
return (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" style={{ borderTopColor: 'var(--text-primary)' }}></div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="NOMAD" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
</p>
</div>
{addons.length === 0 ? (
<div className="p-8 text-center text-sm" style={{ color: 'var(--text-faint)' }}>
{t('admin.addons.noAddons')}
</div>
) : (
<div>
{/* Trip Addons */}
{tripAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
<Briefcase size={13} style={{ color: 'var(--text-muted)' }} />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
{t('admin.addons.type.trip')} {t('admin.addons.tripHint')}
</span>
</div>
{tripAddons.map(addon => (
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
))}
</div>
)}
{/* Global Addons */}
{globalAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
<Globe size={13} style={{ color: 'var(--text-muted)' }} />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
{t('admin.addons.type.global')} {t('admin.addons.globalHint')}
</span>
</div>
{globalAddons.map(addon => (
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
))}
</div>
)}
</div>
)}
</div>
</div>
)
}
interface AddonRowProps {
addon: Addon
onToggle: (addonId: string) => void
t: (key: string) => string
}
function AddonRow({ addon, onToggle, t }: AddonRowProps) {
const isComingSoon = false
return (
<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 */}
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
<AddonIcon name={addon.icon} size={20} />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{addon.name}</span>
{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={{
background: addon.type === 'global' ? 'var(--bg-secondary)' : 'var(--bg-secondary)',
color: 'var(--text-muted)',
}}>
{addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')}
</span>
</div>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{addon.description}</p>
</div>
{/* Toggle */}
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs font-medium" style={{ color: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
onClick={() => !isComingSoon && onToggle(addon)}
disabled={isComingSoon}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
>
<span
className="inline-block h-4 w-4 transform rounded-full transition-transform"
style={{
background: 'var(--bg-card)',
transform: (addon.enabled && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
}}
/>
</button>
</div>
</div>
)
}
@@ -1,8 +1,9 @@
import React, { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef } from 'react'
import { backupApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive } from 'lucide-react'
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../types'
const INTERVAL_OPTIONS = [
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
@@ -29,9 +30,10 @@ export default function BackupPanel() {
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
const fileInputRef = useRef(null)
const toast = useToast()
const { t, locale } = useTranslation()
const { t, language, locale } = useTranslation()
const loadBackups = async () => {
setIsLoading(true)
@@ -67,32 +69,42 @@ export default function BackupPanel() {
}
}
const handleRestore = async (filename) => {
if (!confirm(t('backup.confirm.restore', { name: filename }))) return
setRestoringFile(filename)
try {
await backupApi.restore(filename)
toast.success(t('backup.toast.restored'))
setTimeout(() => window.location.reload(), 1500)
} catch (err) {
toast.error(err.response?.data?.error || t('backup.toast.restoreError'))
setRestoringFile(null)
}
const handleRestore = (filename) => {
setRestoreConfirm({ type: 'file', filename })
}
const handleUploadRestore = async (e) => {
const file = e.target.files?.[0]
const handleUploadRestore = (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
e.target.value = ''
if (!confirm(t('backup.confirm.uploadRestore', { name: file.name }))) return
setIsUploading(true)
try {
await backupApi.uploadRestore(file)
toast.success(t('backup.toast.restored'))
setTimeout(() => window.location.reload(), 1500)
} catch (err) {
toast.error(err.response?.data?.error || t('backup.toast.uploadError'))
setIsUploading(false)
setRestoreConfirm({ type: 'upload', filename: file.name, file })
}
const executeRestore = async () => {
if (!restoreConfirm) return
const { type, filename, file } = restoreConfirm
setRestoreConfirm(null)
if (type === 'file') {
setRestoringFile(filename)
try {
await backupApi.restore(filename)
toast.success(t('backup.toast.restored'))
setTimeout(() => window.location.reload(), 1500)
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('backup.toast.restoreError')))
setRestoringFile(null)
}
} else {
setIsUploading(true)
try {
await backupApi.uploadRestore(file)
toast.success(t('backup.toast.restored'))
setTimeout(() => window.location.reload(), 1500)
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('backup.toast.uploadError')))
setIsUploading(false)
}
}
}
@@ -153,8 +165,8 @@ export default function BackupPanel() {
<div className="flex items-center gap-3">
<HardDrive className="w-5 h-5 text-gray-400" />
<div>
<h2 className="text-lg font-semibold text-gray-900">{t('backup.title')}</h2>
<p className="text-sm text-gray-500 mt-0.5">{t('backup.subtitle')}</p>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.subtitle')}</p>
</div>
</div>
<div className="flex items-center gap-2">
@@ -179,26 +191,28 @@ export default function BackupPanel() {
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
title={isUploading ? t('backup.uploading') : t('backup.upload')}
>
{isUploading ? (
<div className="w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
) : (
<Upload className="w-4 h-4" />
)}
{isUploading ? t('backup.uploading') : t('backup.upload')}
<span className="hidden sm:inline">{isUploading ? t('backup.uploading') : t('backup.upload')}</span>
</button>
<button
onClick={handleCreate}
disabled={isCreating}
className="flex items-center gap-2 bg-slate-700 text-white px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
title={isCreating ? t('backup.creating') : t('backup.create')}
>
{isCreating ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<Plus className="w-4 h-4" />
)}
{isCreating ? t('backup.creating') : t('backup.create')}
<span className="hidden sm:inline">{isCreating ? t('backup.creating') : t('backup.create')}</span>
</button>
</div>
</div>
@@ -275,23 +289,23 @@ export default function BackupPanel() {
<div className="flex items-center gap-3 mb-6">
<Clock className="w-5 h-5 text-gray-400" />
<div>
<h2 className="text-lg font-semibold text-gray-900">{t('backup.auto.title')}</h2>
<p className="text-sm text-gray-500 mt-0.5">{t('backup.auto.subtitle')}</p>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.auto.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.auto.subtitle')}</p>
</div>
</div>
<div className="flex flex-col gap-5">
{/* Enable toggle */}
<label className="flex items-center justify-between cursor-pointer">
<div>
<label className="flex items-center justify-between gap-4 cursor-pointer">
<div className="min-w-0">
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
</div>
<button
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-700' : 'bg-gray-200'}`}
className={`relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-900 dark:bg-slate-100' : 'bg-gray-200 dark:bg-gray-600'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${autoSettings.enabled ? 'translate-x-6' : 'translate-x-1'}`} />
<span className={`absolute left-1 h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${autoSettings.enabled ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
</label>
@@ -307,7 +321,7 @@ export default function BackupPanel() {
onClick={() => handleAutoSettingsChange('interval', opt.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
autoSettings.interval === opt.value
? 'bg-slate-700 text-white border-slate-700'
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
}`}
>
@@ -327,7 +341,7 @@ export default function BackupPanel() {
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
autoSettings.keep_days === opt.value
? 'bg-slate-700 text-white border-slate-700'
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
}`}
>
@@ -344,7 +358,7 @@ export default function BackupPanel() {
<button
onClick={handleSaveAutoSettings}
disabled={autoSettingsSaving || !autoSettingsDirty}
className="flex items-center gap-2 bg-slate-700 text-white px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
>
{autoSettingsSaving
? <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
@@ -355,6 +369,67 @@ export default function BackupPanel() {
</div>
</div>
</div>
{/* Restore Warning Modal */}
{restoreConfirm && (
<div
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={() => setRestoreConfirm(null)}
>
<div
onClick={e => e.stopPropagation()}
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
>
{/* Red header */}
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<AlertTriangle size={20} style={{ color: 'white' }} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>
{t('backup.restoreConfirmTitle')}
</h3>
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
{restoreConfirm.filename}
</p>
</div>
</div>
{/* Body */}
<div style={{ padding: '20px 24px' }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{t('backup.restoreWarning')}
</p>
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
>
{t('backup.restoreTip')}
</div>
</div>
{/* Footer */}
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
onClick={() => setRestoreConfirm(null)}
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
<button
onClick={executeRestore}
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: '#dc2626', color: 'white' }}
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
>
{t('backup.restoreConfirm')}
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -1,9 +1,10 @@
import React, { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef } from 'react'
import { categoriesApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { Plus, Edit2, Trash2, Pipette } from 'lucide-react'
import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../types'
const PRESET_COLORS = [
'#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f97316',
@@ -31,7 +32,7 @@ export default function CategoryManager() {
try {
const data = await categoriesApi.list()
setCategories(data.categories || [])
} catch (err) {
} catch (err: unknown) {
toast.error(t('categories.toast.loadError'))
} finally {
setIsLoading(false)
@@ -71,8 +72,8 @@ export default function CategoryManager() {
toast.success(t('categories.toast.created'))
}
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
} catch (err) {
toast.error(err.response?.data?.error || t('categories.toast.saveError'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('categories.toast.saveError')))
} finally {
setIsSaving(false)
}
@@ -84,8 +85,8 @@ export default function CategoryManager() {
await categoriesApi.delete(id)
setCategories(prev => prev.filter(c => c.id !== id))
toast.success(t('categories.toast.deleted'))
} catch (err) {
toast.error(err.response?.data?.error || t('categories.toast.deleteError'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('categories.toast.deleteError')))
}
}
@@ -190,13 +191,14 @@ export default function CategoryManager() {
<div className="bg-white rounded-2xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-gray-900">{t('categories.title')}</h2>
<p className="text-sm text-gray-500 mt-0.5">{t('categories.subtitle')}</p>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('categories.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('categories.subtitle')}</p>
</div>
<button onClick={handleStartCreate}
className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
<Plus className="w-4 h-4" />
{t('categories.new')}
<span className="hidden sm:inline">{t('categories.new')}</span>
<span className="sm:hidden">Add</span>
</button>
</div>
+263
View File
@@ -0,0 +1,263 @@
import { 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: unknown) {
setError(err instanceof Error ? err.message : 'Unknown error')
}
}
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>
)
}
@@ -1,8 +1,31 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import DOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
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 { budgetApi } from '../../api/client'
import type { BudgetItem, BudgetMember } from '../../types'
interface TripMember {
id: number
username: string
avatar_url?: string | null
}
interface PieSegment {
label: string
value: number
color: string
}
interface PerPersonSummaryEntry {
user_id: number
username: string
avatar_url: string | null
total_assigned: number
}
// ── Helpers ──────────────────────────────────────────────────────────────────
const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD']
@@ -58,7 +81,12 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
}
// ── Add Item Row ─────────────────────────────────────────────────────────────
function AddItemRow({ onAdd, t }) {
interface AddItemRowProps {
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null }) => void
t: (key: string) => string
}
function AddItemRow({ onAdd, t }: AddItemRowProps) {
const [name, setName] = useState('')
const [price, setPrice] = useState('')
const [persons, setPersons] = useState('')
@@ -110,8 +138,200 @@ function AddItemRow({ onAdd, t }) {
)
}
// ── Chip with custom tooltip ─────────────────────────────────────────────────
interface ChipWithTooltipProps {
label: string
avatarUrl: string | null
size?: number
}
function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps) {
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) ────────────────────────────────
interface BudgetMemberChipsProps {
members?: BudgetMember[]
tripMembers?: TripMember[]
onSetMembers: (memberIds: number[]) => void
compact?: boolean
}
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compact = true }: BudgetMemberChipsProps) {
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) ────────────────────────────────────
interface PerPersonInlineProps {
tripId: number
budgetItems: BudgetItem[]
currency: string
locale: string
}
function PerPersonInline({ tripId, budgetItems, currency, locale }: PerPersonInlineProps) {
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) ──────────────────────────────────────
function PieChart({ segments, size = 200, totalLabel }) {
interface PieChartProps {
segments: PieSegment[]
size?: number
totalLabel: string
}
function PieChart({ segments, size = 200, totalLabel }: PieChartProps) {
if (!segments.length) return null
const total = segments.reduce((s, x) => s + x.value, 0)
@@ -148,13 +368,20 @@ function PieChart({ segments, size = 200, totalLabel }) {
}
// ── Main Component ───────────────────────────────────────────────────────────
export default function BudgetPanel({ tripId }) {
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip } = useTripStore()
interface BudgetPanelProps {
tripId: number
tripMembers?: TripMember[]
}
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers } = useTripStore()
const { t, locale } = useTranslation()
const [newCategoryName, setNewCategoryName] = useState('')
const [editingCat, setEditingCat] = useState(null) // { name, value }
const currency = trip?.currency || 'EUR'
const fmt = (v, cur) => fmtNum(v, locale, cur)
const hasMultipleMembers = tripMembers.length > 1
const setCurrency = (cur) => {
if (tripId) updateTrip(tripId, { currency: cur })
@@ -163,7 +390,7 @@ export default function BudgetPanel({ tripId }) {
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
const grouped = useMemo(() => (budgetItems || []).reduce((acc, item) => {
const cat = item.category || 'Sonstiges'
const cat = item.category || 'Other'
if (!acc[cat]) acc[cat] = []
acc[cat].push(item)
return acc
@@ -185,12 +412,17 @@ export default function BudgetPanel({ tripId }) {
const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} }
const handleDeleteCategory = async (cat) => {
const items = grouped[cat] || []
for (const item of items) await deleteBudgetItem(tripId, item.id)
for (const item of Array.from(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 Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
}
const handleAddCategory = () => {
if (!newCategoryName.trim()) return
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)' }
@@ -239,9 +471,27 @@ export default function BudgetPanel({ tripId }) {
return (
<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', 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 }} />
<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 style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
@@ -258,8 +508,8 @@ export default function BudgetPanel({ tripId }) {
<thead>
<tr>
<th style={{ ...th, textAlign: 'left', minWidth: 100 }}>{t('budget.table.name')}</th>
<th style={{ ...th, minWidth: 80 }}>{t('budget.table.total')}</th>
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 50 }}>{t('budget.table.persons')}</th>
<th style={{ ...th, minWidth: 60 }}>{t('budget.table.total')}</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 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>
@@ -273,16 +523,38 @@ export default function BudgetPanel({ tripId }) {
const pp = calcPP(item.total_price, item.persons)
const pd = calcPD(item.total_price, item.days)
const ppd = calcPPD(item.total_price, item.persons, item.days)
const hasMembers = item.members?.length > 0
return (
<tr key={item.id} style={{ transition: 'background 0.1s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
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' }}>
<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 className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
<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 className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
{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 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')} />
@@ -351,6 +623,9 @@ export default function BudgetPanel({ tripId }) {
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</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>
{pieSegments.length > 0 && (
@@ -358,6 +633,7 @@ export default function BudgetPanel({ tripId }) {
background: 'var(--bg-card)', borderRadius: 16, padding: '20px 16px',
border: '1px solid var(--border-primary)',
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>
@@ -386,6 +662,7 @@ export default function BudgetPanel({ tripId }) {
</div>
</div>
)}
</div>
</div>
</div>
+830
View File
@@ -0,0 +1,830 @@
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'
import type { User } from '../../types'
interface ChatReaction {
emoji: string
count: number
users: { id: number; username: string }[]
}
interface ChatMessage {
id: number
trip_id: number
user_id: number
text: string
reply_to_id: number | null
reactions: ChatReaction[]
created_at: string
user?: { username: string; avatar_url: string | null }
reply_to?: ChatMessage | null
}
// ── 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 ── */
interface EmojiPickerProps {
onSelect: (emoji: string) => void
onClose: () => void
anchorRef: React.RefObject<HTMLElement | null>
containerRef: React.RefObject<HTMLElement | null>
}
function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) {
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 = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
interface ReactionMenuProps {
x: number
y: number
onReact: (emoji: string) => void
onClose: () => void
}
function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) {
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 ── */
interface MessageTextProps {
text: string
}
function MessageText({ text }: MessageTextProps) {
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 = {}
interface LinkPreviewProps {
url: string
tripId: number
own: boolean
onLoad: (() => void) | undefined
}
function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
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 ── */
interface ReactionBadgeProps {
reaction: ChatReaction
currentUserId: number
onReact: () => void
}
function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadgeProps) {
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 ── */
interface CollabChatProps {
tripId: number
currentUser: User
}
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
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,112 @@
import { 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,
}
interface TripMember {
id: number
username: string
avatar_url?: string | null
}
interface CollabPanelProps {
tripId: number
tripMembers?: TripMember[]
}
export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelProps) {
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,471 @@
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'
import type { User } from '../../types'
interface PollVoter {
user_id: number
username: string
avatar_url: string | null
}
interface PollOption {
id: number
text: string
voters: PollVoter[]
}
interface Poll {
id: number
question: string
options: PollOption[]
multi_choice: boolean
is_closed: boolean
deadline: string | null
created_by: number
created_at: string
}
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 ────────────────────────────────────────────────────────
interface CreatePollModalProps {
onClose: () => void
onCreate: (data: { question: string; options: string[]; multi_choice: boolean }) => Promise<void>
t: (key: string) => string
}
function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
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 ────────────────────────────────────────────
interface VoterChipProps {
voter: PollVoter
offset: boolean
}
function VoterChip({ voter, offset }: VoterChipProps) {
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 ────────────────────────────────────────────────────────────────
interface PollCardProps {
poll: Poll
currentUser: User
onVote: (pollId: number, optionId: number) => Promise<void>
onClose: (pollId: number) => Promise<void>
onDelete: (pollId: number) => Promise<void>
t: (key: string) => string
}
function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardProps) {
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 ───────────────────────────────────────────────────────────
interface CollabPollsProps {
tripId: number
currentUser: User
}
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
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,199 @@
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' })
}
interface TripMember {
id: number
username: string
avatar_url?: string | null
}
interface WhatsNextWidgetProps {
tripMembers?: TripMember[]
}
export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetProps) {
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>
)
}
@@ -0,0 +1,89 @@
import { useState, useEffect, useCallback } from 'react'
import { ArrowRightLeft, RefreshCw } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
const CURRENCIES = [
'EUR','USD','GBP','JPY','CHF','CAD','AUD','NZD','CNY','HKD',
'SGD','THB','TRY','SEK','NOK','DKK','PLN','CZK','HUF','RON',
'BGN','HRK','ISK','RUB','UAH','BRL','MXN','ARS','CLP','COP',
'INR','IDR','MYR','PHP','KRW','TWD','VND','ZAR','EGP','MAD',
'NGN','KES','AED','SAR','QAR','KWD','BHD','OMR','ILS',
]
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
export default function CurrencyWidget() {
const { t } = useTranslation()
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
const [amount, setAmount] = useState('100')
const [rate, setRate] = useState(null)
const [loading, setLoading] = useState(false)
const fetchRate = useCallback(async () => {
if (from === to) { setRate(1); return }
setLoading(true)
try {
const resp = await fetch(`https://api.exchangerate-api.com/v4/latest/${from}`)
const data = await resp.json()
setRate(data.rates?.[to] || null)
} catch { setRate(null) }
finally { setLoading(false) }
}, [from, to])
useEffect(() => { fetchRate() }, [fetchRate])
useEffect(() => { localStorage.setItem('currency_from', from) }, [from])
useEffect(() => { localStorage.setItem('currency_to', to) }, [to])
const swap = () => { setFrom(to); setTo(from) }
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
const formatNumber = (num) => {
if (!num || num === '—') return '—'
return parseFloat(num).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const result = rawResult
return (
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.currency')}</span>
<button onClick={fetchRate} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* Amount */}
<div className="rounded-xl px-4 py-3 mb-3" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
<input
type="number"
value={amount}
onChange={e => setAmount(e.target.value)}
className="w-full text-2xl font-black tabular-nums outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
style={{ color: 'var(--text-primary)', background: 'transparent', border: 'none' }}
/>
</div>
{/* From / Swap / To */}
<div className="flex items-center gap-2 mb-3">
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
<CustomSelect value={from} onChange={setFrom} options={CURRENCY_OPTIONS} searchable size="sm" />
</div>
<button onClick={swap} className="p-1.5 rounded-lg shrink-0 transition-colors" style={{ color: 'var(--text-muted)' }}>
<ArrowRightLeft size={13} />
</button>
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
<CustomSelect value={to} onChange={setTo} options={CURRENCY_OPTIONS} searchable size="sm" />
</div>
</div>
{/* Result */}
<div className="rounded-xl p-3" style={{ background: 'var(--bg-secondary)' }}>
<p className="text-xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>
{formatNumber(result)} <span className="text-sm font-semibold" style={{ color: 'var(--text-muted)' }}>{to}</span>
</p>
{rate && <p className="text-[10px] mt-0.5" style={{ color: 'var(--text-faint)' }}>1 {from} = {rate.toFixed(4)} {to}</p>}
</div>
</div>
)
}
@@ -0,0 +1,126 @@
import { useState, useEffect } from 'react'
import { Clock, Plus, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
const POPULAR_ZONES = [
{ label: 'New York', tz: 'America/New_York' },
{ label: 'London', tz: 'Europe/London' },
{ label: 'Berlin', tz: 'Europe/Berlin' },
{ label: 'Paris', tz: 'Europe/Paris' },
{ label: 'Dubai', tz: 'Asia/Dubai' },
{ label: 'Mumbai', tz: 'Asia/Kolkata' },
{ label: 'Bangkok', tz: 'Asia/Bangkok' },
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
{ label: 'Sydney', tz: 'Australia/Sydney' },
{ label: 'Los Angeles', tz: 'America/Los_Angeles' },
{ label: 'Chicago', tz: 'America/Chicago' },
{ label: 'São Paulo', tz: 'America/Sao_Paulo' },
{ label: 'Istanbul', tz: 'Europe/Istanbul' },
{ label: 'Singapore', tz: 'Asia/Singapore' },
{ label: 'Hong Kong', tz: 'Asia/Hong_Kong' },
{ label: 'Seoul', tz: 'Asia/Seoul' },
{ label: 'Moscow', tz: 'Europe/Moscow' },
{ label: 'Cairo', tz: 'Africa/Cairo' },
]
function getTime(tz) {
try {
return new Date().toLocaleTimeString('de-DE', { timeZone: tz, hour: '2-digit', minute: '2-digit' })
} catch { return '—' }
}
function getOffset(tz) {
try {
const now = new Date()
const local = new Date(now.toLocaleString('en-US', { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }))
const remote = new Date(now.toLocaleString('en-US', { timeZone: tz }))
const diff = (remote - local) / 3600000
const sign = diff >= 0 ? '+' : ''
return `${sign}${diff}h`
} catch { return '' }
}
export default function TimezoneWidget() {
const { t } = useTranslation()
const [zones, setZones] = useState(() => {
const saved = localStorage.getItem('dashboard_timezones')
return saved ? JSON.parse(saved) : [
{ label: 'New York', tz: 'America/New_York' },
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
]
})
const [now, setNow] = useState(Date.now())
const [showAdd, setShowAdd] = useState(false)
useEffect(() => {
const i = setInterval(() => setNow(Date.now()), 10000)
return () => clearInterval(i)
}, [])
useEffect(() => {
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
}, [zones])
const addZone = (zone) => {
if (!zones.find(z => z.tz === zone.tz)) {
setZones([...zones, zone])
}
setShowAdd(false)
}
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
const localTime = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
// Show abbreviated timezone name (e.g. CET, CEST, EST)
const tzAbbr = new Date().toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop()
return (
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezone')}</span>
<button onClick={() => setShowAdd(!showAdd)} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
<Plus size={12} />
</button>
</div>
{/* Local time */}
<div className="mb-3 pb-3" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<p className="text-2xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>{localTime}</p>
<p className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{localZone} ({tzAbbr}) · {t('dashboard.localTime')}</p>
</div>
{/* Zone list */}
<div className="space-y-2">
{zones.map(z => (
<div key={z.tz} className="flex items-center justify-between group">
<div>
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz)}</p>
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
</div>
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
<X size={11} />
</button>
</div>
))}
</div>
{/* Add zone dropdown */}
{showAdd && (
<div className="mt-2 rounded-xl p-2 max-h-[200px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
{POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
<button key={z.tz} onClick={() => addZone(z)}
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<span className="font-medium">{z.label}</span>
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz)}</span>
</button>
))}
</div>
)}
</div>
)
}
@@ -1,193 +0,0 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'
import { Globe, MapPin, Plane } from 'lucide-react'
import { authApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
// Numeric ISO → country name lookup (countries-110m uses numeric IDs)
const NUMERIC_TO_NAME = {"004":"Afghanistan","008":"Albania","012":"Algeria","024":"Angola","032":"Argentina","036":"Australia","040":"Austria","050":"Bangladesh","056":"Belgium","064":"Bhutan","068":"Bolivia","070":"Bosnia and Herzegovina","072":"Botswana","076":"Brazil","100":"Bulgaria","104":"Myanmar","108":"Burundi","112":"Belarus","116":"Cambodia","120":"Cameroon","124":"Canada","140":"Central African Republic","144":"Sri Lanka","148":"Chad","152":"Chile","156":"China","170":"Colombia","178":"Congo","180":"Democratic Republic of the Congo","188":"Costa Rica","191":"Croatia","192":"Cuba","196":"Cyprus","203":"Czech Republic","204":"Benin","208":"Denmark","214":"Dominican Republic","218":"Ecuador","818":"Egypt","222":"El Salvador","226":"Equatorial Guinea","232":"Eritrea","233":"Estonia","231":"Ethiopia","238":"Falkland Islands","246":"Finland","250":"France","266":"Gabon","270":"Gambia","268":"Georgia","276":"Germany","288":"Ghana","300":"Greece","320":"Guatemala","324":"Guinea","328":"Guyana","332":"Haiti","340":"Honduras","348":"Hungary","352":"Iceland","356":"India","360":"Indonesia","364":"Iran","368":"Iraq","372":"Ireland","376":"Israel","380":"Italy","384":"Ivory Coast","388":"Jamaica","392":"Japan","400":"Jordan","398":"Kazakhstan","404":"Kenya","408":"North Korea","410":"South Korea","414":"Kuwait","417":"Kyrgyzstan","418":"Laos","422":"Lebanon","426":"Lesotho","430":"Liberia","434":"Libya","440":"Lithuania","442":"Luxembourg","450":"Madagascar","454":"Malawi","458":"Malaysia","466":"Mali","478":"Mauritania","484":"Mexico","496":"Mongolia","498":"Moldova","504":"Morocco","508":"Mozambique","516":"Namibia","524":"Nepal","528":"Netherlands","540":"New Caledonia","554":"New Zealand","558":"Nicaragua","562":"Niger","566":"Nigeria","578":"Norway","512":"Oman","586":"Pakistan","591":"Panama","598":"Papua New Guinea","600":"Paraguay","604":"Peru","608":"Philippines","616":"Poland","620":"Portugal","630":"Puerto Rico","634":"Qatar","642":"Romania","643":"Russia","646":"Rwanda","682":"Saudi Arabia","686":"Senegal","688":"Serbia","694":"Sierra Leone","703":"Slovakia","705":"Slovenia","706":"Somalia","710":"South Africa","724":"Spain","144":"Sri Lanka","729":"Sudan","740":"Suriname","748":"Swaziland","752":"Sweden","756":"Switzerland","760":"Syria","762":"Tajikistan","764":"Thailand","768":"Togo","780":"Trinidad and Tobago","788":"Tunisia","792":"Turkey","795":"Turkmenistan","800":"Uganda","804":"Ukraine","784":"United Arab Emirates","826":"United Kingdom","840":"United States of America","858":"Uruguay","860":"Uzbekistan","862":"Venezuela","704":"Vietnam","887":"Yemen","894":"Zambia","716":"Zimbabwe"}
// Our country names from addresses → match against GeoJSON names
function isCountryMatch(geoName, visitedCountries) {
if (!geoName) return false
const lower = geoName.toLowerCase()
return visitedCountries.some(c => {
const cl = c.toLowerCase()
return lower === cl || lower.includes(cl) || cl.includes(lower)
// Handle common mismatches
|| (cl === 'usa' && lower.includes('united states'))
|| (cl === 'uk' && lower === 'united kingdom')
|| (cl === 'south korea' && lower === 'korea' || lower === 'south korea')
|| (cl === 'deutschland' && lower === 'germany')
|| (cl === 'frankreich' && lower === 'france')
|| (cl === 'italien' && lower === 'italy')
|| (cl === 'spanien' && lower === 'spain')
|| (cl === 'österreich' && lower === 'austria')
|| (cl === 'schweiz' && lower === 'switzerland')
|| (cl === 'niederlande' && lower === 'netherlands')
|| (cl === 'türkei' && (lower === 'turkey' || lower === 'türkiye'))
|| (cl === 'griechenland' && lower === 'greece')
|| (cl === 'tschechien' && (lower === 'czech republic' || lower === 'czechia'))
|| (cl === 'ägypten' && lower === 'egypt')
|| (cl === 'südkorea' && lower.includes('korea'))
|| (cl === 'indien' && lower === 'india')
|| (cl === 'brasilien' && lower === 'brazil')
|| (cl === 'argentinien' && lower === 'argentina')
|| (cl === 'russland' && lower === 'russia')
|| (cl === 'australien' && lower === 'australia')
|| (cl === 'kanada' && lower === 'canada')
|| (cl === 'mexiko' && lower === 'mexico')
|| (cl === 'neuseeland' && lower === 'new zealand')
|| (cl === 'singapur' && lower === 'singapore')
|| (cl === 'kroatien' && lower === 'croatia')
|| (cl === 'ungarn' && lower === 'hungary')
|| (cl === 'rumänien' && lower === 'romania')
|| (cl === 'polen' && lower === 'poland')
|| (cl === 'schweden' && lower === 'sweden')
|| (cl === 'norwegen' && lower === 'norway')
|| (cl === 'dänemark' && lower === 'denmark')
|| (cl === 'finnland' && lower === 'finland')
|| (cl === 'irland' && lower === 'ireland')
|| (cl === 'portugal' && lower === 'portugal')
|| (cl === 'belgien' && lower === 'belgium')
})
}
const TOTAL_COUNTRIES = 195
// Simple Mercator projection for SVG
function project(lon, lat, width, height) {
const clampedLat = Math.max(-75, Math.min(83, lat))
const x = ((lon + 180) / 360) * width
const latRad = (clampedLat * Math.PI) / 180
const mercN = Math.log(Math.tan(Math.PI / 4 + latRad / 2))
const y = (height / 2) - (width * mercN) / (2 * Math.PI)
return [x, y]
}
function geoToPath(coords, width, height) {
return coords.map((ring) => {
// Split ring at dateline crossings to avoid horizontal stripes
const segments = [[]]
for (let i = 0; i < ring.length; i++) {
const [lon, lat] = ring[i]
if (i > 0) {
const prevLon = ring[i - 1][0]
if (Math.abs(lon - prevLon) > 180) {
// Dateline crossing — start new segment
segments.push([])
}
}
const [x, y] = project(lon, Math.max(-75, Math.min(83, lat)), width, height)
segments[segments.length - 1].push(`${x.toFixed(1)},${y.toFixed(1)}`)
}
return segments
.filter(s => s.length > 2)
.map(s => 'M' + s.join('L') + 'Z')
.join(' ')
}).join(' ')
}
let geoJsonCache = null
async function loadGeoJson() {
if (geoJsonCache) return geoJsonCache
try {
const res = await fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json')
const topo = await res.json()
const { feature } = await import('topojson-client')
const geo = feature(topo, topo.objects.countries)
geo.features.forEach(f => {
f.properties.name = NUMERIC_TO_NAME[f.id] || f.properties?.name || ''
})
geoJsonCache = geo
return geo
} catch { return null }
}
export default function TravelStats() {
const { t } = useTranslation()
const dark = useSettingsStore(s => s.settings.dark_mode)
const [stats, setStats] = useState(null)
const [geoData, setGeoData] = useState(null)
useEffect(() => {
authApi.travelStats().then(setStats).catch(() => {})
loadGeoJson().then(setGeoData)
}, [])
const countryCount = stats?.countries?.length || 0
const worldPercent = ((countryCount / TOTAL_COUNTRIES) * 100).toFixed(1)
if (!stats || stats.totalPlaces === 0) return null
return (
<div style={{ width: 340 }}>
{/* Stats Card */}
<div style={{
borderRadius: 20, overflow: 'hidden', height: 300,
display: 'flex', flexDirection: 'column', justifyContent: 'center',
border: '1px solid var(--border-primary)',
background: 'var(--bg-card)',
padding: 16,
}}>
{/* Progress bar */}
<div style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 6 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('stats.worldProgress')}</span>
<span style={{ fontSize: 20, fontWeight: 800, color: 'var(--text-primary)' }}>{worldPercent}%</span>
</div>
<div style={{ height: 6, borderRadius: 99, background: 'var(--bg-hover)', overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 99,
background: dark ? 'linear-gradient(90deg, #e2e8f0, #cbd5e1)' : 'linear-gradient(90deg, #111827, #374151)',
width: `${Math.max(1, parseFloat(worldPercent))}%`,
transition: 'width 0.5s ease',
}} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4 }}>
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{countryCount} {t('stats.visited')}</span>
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{TOTAL_COUNTRIES - countryCount} {t('stats.remaining')}</span>
</div>
</div>
{/* Stat grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 14 }}>
<StatBox icon={Globe} value={countryCount} label={t('stats.countries')} />
<StatBox icon={MapPin} value={stats.cities.length} label={t('stats.cities')} />
<StatBox icon={Plane} value={stats.totalTrips} label={t('stats.trips')} />
<StatBox icon={MapPin} value={stats.totalPlaces} label={t('stats.places')} />
</div>
{/* Country tags */}
{stats.countries.length > 0 && (
<>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6 }}>{t('stats.visitedCountries')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{stats.countries.map(c => (
<span key={c} style={{
fontSize: 10.5, fontWeight: 500, color: 'var(--text-secondary)',
background: 'var(--bg-hover)', borderRadius: 99, padding: '3px 9px',
}}>{c}</span>
))}
</div>
</>
)}
</div>
</div>
)
}
function StatBox({ icon: Icon, value, label }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
borderRadius: 10, background: 'var(--bg-hover)',
}}>
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1 }}>{value}</div>
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 1 }}>{label}</div>
</div>
</div>
)
}
@@ -1,8 +1,11 @@
import React, { useState, useCallback } from 'react'
import ReactDOM from 'react-dom'
import { useState, useCallback } from 'react'
import DOM from 'react-dom'
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 { useTranslation } from '../../i18n'
import type { Place, Reservation, TripFile } from '../../types'
function isImage(mimeType) {
if (!mimeType) return false
@@ -31,7 +34,12 @@ function formatDateWithLocale(dateStr, locale) {
}
// Image lightbox
function ImageLightbox({ file, onClose }) {
interface ImageLightboxProps {
file: TripFile & { url: string }
onClose: () => void
}
function ImageLightbox({ file, onClose }: ImageLightboxProps) {
const { t } = useTranslation()
return (
<div
@@ -61,22 +69,38 @@ function ImageLightbox({ file, onClose }) {
}
// Source badge — unified style for both place and reservation
function SourceBadge({ icon: Icon, label }) {
interface SourceBadgeProps {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
label: string
}
function SourceBadge({ icon: Icon, label }: SourceBadgeProps) {
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 10.5, color: '#4b5563',
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
borderRadius: 6, padding: '2px 7px',
fontWeight: 500, whiteSpace: 'nowrap',
fontWeight: 500, maxWidth: '100%', overflow: 'hidden',
}}>
<Icon size={10} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
{label}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</span>
)
}
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId }) {
interface FileManagerProps {
files?: TripFile[]
onUpload: (fd: FormData) => Promise<void>
onDelete: (fileId: number) => Promise<void>
onUpdate: (fileId: number, data: Partial<TripFile>) => Promise<void>
places: Place[]
reservations?: Reservation[]
tripId: number
allowedFileTypes: Record<string, string[]>
}
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
const [uploading, setUploading] = useState(false)
const [filterType, setFilterType] = useState('all')
const [lightboxFile, setLightboxFile] = useState(null)
@@ -106,10 +130,28 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
noClick: false,
})
// Paste support
const handlePaste = useCallback((e) => {
const items = e.clipboardData?.items
if (!items) return
const files = []
for (const item of Array.from(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 => {
if (filterType === 'pdf') return f.mime_type === 'application/pdf'
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 === 'collab') return !!f.note_id
return true
})
@@ -134,18 +176,18 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
}
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 */}
{lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />}
{/* Datei-Vorschau Modal */}
{previewFile && (
{/* Datei-Vorschau Modal — portal to body to escape stacking context */}
{previewFile && ReactDOM.createPortal(
<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)}
>
<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()}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
@@ -165,13 +207,19 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</button>
</div>
</div>
<iframe
src={`${previewFile.url || `/uploads/files/${previewFile.filename}`}#view=FitH`}
<object
data={`${previewFile.url || `/uploads/files/${previewFile.filename}`}#view=FitH`}
type="application/pdf"
style={{ flex: 1, width: '100%', border: 'none' }}
title={previewFile.original_name}
/>
>
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>PDF herunterladen</a>
</p>
</object>
</div>
</div>
</div>,
document.body
)}
{/* Header */}
@@ -205,6 +253,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: 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>
@@ -216,6 +267,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{ id: 'pdf', label: t('files.filterPdf') },
{ id: 'image', label: t('files.filterImages') },
{ id: 'doc', label: t('files.filterDocs') },
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
].map(tab => (
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
@@ -246,7 +298,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
const linkedReservation = file.reservation_id
? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title })
: 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 (
<div key={file.id} style={{
@@ -269,7 +321,15 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
>
{isImage(file.mime_type)
? <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>
@@ -298,6 +358,12 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
label={`${t('files.sourceBooking')} · ${linkedReservation.title || t('files.sourceBooking')}`}
/>
)}
{file.note_id && (
<SourceBadge
icon={StickyNote}
label={t('files.sourceCollab') || 'Collab Notes'}
/>
)}
</div>
{file.description && !linkedReservation && (
+232
View File
@@ -0,0 +1,232 @@
import React, { useState, useEffect } from 'react'
import { Info, Github, Shield, Key, Users, Database, Upload, Clock, Puzzle, CalendarDays, Globe, ArrowRightLeft, Map, Briefcase, ListChecks, Wallet, FileText, Plane } from 'lucide-react'
import { useTranslation } from '../../i18n'
interface DemoTexts {
titleBefore: string
titleAfter: string
title: string
description: string
resetIn: string
minutes: string
uploadNote: string
fullVersionTitle: string
features: string[]
addonsTitle: string
addons: [string, string][]
whatIs: string
whatIsDesc: string
selfHost: string
selfHostLink: string
close: string
}
const texts: Record<string, DemoTexts> = {
de: {
titleBefore: 'Willkommen bei ',
titleAfter: '',
title: 'Willkommen zur NOMAD Demo',
description: 'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
resetIn: 'Naechster Reset in',
minutes: 'Minuten',
uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.',
fullVersionTitle: 'In der Vollversion zusaetzlich:',
features: [
'Datei-Uploads (Fotos, Dokumente, Cover)',
'API-Schluessel (Google Maps, Wetter)',
'Benutzer- & Rechteverwaltung',
'Automatische Backups',
'Addon-Verwaltung (aktivieren/deaktivieren)',
'OIDC / SSO Single Sign-On',
],
addonsTitle: 'Modulare Addons (in der Vollversion deaktivierbar)',
addons: [
['Vacay', 'Urlaubsplaner mit Kalender, Feiertagen & Fusion'],
['Atlas', 'Weltkarte mit besuchten Laendern & Reisestatistiken'],
['Packliste', 'Checklisten pro Reise'],
['Budget', 'Kostenplanung mit Splitting'],
['Dokumente', 'Dateien an Reisen anhaengen'],
['Widgets', 'Waehrungsrechner & Zeitzonen'],
],
whatIs: 'Was ist NOMAD?',
whatIsDesc: 'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
selfHost: 'Open Source — ',
selfHostLink: 'selbst hosten',
close: 'Verstanden',
},
en: {
titleBefore: 'Welcome to ',
titleAfter: '',
title: 'Welcome to the NOMAD Demo',
description: 'You can view, edit and create trips. All changes are automatically reset every hour.',
resetIn: 'Next reset in',
minutes: 'minutes',
uploadNote: 'File uploads (photos, documents, covers) are disabled in demo mode.',
fullVersionTitle: 'Additionally in the full version:',
features: [
'File uploads (photos, documents, covers)',
'API key management (Google Maps, Weather)',
'User & permission management',
'Automatic backups',
'Addon management (enable/disable)',
'OIDC / SSO single sign-on',
],
addonsTitle: 'Modular Addons (can be deactivated in full version)',
addons: [
['Vacay', 'Vacation planner with calendar, holidays & user fusion'],
['Atlas', 'World map with visited countries & travel stats'],
['Packing', 'Checklists per trip'],
['Budget', 'Expense tracking with splitting'],
['Documents', 'Attach files to trips'],
['Widgets', 'Currency converter & timezones'],
],
whatIs: 'What is NOMAD?',
whatIsDesc: 'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.',
selfHost: 'Open source — ',
selfHostLink: 'self-host it',
close: 'Got it',
},
}
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
const addonIcons = [CalendarDays, Globe, ListChecks, Wallet, FileText, ArrowRightLeft]
export default function DemoBanner(): React.ReactElement | null {
const [dismissed, setDismissed] = useState<boolean>(false)
const [minutesLeft, setMinutesLeft] = useState<number>(59 - new Date().getMinutes())
const { language } = useTranslation()
const t = texts[language] || texts.en
useEffect(() => {
const interval = setInterval(() => setMinutesLeft(59 - new Date().getMinutes()), 10000)
return () => clearInterval(interval)
}, [])
if (dismissed) return null
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 16, overflow: 'auto',
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}} onClick={() => setDismissed(true)}>
<div style={{
background: 'white', borderRadius: 20, padding: '28px 24px 20px',
maxWidth: 480, width: '100%',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
maxHeight: '90vh', overflow: 'auto',
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} />
<h2 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 5 }}>
{t.titleBefore}<img src="/text-dark.svg" alt="NOMAD" style={{ height: 18 }} />{t.titleAfter}
</h2>
</div>
<p style={{ fontSize: 13, color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}>
{t.description}
</p>
{/* Timer + Upload note */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<div style={{
flex: 1, display: 'flex', alignItems: 'center', gap: 6,
background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '8px 10px',
}}>
<Clock size={13} style={{ flexShrink: 0, color: '#0284c7' }} />
<span style={{ fontSize: 11, color: '#0369a1', fontWeight: 600 }}>
{t.resetIn} {minutesLeft} {t.minutes}
</span>
</div>
<div style={{
flex: 1, display: 'flex', alignItems: 'center', gap: 6,
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '8px 10px',
}}>
<Upload size={13} style={{ flexShrink: 0, color: '#b45309' }} />
<span style={{ fontSize: 11, color: '#b45309' }}>{t.uploadNote}</span>
</div>
</div>
{/* What is NOMAD */}
<div style={{
background: '#f8fafc', borderRadius: 12, padding: '12px 14px', marginBottom: 16,
border: '1px solid #e2e8f0',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Map size={14} style={{ color: '#111827' }} />
<span style={{ fontSize: 12, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}>
{language === 'de' ? 'Was ist ' : 'What is '}<img src="/text-dark.svg" alt="NOMAD" style={{ height: 13, marginRight: -2 }} />?
</span>
</div>
<p style={{ fontSize: 12, color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
</div>
{/* Addons */}
<p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
<Puzzle size={12} />
{t.addonsTitle}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
{t.addons.map(([name, desc], i) => {
const Icon = addonIcons[i]
return (
<div key={name} style={{
background: '#f8fafc', borderRadius: 10, padding: '8px 10px',
border: '1px solid #f1f5f9',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<Icon size={12} style={{ flexShrink: 0, color: '#111827' }} />
<span style={{ fontSize: 11, fontWeight: 700, color: '#111827' }}>{name}</span>
</div>
<p style={{ fontSize: 10, color: '#94a3b8', margin: 0, lineHeight: 1.3, paddingLeft: 18 }}>{desc}</p>
</div>
)
})}
</div>
{/* Full version features */}
<p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
<Shield size={12} />
{t.fullVersionTitle}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
{t.features.map((text, i) => {
const Icon = featureIcons[i]
return (
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: '#4b5563', padding: '4px 0' }}>
<Icon size={13} style={{ flexShrink: 0, color: '#9ca3af' }} />
<span>{text}</span>
</div>
)
})}
</div>
{/* Footer */}
<div style={{
paddingTop: 14, borderTop: '1px solid #e5e7eb',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
<Github size={13} />
<span>{t.selfHost}</span>
<a href="https://github.com/mauriceboe/NOMAD" target="_blank" rel="noopener noreferrer"
style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}>
{t.selfHostLink}
</a>
</div>
<button onClick={() => setDismissed(true)} style={{
background: '#111827', color: 'white', border: 'none',
borderRadius: 10, padding: '8px 20px', fontSize: 12,
fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}>
{t.close}
</button>
</div>
</div>
</div>
)
}
@@ -1,25 +1,70 @@
import React, { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import React, { useState, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun } from 'lucide-react'
import { addonsApi } from '../../api/client'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }) {
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe }
interface NavbarProps {
tripTitle?: string
tripId?: string
onBack?: () => void
showBack?: boolean
onShare?: () => void
}
interface Addon {
id: string
name: string
icon: string
type: string
}
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
const { user, logout } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const { t, locale } = useTranslation()
const navigate = useNavigate()
const [userMenuOpen, setUserMenuOpen] = useState(false)
const dark = settings.dark_mode
const location = useLocation()
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
const [appVersion, setAppVersion] = useState<string | null>(null)
const [globalAddons, setGlobalAddons] = useState<Addon[]>([])
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const loadAddons = () => {
if (user) {
addonsApi.enabled().then(data => {
setGlobalAddons(data.addons.filter(a => a.type === 'global'))
}).catch(() => {})
}
}
useEffect(loadAddons, [user, location.pathname])
// Listen for addon changes from AddonManager
useEffect(() => {
const handler = () => loadAddons()
window.addEventListener('addons-changed', handler)
return () => window.removeEventListener('addons-changed', handler)
}, [user])
useEffect(() => {
import('../../api/client').then(({ authApi }) => {
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
})
}, [])
const handleLogout = () => {
logout()
navigate('/login')
}
const toggleDark = () => {
updateSetting('dark_mode', !dark).catch(() => {})
const toggleDarkMode = () => {
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
}
return (
@@ -28,7 +73,10 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
boxShadow: dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)',
}} className="h-14 flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
touchAction: 'manipulation',
paddingTop: 'env(safe-area-inset-top, 0px)',
height: 'var(--nav-h)',
}} className="flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
{/* Left side */}
<div className="flex items-center gap-3 min-w-0">
{showBack && (
@@ -42,12 +90,47 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
</button>
)}
<Link to="/dashboard" className="flex items-center gap-2 transition-colors flex-shrink-0"
style={{ color: 'var(--text-primary)' }}>
<Plane className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
<span className="font-bold text-sm hidden sm:inline">NOMAD</span>
<Link to="/dashboard" className="flex items-center transition-colors flex-shrink-0">
<img src={dark ? '/icons/icon-white.svg' : '/icons/icon-dark.svg'} alt="NOMAD" className="sm:hidden" style={{ height: 22, width: 22 }} />
<img src={dark ? '/logo-light.svg' : '/logo-dark.svg'} alt="NOMAD" className="hidden sm:block" style={{ height: 28 }} />
</Link>
{/* Global addon nav items */}
{globalAddons.length > 0 && !tripTitle && (
<>
<span style={{ color: 'var(--text-faint)' }}>|</span>
<Link to="/dashboard"
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex-shrink-0"
style={{
color: location.pathname === '/dashboard' ? 'var(--text-primary)' : 'var(--text-muted)',
background: location.pathname === '/dashboard' ? 'var(--bg-hover)' : 'transparent',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => { if (location.pathname !== '/dashboard') e.currentTarget.style.background = 'transparent' }}>
<Briefcase className="w-3.5 h-3.5" />
<span className="hidden md:inline">{t('nav.myTrips')}</span>
</Link>
{globalAddons.map(addon => {
const Icon = ADDON_ICONS[addon.icon] || CalendarDays
const path = `/${addon.id}`
const isActive = location.pathname === path
return (
<Link key={addon.id} to={path}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex-shrink-0"
style={{
color: isActive ? 'var(--text-primary)' : 'var(--text-muted)',
background: isActive ? 'var(--bg-hover)' : 'transparent',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent' }}>
<Icon className="w-3.5 h-3.5" />
<span className="hidden md:inline">{addon.name}</span>
</Link>
)
})}
</>
)}
{tripTitle && (
<>
<span className="hidden sm:inline" style={{ color: 'var(--text-faint)' }}>/</span>
@@ -73,8 +156,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
</button>
)}
{/* Dark mode toggle */}
<button onClick={toggleDark} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
{/* Dark mode toggle (light ↔ dark, overrides auto) */}
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
className="p-2 rounded-lg transition-colors flex-shrink-0"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
@@ -103,11 +186,10 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-faint)' }} />
</button>
{userMenuOpen && (
{userMenuOpen && ReactDOM.createPortal(
<>
<div className="fixed inset-0 z-10" onClick={() => setUserMenuOpen(false)} />
<div className="absolute right-0 top-full mt-2 w-52 rounded-xl shadow-xl border z-20 overflow-hidden"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setUserMenuOpen(false)} />
<div className="w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
@@ -146,9 +228,18 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
<LogOut className="w-4 h-4" />
{t('nav.logout')}
</button>
{appVersion && (
<div className="px-4 pt-2 pb-2.5 text-center" style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="NOMAD" style={{ height: 10, opacity: 0.5 }} />
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
</div>
</div>
)}
</div>
</div>
</>
</>,
document.body
)}
</div>
)}
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, useMemo } from 'react'
import DOM from 'react-dom'
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import L from 'leaflet'
@@ -6,6 +7,7 @@ import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
import { mapsApi } from '../../api/client'
import { getCategoryIcon } from '../shared/categoryIcons'
import type { Place } from '../../types'
// Fix default marker icons for vite
delete L.Icon.Default.prototype._getIconUrl
@@ -19,7 +21,12 @@ L.Icon.Default.mergeOptions({
* Create a round photo-circle marker.
* Shows image_url if available, otherwise category icon in colored circle.
*/
function createPlaceIcon(place, orderNumber, isSelected) {
function escAttr(s) {
if (!s) return ''
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function createPlaceIcon(place, orderNumbers, isSelected) {
const size = isSelected ? 44 : 36
const borderColor = isSelected ? '#111827' : 'white'
const borderWidth = isSelected ? 3 : 2.5
@@ -29,20 +36,23 @@ function createPlaceIcon(place, orderNumber, isSelected) {
const bgColor = place.category_color || '#6b7280'
const icon = place.category_icon || '📍'
// White semi-transparent number badge (bottom-right), only when orderNumber is set
const badgeHtml = orderNumber != null ? `
<span style="
position:absolute;bottom:-3px;right:-3px;
min-width:18px;height:18px;border-radius:9px;
padding:0 3px;
background:rgba(255,255,255,0.92);
border:1.5px solid rgba(0,0,0,0.18);
// Number badges (bottom-right), supports multiple numbers for duplicate places
let badgeHtml = ''
if (orderNumbers && orderNumbers.length > 0) {
const label = orderNumbers.join(' · ')
badgeHtml = `<span style="
position:absolute;bottom:-4px;right:-4px;
min-width:18px;height:${orderNumbers.length > 1 ? 16 : 18}px;border-radius:${orderNumbers.length > 1 ? 8 : 9}px;
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);
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;
box-sizing:border-box;
">${orderNumber}</span>` : ''
box-sizing:border-box;white-space:nowrap;
">${label}</span>`
}
if (place.image_url) {
return L.divIcon({
@@ -55,7 +65,7 @@ function createPlaceIcon(place, orderNumber, isSelected) {
cursor:pointer;flex-shrink:0;position:relative;
">
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
<img src="${place.image_url}" style="width:100%;height:100%;object-fit:cover;" />
<img src="${escAttr(place.image_url)}" style="width:100%;height:100%;object-fit:cover;" />
</div>
${badgeHtml}
</div>`,
@@ -84,24 +94,43 @@ function createPlaceIcon(place, orderNumber, isSelected) {
})
}
function SelectionController({ places, selectedPlaceId }) {
interface SelectionControllerProps {
places: Place[]
selectedPlaceId: number | null
dayPlaces: Place[]
paddingOpts: Record<string, number>
}
function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }: SelectionControllerProps) {
const map = useMap()
const prev = useRef(null)
useEffect(() => {
if (selectedPlaceId && selectedPlaceId !== prev.current) {
const place = places.find(p => p.id === selectedPlaceId)
if (place?.lat && place?.lng) {
map.setView([place.lat, place.lng], Math.max(map.getZoom(), 15), { animate: true, duration: 0.5 })
// Fit all day places into view (so you see context), but ensure selected is visible
const toFit = dayPlaces.length > 0 ? dayPlaces : places.filter(p => p.id === selectedPlaceId)
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
}, [selectedPlaceId, places, map])
}, [selectedPlaceId, places, dayPlaces, paddingOpts, map])
return null
}
function MapController({ center, zoom }) {
interface MapControllerProps {
center: [number, number]
zoom: number
}
function MapController({ center, zoom }: MapControllerProps) {
const map = useMap()
const prevCenter = useRef(center)
@@ -116,7 +145,13 @@ function MapController({ center, zoom }) {
}
// Fit bounds when places change (fitKey triggers re-fit)
function BoundsController({ places, fitKey }) {
interface BoundsControllerProps {
places: Place[]
fitKey: number
paddingOpts: Record<string, number>
}
function BoundsController({ places, fitKey, paddingOpts }: BoundsControllerProps) {
const map = useMap()
const prevFitKey = useRef(-1)
@@ -126,14 +161,18 @@ function BoundsController({ places, fitKey }) {
if (places.length === 0) return
try {
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 {}
}, [fitKey, places, map])
}, [fitKey, places, paddingOpts, map])
return null
}
function MapClickHandler({ onClick }) {
interface MapClickHandlerProps {
onClick: ((e: L.LeafletMouseEvent) => void) | null
}
function MapClickHandler({ onClick }: MapClickHandlerProps) {
const map = useMap()
useEffect(() => {
if (!onClick) return
@@ -143,12 +182,64 @@ function MapClickHandler({ onClick }) {
return null
}
// ── Route travel time label ──
interface RouteLabelProps {
midpoint: [number, number]
walkingText: string
drivingText: string
}
function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
const map = useMap()
const [visible, setVisible] = useState(map ? map.getZoom() >= 12 : false)
useEffect(() => {
if (!map) return
const check = () => setVisible(map.getZoom() >= 12)
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
const mapPhotoCache = new Map()
export function MapView({
places = [],
dayPlaces = [],
route = null,
routeSegments = [],
selectedPlaceId = null,
onMarkerClick,
onMapClick,
@@ -157,7 +248,20 @@ export function MapView({
tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
fitKey = 0,
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({})
// Fetch Google photos for places that have google_place_id but no image_url
@@ -195,8 +299,8 @@ export function MapView({
/>
<MapController center={center} zoom={zoom} />
<BoundsController places={places} fitKey={fitKey} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MarkerClusterGroup
@@ -206,6 +310,7 @@ export function MapView({
spiderfyOnMaxZoom
showCoverageOnHover={false}
zoomToBoundsOnClick
singleMarkerMode
iconCreateFunction={(cluster) => {
const count = cluster.getChildCount()
const size = count < 10 ? 36 : count < 50 ? 42 : 48
@@ -222,8 +327,8 @@ export function MapView({
{places.map((place) => {
const isSelected = place.id === selectedPlaceId
const resolvedPhotoUrl = place.image_url || (place.google_place_id && photoUrls[place.google_place_id]) || null
const orderNumber = dayOrderMap[place.id] ?? null
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumber, isSelected)
const orderNumbers = dayOrderMap[place.id] ?? null
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected)
return (
<Marker
@@ -267,13 +372,18 @@ export function MapView({
</MarkerClusterGroup>
{route && route.length > 1 && (
<Polyline
positions={route}
color="#111827"
weight={3}
opacity={0.9}
dashArray="6, 5"
/>
<>
<Polyline
positions={route}
color="#111827"
weight={3}
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>
)
@@ -1,109 +0,0 @@
// OSRM routing utility - free, no API key required
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
/**
* Calculate a route between multiple waypoints using OSRM
* @param {Array<{lat: number, lng: number}>} waypoints
* @param {string} profile - 'driving' | 'walking' | 'cycling'
* @returns {Promise<{coordinates: Array<[number,number]>, distance: number, duration: number, distanceText: string, durationText: string}>}
*/
export async function calculateRoute(waypoints, profile = 'driving') {
if (!waypoints || waypoints.length < 2) {
throw new Error('Mindestens 2 Wegpunkte erforderlich')
}
const coords = waypoints.map(p => `${p.lng},${p.lat}`).join(';')
// OSRM public API only supports driving; we override duration for other modes
const url = `${OSRM_BASE}/driving/${coords}?overview=full&geometries=geojson&steps=false`
const response = await fetch(url)
if (!response.ok) {
throw new Error('Route konnte nicht berechnet werden')
}
const data = await response.json()
if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
throw new Error('Keine Route gefunden')
}
const route = data.routes[0]
const coordinates = route.geometry.coordinates.map(([lng, lat]) => [lat, lng])
const distance = route.distance // meters
// Compute duration based on mode (walking: 5 km/h, cycling: 15 km/h)
let duration
if (profile === 'walking') {
duration = distance / (5000 / 3600)
} else if (profile === 'cycling') {
duration = distance / (15000 / 3600)
} else {
duration = route.duration // driving: use OSRM value
}
return {
coordinates,
distance,
duration,
distanceText: formatDistance(distance),
durationText: formatDuration(duration),
}
}
/**
* Generate a Google Maps directions URL for the given places
*/
export function generateGoogleMapsUrl(places) {
const valid = places.filter(p => p.lat && p.lng)
if (valid.length === 0) return null
if (valid.length === 1) {
return `https://www.google.com/maps/search/?api=1&query=${valid[0].lat},${valid[0].lng}`
}
// Use /dir/stop1/stop2/.../stopN format — all stops as path segments
const stops = valid.map(p => `${p.lat},${p.lng}`).join('/')
return `https://www.google.com/maps/dir/${stops}`
}
/**
* Simple nearest-neighbor route optimization
*/
export function optimizeRoute(places) {
const valid = places.filter(p => p.lat && p.lng)
if (valid.length <= 2) return places
const visited = new Set()
const result = []
let current = valid[0]
visited.add(current.id)
result.push(current)
while (result.length < valid.length) {
let nearest = null
let minDist = Infinity
for (const place of valid) {
if (visited.has(place.id)) continue
const d = Math.sqrt(
Math.pow(place.lat - current.lat, 2) + Math.pow(place.lng - current.lng, 2)
)
if (d < minDist) { minDist = d; nearest = place }
}
if (nearest) { visited.add(nearest.id); result.push(nearest); current = nearest }
}
return result
}
function formatDistance(meters) {
if (meters < 1000) {
return `${Math.round(meters)} m`
}
return `${(meters / 1000).toFixed(1)} km`
}
function formatDuration(seconds) {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0) {
return `${h} Std. ${m} Min.`
}
return `${m} Min.`
}
@@ -0,0 +1,139 @@
import type { RouteResult, RouteSegment, Waypoint } from '../../types'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
export async function calculateRoute(
waypoints: Waypoint[],
profile: 'driving' | 'walking' | 'cycling' = 'driving',
{ signal }: { signal?: AbortSignal } = {}
): Promise<RouteResult> {
if (!waypoints || waypoints.length < 2) {
throw new Error('At least 2 waypoints required')
}
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const url = `${OSRM_BASE}/driving/${coords}?overview=full&geometries=geojson&steps=false`
const response = await fetch(url, { signal })
if (!response.ok) {
throw new Error('Route could not be calculated')
}
const data = await response.json()
if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
throw new Error('No route found')
}
const route = data.routes[0]
const coordinates: [number, number][] = route.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng])
const distance: number = route.distance
let duration: number
if (profile === 'walking') {
duration = distance / (5000 / 3600)
} else if (profile === 'cycling') {
duration = distance / (15000 / 3600)
} else {
duration = route.duration
}
const walkingDuration = distance / (5000 / 3600)
const drivingDuration: number = route.duration
return {
coordinates,
distance,
duration,
distanceText: formatDistance(distance),
durationText: formatDuration(duration),
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(drivingDuration),
}
}
export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
const valid = places.filter((p) => p.lat && p.lng)
if (valid.length === 0) return null
if (valid.length === 1) {
return `https://www.google.com/maps/search/?api=1&query=${valid[0].lat},${valid[0].lng}`
}
const stops = valid.map((p) => `${p.lat},${p.lng}`).join('/')
return `https://www.google.com/maps/dir/${stops}`
}
/** Reorders waypoints using a nearest-neighbor heuristic to minimize total Euclidean distance. */
export function optimizeRoute(places: Waypoint[]): Waypoint[] {
const valid = places.filter((p) => p.lat && p.lng)
if (valid.length <= 2) return places
const visited = new Set<number>()
const result: Waypoint[] = []
let current = valid[0]
visited.add(0)
result.push(current)
while (result.length < valid.length) {
let nearestIdx = -1
let minDist = Infinity
for (let i = 0; i < valid.length; i++) {
if (visited.has(i)) continue
const d = Math.sqrt(
Math.pow(valid[i].lat - current.lat, 2) + Math.pow(valid[i].lng - current.lng, 2)
)
if (d < minDist) { minDist = d; nearestIdx = i }
}
if (nearestIdx === -1) break
visited.add(nearestIdx)
current = valid[nearestIdx]
result.push(current)
}
return result
}
/** Fetches per-leg distance/duration from OSRM and returns segment metadata (midpoints, walking/driving times). */
export async function calculateSegments(
waypoints: Waypoint[],
{ signal }: { signal?: AbortSignal } = {}
): Promise<RouteSegment[]> {
if (!waypoints || waypoints.length < 2) return []
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const url = `${OSRM_BASE}/driving/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
const response = await fetch(url, { signal })
if (!response.ok) throw new Error('Route could not be calculated')
const data = await response.json()
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
const legs = data.routes[0].legs
return legs.map((leg: { distance: number; duration: number }, i: number): RouteSegment => {
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng]
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng]
const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
const walkingDuration = leg.distance / (5000 / 3600)
return {
mid, from, to,
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(leg.duration),
}
})
}
function formatDistance(meters: number): string {
if (meters < 1000) {
return `${Math.round(meters)} m`
}
return `${(meters / 1000).toFixed(1)} km`
}
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0) {
return `${h} h ${m} min`
}
return `${m} min`
}
@@ -1,7 +1,16 @@
// Trip PDF via browser print window
import { createElement } from 'react'
import { getCategoryIcon } from '../shared/categoryIcons'
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark } from 'lucide-react'
import { mapsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
const NOTE_ICON_MAP = { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark }
function noteIconSvg(iconId) {
if (!_renderToStaticMarkup) return ''
const Icon = NOTE_ICON_MAP[iconId] || FileText
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' }))
}
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
@@ -80,7 +89,18 @@ async function fetchPlacePhotos(assignments) {
return photoMap
}
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, t: _t, locale: _locale }) {
interface downloadTripPDFProps {
trip: Trip
days: Day[]
places: Place[]
assignments: AssignmentsMap
categories: Category[]
dayNotes: DayNotesMap
t: (key: string, params?: Record<string, string | number>) => string
locale: string
}
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, t: _t, locale: _locale }: downloadTripPDFProps) {
await ensureRenderer()
const loc = _locale || 'de-DE'
const tr = _t || (k => k)
@@ -104,7 +124,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const cost = dayCost(assignments, day.id, loc)
const merged = []
assigned.forEach(a => merged.push({ type: 'place', k: a.sort_order ?? 0, data: a }))
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
merged.sort((a, b) => a.k - b.k)
@@ -117,12 +137,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
return `
<div class="note-card">
<div class="note-line"></div>
<svg class="note-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.8" stroke-linecap="round">
<rect x="4" y="3" width="16" height="18" rx="2"/>
<line x1="8" y1="8" x2="16" y2="8"/>
<line x1="8" y1="12" x2="16" y2="12"/>
<line x1="8" y1="16" x2="13" y2="16"/>
</svg>
<span class="note-icon">${noteIconSvg(note.icon)}</span>
<div class="note-body">
<div class="note-text">${escHtml(note.text)}</div>
${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''}
@@ -141,9 +156,6 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const googleImg = photoMap[place.id] || null
const img = directImg || googleImg
const confirmed = place.reservation_status === 'confirmed'
const pending = place.reservation_status === 'pending'
const iconSvg = categoryIconSvg(cat?.icon, color, 24)
const thumbHtml = img
? `<img class="place-thumb" src="${escHtml(img)}" />`
@@ -154,8 +166,6 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const chips = [
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>` : '',
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('')
return `
@@ -193,13 +203,31 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
<head>
<meta charset="UTF-8">
<base href="${window.location.origin}/">
<title>${escHtml(trip?.name || tr('pdf.travelPlan'))}</title>
<title>${escHtml(trip?.title || tr('pdf.travelPlan'))}</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Poppins', sans-serif; background: #fff; color: #1e293b; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
svg { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
/* Footer on every printed page */
.pdf-footer {
position: fixed;
bottom: 20px;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
opacity: 0.3;
}
.pdf-footer span {
font-size: 7px;
color: #64748b;
letter-spacing: 0.5px;
}
/* ── Cover ─────────────────────────────────────── */
.cover {
width: 100%; min-height: 100vh;
@@ -215,8 +243,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
.cover-dim { position: absolute; inset: 0; background: rgba(8,12,28,0.55); }
.cover-brand {
position: absolute; top: 36px; right: 52px;
font-size: 9px; font-weight: 600; letter-spacing: 2.5px;
color: rgba(255,255,255,0.3); text-transform: uppercase;
z-index: 2;
}
.cover-body { position: relative; z-index: 1; }
.cover-circle {
@@ -316,17 +343,23 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
</head>
<body>
<!-- Footer on every page -->
<div class="pdf-footer">
<span>made with</span>
<img src="${absUrl('/logo-dark.svg')}" style="height:10px;opacity:0.6;" />
</div>
<!-- Cover -->
<div class="cover">
${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(coverImg)}')"></div>` : ''}
<div class="cover-dim"></div>
<div class="cover-brand">NOMAD</div>
<div class="cover-brand"><img src="${absUrl('/logo-light.svg')}" style="height:28px;opacity:0.5;" /></div>
<div class="cover-body">
${coverImg
? `<div class="cover-circle"><img src="${escHtml(coverImg)}" /></div>`
: `<div class="cover-circle-ph"></div>`}
<div class="cover-label">${escHtml(tr('pdf.travelPlan'))}</div>
<div class="cover-title">${escHtml(trip?.name || 'Meine Reise')}</div>
<div class="cover-title">${escHtml(trip?.title || 'My Trip')}</div>
${trip?.description ? `<div class="cover-desc">${escHtml(trip.description)}</div>` : ''}
${range ? `<div class="cover-dates">${range}</div>` : ''}
<div class="cover-line"></div>
@@ -356,15 +389,11 @@ ${daysHtml}
</body></html>`
// Open print window
const blob = new Blob([html], { type: 'text/html' })
const url = URL.createObjectURL(blob)
// Modal in die App einfügen
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
const overlay = document.createElement('div')
overlay.id = 'pdf-preview-overlay'
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); URL.revokeObjectURL(url) } }
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
const card = document.createElement('div')
card.style.cssText = 'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);'
@@ -372,7 +401,7 @@ ${daysHtml}
const header = document.createElement('div')
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;'
header.innerHTML = `
<span style="font-size:13px;font-weight:600;color:var(--text-primary)">${escHtml(trip?.name || tr('pdf.travelPlan'))}</span>
<span style="font-size:13px;font-weight:600;color:var(--text-primary)">${escHtml(trip?.title || tr('pdf.travelPlan'))}</span>
<div style="display:flex;align-items:center;gap:8px">
<button id="pdf-print-btn" style="display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit">${tr('pdf.saveAsPdf')}</button>
<button id="pdf-close-btn" style="background:none;border:none;cursor:pointer;color:var(--text-faint);display:flex;padding:4px;border-radius:6px">
@@ -383,13 +412,14 @@ ${daysHtml}
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
iframe.src = url
iframe.sandbox = 'allow-same-origin allow-modals'
iframe.srcdoc = html
card.appendChild(header)
card.appendChild(iframe)
overlay.appendChild(card)
document.body.appendChild(overlay)
header.querySelector('#pdf-close-btn').onclick = () => { overlay.remove(); URL.revokeObjectURL(url) }
header.querySelector('#pdf-close-btn').onclick = () => overlay.remove()
header.querySelector('#pdf-print-btn').onclick = () => { iframe.contentWindow?.print() }
}
@@ -1,4 +1,4 @@
import React, { useState, useMemo, useRef } from 'react'
import { useState, useMemo, useRef } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
@@ -6,38 +6,39 @@ import {
CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight,
Sparkles, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage,
} from 'lucide-react'
import type { PackingItem } from '../../types'
const VORSCHLAEGE = [
{ name: 'Reisepass', kategorie: 'Dokumente' },
{ name: 'Reiseversicherung', kategorie: 'Dokumente' },
{ name: 'Visum-Unterlagen', kategorie: 'Dokumente' },
{ name: 'Flugtickets', kategorie: 'Dokumente' },
{ name: 'Hotelbuchungen', kategorie: 'Dokumente' },
{ name: 'Impfpass', kategorie: 'Dokumente' },
{ name: 'T-Shirts (5×)', kategorie: 'Kleidung' },
{ name: 'Hosen (2×)', kategorie: 'Kleidung' },
{ name: 'Unterwäsche (7×)', kategorie: 'Kleidung' },
{ name: 'Socken (7×)', kategorie: 'Kleidung' },
{ name: 'Jacke', kategorie: 'Kleidung' },
{ name: 'Badeanzug / Badehose', kategorie: 'Kleidung' },
{ name: 'Sportschuhe', kategorie: 'Kleidung' },
{ name: 'Zahnbürste', kategorie: 'Körperpflege' },
{ name: 'Zahnpasta', kategorie: 'Körperpflege' },
{ name: 'Shampoo', kategorie: 'Körperpflege' },
{ name: 'Sonnencreme', kategorie: 'Körperpflege' },
{ name: 'Deo', kategorie: 'Körperpflege' },
{ name: 'Rasierer', kategorie: 'Körperpflege' },
{ name: 'Ladekabel Handy', kategorie: 'Elektronik' },
{ name: 'Reiseadapter', kategorie: 'Elektronik' },
{ name: 'Kopfhörer', kategorie: 'Elektronik' },
{ name: 'Kamera', kategorie: 'Elektronik' },
{ name: 'Powerbank', kategorie: 'Elektronik' },
{ name: 'Erste-Hilfe-Set', kategorie: 'Gesundheit' },
{ name: 'Verschreibungspflichtige Medikamente', kategorie: 'Gesundheit' },
{ name: 'Schmerzmittel', kategorie: 'Gesundheit' },
{ name: 'Mückenschutz', kategorie: 'Gesundheit' },
{ name: 'Bargeld', kategorie: 'Finanzen' },
{ name: 'Kreditkarte', kategorie: 'Finanzen' },
{ name: 'Passport', category: 'Documents' },
{ name: 'Travel Insurance', category: 'Documents' },
{ name: 'Visa Documents', category: 'Documents' },
{ name: 'Flight Tickets', category: 'Documents' },
{ name: 'Hotel Bookings', category: 'Documents' },
{ name: 'Vaccination Card', category: 'Documents' },
{ name: 'T-Shirts (5x)', category: 'Clothing' },
{ name: 'Pants (2x)', category: 'Clothing' },
{ name: 'Underwear (7x)', category: 'Clothing' },
{ name: 'Socks (7x)', category: 'Clothing' },
{ name: 'Jacket', category: 'Clothing' },
{ name: 'Swimwear', category: 'Clothing' },
{ name: 'Sport Shoes', category: 'Clothing' },
{ name: 'Toothbrush', category: 'Toiletries' },
{ name: 'Toothpaste', category: 'Toiletries' },
{ name: 'Shampoo', category: 'Toiletries' },
{ name: 'Sunscreen', category: 'Toiletries' },
{ name: 'Deodorant', category: 'Toiletries' },
{ name: 'Razor', category: 'Toiletries' },
{ name: 'Phone Charger', category: 'Electronics' },
{ name: 'Travel Adapter', category: 'Electronics' },
{ name: 'Headphones', category: 'Electronics' },
{ name: 'Camera', category: 'Electronics' },
{ name: 'Power Bank', category: 'Electronics' },
{ name: 'First Aid Kit', category: 'Health' },
{ name: 'Prescription Medication', category: 'Health' },
{ name: 'Pain Medication', category: 'Health' },
{ name: 'Insect Repellent', category: 'Health' },
{ name: 'Cash', category: 'Finances' },
{ name: 'Credit Card', category: 'Finances' },
]
// Cycling color palette — works in light & dark mode
@@ -64,7 +65,14 @@ function katColor(kat, allCategories) {
}
// ── Artikel-Zeile ──────────────────────────────────────────────────────────
function ArtikelZeile({ item, tripId, categories, onCategoryChange }) {
interface ArtikelZeileProps {
item: PackingItem
tripId: number
categories: string[]
onCategoryChange: () => void
}
function ArtikelZeile({ item, tripId, categories, onCategoryChange }: ArtikelZeileProps) {
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(item.name)
const [hovered, setHovered] = useState(false)
@@ -178,7 +186,16 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange }) {
}
// ── Kategorie-Gruppe ───────────────────────────────────────────────────────
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll }) {
interface KategorieGruppeProps {
kategorie: string
items: PackingItem[]
tripId: number
allCategories: string[]
onRename: (oldName: string, newName: string) => Promise<void>
onDeleteAll: (items: PackingItem[]) => Promise<void>
}
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll }: KategorieGruppeProps) {
const [offen, setOffen] = useState(true)
const [editingName, setEditingName] = useState(false)
const [editKatName, setEditKatName] = useState(kategorie)
@@ -198,12 +215,12 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
}
const handleCheckAll = async () => {
for (const item of items) {
for (const item of Array.from(items)) {
if (!item.checked) await togglePackingItem(tripId, item.id, true)
}
}
const handleUncheckAll = async () => {
for (const item of items) {
for (const item of Array.from(items)) {
if (item.checked) await togglePackingItem(tripId, item.id, false)
}
}
@@ -272,7 +289,14 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
)
}
function MenuItem({ icon, label, onClick, danger }) {
interface MenuItemProps {
icon: React.ReactNode
label: string
onClick: () => void
danger: boolean
}
function MenuItem({ icon, label, onClick, danger }: MenuItemProps) {
return (
<button onClick={onClick} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
@@ -289,7 +313,12 @@ function MenuItem({ icon, label, onClick, danger }) {
}
// ── Haupt-Panel ────────────────────────────────────────────────────────────
export default function PackingListPanel({ tripId, items }) {
interface PackingListPanelProps {
tripId: number
items: PackingItem[]
}
export default function PackingListPanel({ tripId, items }: PackingListPanelProps) {
const [neuerName, setNeuerName] = useState('')
const [neueKategorie, setNeueKategorie] = useState('')
const [zeigeVorschlaege, setZeigeVorschlaege] = useState(false)
@@ -1,10 +1,23 @@
import React, { useState, useMemo } from 'react'
import { useState, useMemo } from 'react'
import { PhotoLightbox } from './PhotoLightbox'
import { PhotoUpload } from './PhotoUpload'
import { Upload, Camera } from 'lucide-react'
import Modal from '../shared/Modal'
import { useTranslation } from '../../i18n'
import type { Photo, Place, Day } from '../../types'
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }) {
interface PhotoGalleryProps {
photos: Photo[]
onUpload: (fd: FormData) => Promise<void>
onDelete: (photoId: number) => Promise<void>
onUpdate: (photoId: number, data: Partial<Photo>) => Promise<void>
places: Place[]
days: Day[]
tripId: number
}
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }: PhotoGalleryProps) {
const { t } = useTranslation()
const [lightboxIndex, setLightboxIndex] = useState(null)
const [showUpload, setShowUpload] = useState(false)
const [filterDayId, setFilterDayId] = useState('')
@@ -49,7 +62,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
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"
>
<option value="">Alle Tage</option>
<option value="">{t('photos.allDays')}</option>
{(days || []).map(day => (
<option key={day.id} value={day.id}>
Tag {day.day_number}{day.date ? ` · ${formatDate(day.date)}` : ''}
@@ -62,7 +75,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
onClick={() => setFilterDayId('')}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Zurücksetzen
{t('common.reset')}
</button>
)}
@@ -80,8 +93,8 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
{filteredPhotos.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: '#9ca3af' }}>
<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: 13, color: '#9ca3af', margin: '0 0 20px' }}>Lade deine Reisefotos hoch</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' }}>{t('photos.uploadHint')}</p>
<button
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"
@@ -109,7 +122,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"
>
<Upload className="w-6 h-6" />
<span className="text-xs">Hinzufügen</span>
<span className="text-xs">{t('common.add')}</span>
</button>
</div>
)}
@@ -151,7 +164,14 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
)
}
function PhotoThumbnail({ photo, days, places, onClick }) {
interface PhotoThumbnailProps {
photo: Photo
days: Day[]
places: Place[]
onClick: () => void
}
function PhotoThumbnail({ photo, days, places, onClick }: PhotoThumbnailProps) {
const day = days?.find(d => d.id === photo.day_id)
const place = places?.find(p => p.id === photo.place_id)
@@ -166,8 +186,8 @@ function PhotoThumbnail({ photo, days, places, onClick }) {
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
onError={e => {
e.target.style.display = 'none'
e.target.nextSibling && (e.target.nextSibling.style.display = 'flex')
(e.target as HTMLImageElement).style.display = 'none'
const next = (e.target as HTMLImageElement).nextSibling as HTMLElement; if (next) next.style.display = 'flex'
}}
/>
@@ -1,7 +1,21 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { X, ChevronLeft, ChevronRight, Edit2, Trash2, Check } from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { Photo, Place, Day } from '../../types'
export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelete, days, places, tripId }) {
interface PhotoLightboxProps {
photos: Photo[]
initialIndex: number
onClose: () => void
onUpdate: (photoId: number, data: Partial<Photo>) => Promise<void>
onDelete: (photoId: number) => Promise<void>
days: Day[]
places: Place[]
tripId: number
}
export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelete, days, places, tripId }: PhotoLightboxProps) {
const { t } = useTranslation()
const [index, setIndex] = useState(initialIndex || 0)
const [editCaption, setEditCaption] = useState(false)
const [caption, setCaption] = useState('')
@@ -81,7 +95,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
<button
onClick={handleDelete}
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" />
</button>
@@ -1,8 +1,19 @@
import React, { useState, useCallback } from 'react'
import { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, X, Image } from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { Place, Day } from '../../types'
export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
interface PhotoUploadProps {
tripId: number
days: Day[]
places: Place[]
onUpload: (fd: FormData) => Promise<void>
onClose: () => void
}
export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUploadProps) {
const { t } = useTranslation()
const [files, setFiles] = useState([])
const [dayId, setDayId] = useState('')
const [placeId, setPlaceId] = useState('')
@@ -46,7 +57,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
await onUpload(formData)
files.forEach(f => URL.revokeObjectURL(f.preview))
setFiles([])
} catch (err) {
} catch (err: unknown) {
console.error('Upload failed:', err)
} finally {
setUploading(false)
@@ -78,7 +89,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
) : (
<>
<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>
</>
)}
@@ -128,13 +139,13 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
</select>
</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
value={placeId}
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"
>
<option value="">Kein Ort</option>
<option value="">{t('photos.noPlace')}</option>
{(places || []).map(place => (
<option key={place.id} value={place.id}>{place.name}</option>
))}
@@ -175,7 +186,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
onClick={onClose}
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
onClick={handleUpload}
@@ -183,7 +194,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"
>
<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>
</div>
</div>
@@ -1,504 +0,0 @@
import React, { useState, useEffect } from 'react'
import Modal from '../shared/Modal'
import { mapsApi, tagsApi, categoriesApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useAuthStore } from '../../store/authStore'
import { Search, Plus, MapPin, Loader } from 'lucide-react'
const STATUSES = [
{ value: 'none', label: 'None' },
{ value: 'pending', label: 'Pending' },
{ value: 'confirmed', label: 'Confirmed' },
]
export default function PlaceFormModal({
isOpen,
onClose,
onSave,
place,
tripId,
categories: initialCategories = [],
tags: initialTags = [],
onCategoryCreated,
onTagCreated,
}) {
const isEditing = !!place
const { user } = useAuthStore()
const toast = useToast()
const [categories, setCategories] = useState(initialCategories)
const [tags, setTags] = useState(initialTags)
useEffect(() => { setCategories(initialCategories) }, [initialCategories])
useEffect(() => { setTags(initialTags) }, [initialTags])
const emptyForm = {
name: '',
description: '',
address: '',
lat: '',
lng: '',
category_id: '',
place_time: '',
reservation_status: 'none',
reservation_notes: '',
reservation_datetime: '',
google_place_id: '',
website: '',
tags: [],
}
const [formData, setFormData] = useState(emptyForm)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
// Maps search state
const [mapQuery, setMapQuery] = useState('')
const [mapResults, setMapResults] = useState([])
const [mapSearching, setMapSearching] = useState(false)
// New category/tag
const [newCategoryName, setNewCategoryName] = useState('')
const [newCategoryColor, setNewCategoryColor] = useState('#374151')
const [showNewCategory, setShowNewCategory] = useState(false)
const [newTagName, setNewTagName] = useState('')
const [newTagColor, setNewTagColor] = useState('#374151')
const [showNewTag, setShowNewTag] = useState(false)
useEffect(() => {
if (place && isOpen) {
setFormData({
name: place.name || '',
description: place.description || '',
address: place.address || '',
lat: place.lat ?? '',
lng: place.lng ?? '',
category_id: place.category_id || '',
place_time: place.place_time || '',
reservation_status: place.reservation_status || 'none',
reservation_notes: place.reservation_notes || '',
reservation_datetime: place.reservation_datetime || '',
google_place_id: place.google_place_id || '',
website: place.website || '',
tags: (place.tags || []).map(t => t.id),
})
} else if (!place && isOpen) {
setFormData(emptyForm)
}
setError('')
setMapResults([])
setMapQuery('')
}, [place, isOpen])
const update = (field, value) => setFormData(prev => ({ ...prev, [field]: value }))
const toggleTag = (tagId) => {
setFormData(prev => ({
...prev,
tags: prev.tags.includes(tagId)
? prev.tags.filter(id => id !== tagId)
: [...prev.tags, tagId]
}))
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!formData.name.trim()) {
setError('Place name is required')
return
}
setIsLoading(true)
setError('')
try {
await onSave({
...formData,
lat: formData.lat !== '' ? parseFloat(formData.lat) : null,
lng: formData.lng !== '' ? parseFloat(formData.lng) : null,
category_id: formData.category_id || null,
})
onClose()
} catch (err) {
setError(err.message || 'Failed to save place')
} finally {
setIsLoading(false)
}
}
const handleMapSearch = async () => {
if (!mapQuery.trim()) return
setMapSearching(true)
try {
const data = await mapsApi.search(mapQuery)
setMapResults(data.places || [])
} catch (err) {
toast.error(err.response?.data?.error || 'Maps search failed')
} finally {
setMapSearching(false)
}
}
const selectMapPlace = (p) => {
setFormData(prev => ({
...prev,
name: p.name || prev.name,
address: p.address || prev.address,
lat: p.lat ?? prev.lat,
lng: p.lng ?? prev.lng,
google_place_id: p.google_place_id || prev.google_place_id,
website: p.website || prev.website,
}))
setMapResults([])
setMapQuery('')
}
const handleCreateCategory = async () => {
if (!newCategoryName.trim()) return
try {
const data = await categoriesApi.create({ name: newCategoryName, color: newCategoryColor, icon: 'MapPin' })
setCategories(prev => [...prev, data.category])
if (onCategoryCreated) onCategoryCreated(data.category)
setFormData(prev => ({ ...prev, category_id: data.category.id }))
setNewCategoryName('')
setShowNewCategory(false)
toast.success('Category created')
} catch (err) {
toast.error('Failed to create category')
}
}
const handleCreateTag = async () => {
if (!newTagName.trim()) return
try {
const data = await tagsApi.create({ name: newTagName, color: newTagColor })
setTags(prev => [...prev, data.tag])
if (onTagCreated) onTagCreated(data.tag)
setFormData(prev => ({ ...prev, tags: [...prev.tags, data.tag.id] }))
setNewTagName('')
setShowNewTag(false)
toast.success('Tag created')
} catch (err) {
toast.error('Failed to create tag')
}
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditing ? 'Edit Place' : 'Add Place'}
size="xl"
footer={
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={isLoading}
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 disabled:bg-slate-400 text-white rounded-lg flex items-center gap-2"
>
{isLoading ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
Saving...
</>
) : isEditing ? 'Save Changes' : 'Add Place'}
</button>
</div>
}
>
<div className="space-y-5">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
{error}
</div>
)}
{/* Google Maps search — always visible when API key is set */}
{user?.maps_api_key && (
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={mapQuery}
onChange={e => setMapQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleMapSearch()}
placeholder="Google Maps suchen..."
className="w-full pl-8 pr-3 py-2 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
/>
</div>
<button
onClick={handleMapSearch}
disabled={mapSearching}
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-50"
>
{mapSearching ? <Loader className="w-4 h-4 animate-spin" /> : 'Suchen'}
</button>
</div>
{mapResults.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 max-h-48 overflow-y-auto mt-2">
{mapResults.map((p, i) => (
<button
key={p.google_place_id || i}
onClick={() => selectMapPlace(p)}
className="w-full text-left px-3 py-2.5 hover:bg-slate-50 transition-colors border-b border-slate-100 last:border-0"
>
<p className="text-sm font-medium text-slate-900">{p.name}</p>
<p className="text-xs text-slate-500 truncate flex items-center gap-1 mt-0.5">
<MapPin className="w-3 h-3" />
{p.address}
</p>
{p.rating && (
<p className="text-xs text-amber-600 mt-0.5"> {p.rating}</p>
)}
</button>
))}
</div>
)}
</div>
)}
{/* Name */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={e => update('name', e.target.value)}
required
placeholder="e.g. Eiffel Tower"
className="w-full px-3 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"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Description</label>
<textarea
value={formData.description}
onChange={e => update('description', e.target.value)}
placeholder="Notes about this place..."
rows={2}
className="w-full px-3 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 resize-none"
/>
</div>
{/* Address */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Address</label>
<input
type="text"
value={formData.address}
onChange={e => update('address', e.target.value)}
placeholder="Street address"
className="w-full px-3 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"
/>
</div>
{/* Lat / Lng */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Latitude</label>
<input
type="number"
step="any"
value={formData.lat}
onChange={e => update('lat', e.target.value)}
placeholder="e.g. 48.8584"
className="w-full px-3 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Longitude</label>
<input
type="number"
step="any"
value={formData.lng}
onChange={e => update('lng', e.target.value)}
placeholder="e.g. 2.2945"
className="w-full px-3 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"
/>
</div>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Category</label>
<div className="flex gap-2">
<select
value={formData.category_id}
onChange={e => update('category_id', e.target.value)}
className="flex-1 px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
>
<option value="">No category</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
<button
type="button"
onClick={() => setShowNewCategory(!showNewCategory)}
className="px-3 py-2.5 border border-slate-300 rounded-lg text-slate-500 hover:text-slate-700 hover:border-slate-400 transition-colors"
title="Create new category"
>
<Plus className="w-4 h-4" />
</button>
</div>
{showNewCategory && (
<div className="mt-2 flex gap-2">
<input
type="text"
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
placeholder="Category name"
className="flex-1 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<input
type="color"
value={newCategoryColor}
onChange={e => setNewCategoryColor(e.target.value)}
className="w-10 h-10 border border-slate-300 rounded-lg cursor-pointer p-1"
title="Category color"
/>
<button
type="button"
onClick={handleCreateCategory}
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700"
>
Add
</button>
</div>
)}
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Tags</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{tags.map(tag => (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.id)}
className={`text-xs px-2.5 py-1 rounded-full font-medium transition-all ${
formData.tags.includes(tag.id)
? 'text-white shadow-sm ring-2 ring-offset-1'
: 'text-white opacity-50 hover:opacity-80'
}`}
style={{
backgroundColor: tag.color || '#374151',
ringColor: formData.tags.includes(tag.id) ? tag.color : 'transparent'
}}
>
{tag.name}
</button>
))}
<button
type="button"
onClick={() => setShowNewTag(!showNewTag)}
className="text-xs px-2.5 py-1 border border-dashed border-slate-300 rounded-full text-slate-500 hover:border-slate-400 hover:text-slate-700 transition-colors"
>
<Plus className="inline w-3 h-3 mr-0.5" />
New tag
</button>
</div>
{showNewTag && (
<div className="flex gap-2">
<input
type="text"
value={newTagName}
onChange={e => setNewTagName(e.target.value)}
placeholder="Tag name"
className="flex-1 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<input
type="color"
value={newTagColor}
onChange={e => setNewTagColor(e.target.value)}
className="w-10 h-10 border border-slate-300 rounded-lg cursor-pointer p-1"
/>
<button
type="button"
onClick={handleCreateTag}
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700"
>
Add
</button>
</div>
)}
</div>
{/* Time & Reservation */}
<div className="grid grid-cols-2 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>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation</label>
<select
value={formData.reservation_status}
onChange={e => update('reservation_status', 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 bg-white"
>
{STATUSES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
</div>
{/* Reservation details */}
{formData.reservation_status !== 'none' && (
<div className="space-y-3 p-3 bg-amber-50 rounded-lg border border-amber-200">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation Date & Time</label>
<input
type="datetime-local"
value={formData.reservation_datetime}
onChange={e => update('reservation_datetime', 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 bg-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation Notes</label>
<textarea
value={formData.reservation_notes}
onChange={e => update('reservation_notes', e.target.value)}
placeholder="Confirmation number, special requests..."
rows={2}
className="w-full px-3 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 resize-none bg-white"
/>
</div>
</div>
)}
{/* Website */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Website</label>
<input
type="url"
value={formData.website}
onChange={e => update('website', e.target.value)}
placeholder="https://..."
className="w-full px-3 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"
/>
</div>
</div>
</Modal>
)
}
@@ -1,149 +0,0 @@
import React from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVertical, X, Edit2, Clock, DollarSign, CheckCircle, Clock3, MapPin } from 'lucide-react'
export default function AssignedPlaceItem({ assignment, dayId, onRemove, onEdit }) {
const { place } = assignment
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: `assignment-${assignment.id}`,
data: {
type: 'assignment',
dayId: dayId,
assignment,
},
})
const style = {
transform: CSS.Transform.toString(transform),
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 (
<div
ref={setNodeRef}
style={style}
className={`
group bg-white border rounded-lg p-2.5 transition-all
${isDragging
? 'opacity-40 border-slate-300 shadow-lg'
: 'border-slate-200 hover:border-slate-300 hover:shadow-sm'
}
`}
>
<div className="flex items-start gap-2">
{/* Drag handle */}
<button
{...attributes}
{...listeners}
className="drag-handle mt-0.5 p-0.5 text-slate-300 hover:text-slate-500 flex-shrink-0 rounded touch-none"
tabIndex={-1}
>
<GripVertical className="w-4 h-4" />
</button>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Name row */}
<div className="flex items-center gap-1.5 mb-1">
{place.category && (
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: place.category.color || '#6366f1' }}
/>
)}
<span className="text-sm font-medium text-slate-800 truncate">{place.name}</span>
{reservationIcon()}
</div>
{/* Time & price row */}
<div className="flex items-center gap-2 mb-1">
{place.place_time && (
<span className="flex items-center gap-1 text-xs text-slate-600 bg-slate-50 px-1.5 py-0.5 rounded">
<Clock className="w-3 h-3" />
{place.place_time}
</span>
)}
{place.price != null && (
<span className="flex items-center gap-1 text-xs text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
<DollarSign className="w-3 h-3" />
{Number(place.price).toLocaleString()} {place.currency || ''}
</span>
)}
</div>
{/* Address */}
{place.address && (
<p className="text-xs text-slate-400 truncate flex items-center gap-1">
<MapPin className="w-3 h-3 flex-shrink-0" />
{place.address}
</p>
)}
{/* Category badge */}
{place.category && (
<span
className="inline-block mt-1 text-xs px-1.5 py-0.5 rounded text-white text-[10px] font-medium"
style={{ backgroundColor: place.category.color || '#6366f1' }}
>
{place.category.name}
</span>
)}
{/* Tags */}
{place.tags && place.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{place.tags.map(tag => (
<span
key={tag.id}
className="text-[10px] px-1.5 py-0.5 rounded-full text-white font-medium"
style={{ backgroundColor: tag.color || '#6366f1' }}
>
{tag.name}
</span>
))}
</div>
)}
</div>
{/* Action buttons */}
<div className="flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{onEdit && (
<button
onClick={() => onEdit(place)}
className="p-1 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded transition-colors"
title="Edit place"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => onRemove(assignment.id)}
className="p-1 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
title="Remove from day"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
)
}
-177
View File
@@ -1,177 +0,0 @@
import React, { useState } from 'react'
import { useDroppable } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import AssignedPlaceItem from './AssignedPlaceItem'
import { ChevronDown, ChevronUp, Plus, FileText, Package, DollarSign } from 'lucide-react'
export default function DayColumn({
day,
assignments,
tripId,
onRemoveAssignment,
onEditPlace,
onQuickAdd,
}) {
const [isCollapsed, setIsCollapsed] = useState(false)
const [showNotes, setShowNotes] = useState(false)
const [notes, setNotes] = useState(day.notes || '')
const [notesEditing, setNotesEditing] = useState(false)
const { isOver, setNodeRef } = useDroppable({
id: `day-${day.id}`,
data: {
type: 'day',
dayId: day.id,
},
})
const sortableIds = (assignments || []).map(a => `assignment-${a.id}`)
const totalCost = (assignments || []).reduce((sum, a) => {
return sum + (a.place?.price ? Number(a.place.price) : 0)
}, 0)
const formatDate = (dateStr) => {
if (!dateStr) return null
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
}
return (
<div
className={`
flex-shrink-0 w-72 flex flex-col rounded-xl border-2 transition-all duration-150
${isOver
? 'border-slate-400 bg-slate-50 shadow-lg shadow-slate-100'
: 'border-transparent bg-white shadow-sm'
}
`}
>
{/* Header */}
<div
className={`
px-3 py-2.5 border-b flex items-center gap-2 rounded-t-xl
${isOver ? 'border-slate-200 bg-slate-50' : 'border-slate-100 bg-slate-50'}
`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-bold text-slate-900">Day {day.day_number}</span>
<span className="text-xs bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded-full font-medium">
{assignments?.length || 0}
</span>
</div>
{day.date && (
<p className="text-xs text-slate-500 mt-0.5">{formatDate(day.date)}</p>
)}
</div>
<div className="flex items-center gap-1">
{totalCost > 0 && (
<span className="flex items-center gap-0.5 text-xs text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
<DollarSign className="w-3 h-3" />
{totalCost.toLocaleString()}
</span>
)}
<button
onClick={() => setShowNotes(!showNotes)}
className={`p-1 rounded transition-colors ${showNotes ? 'text-slate-700 bg-slate-100' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'}`}
title="Notes"
>
<FileText className="w-4 h-4" />
</button>
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="p-1 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded transition-colors"
>
{isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
</button>
</div>
</div>
{/* Notes area */}
{showNotes && (
<div className="px-3 py-2 border-b border-slate-100 bg-amber-50">
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
onBlur={() => setNotesEditing(false)}
onFocus={() => setNotesEditing(true)}
placeholder="Add notes for this day..."
rows={2}
className="w-full text-xs text-slate-600 bg-transparent resize-none focus:outline-none placeholder-amber-400"
/>
{notesEditing && (
<div className="flex gap-2 mt-1">
<button
onMouseDown={(e) => {
e.preventDefault()
// Parent will handle save via onUpdateNotes if passed
}}
className="text-xs text-slate-600 hover:text-slate-900"
>
Save
</button>
</div>
)}
</div>
)}
{/* Assignments list */}
{!isCollapsed && (
<div
ref={setNodeRef}
className={`
flex-1 p-2 flex flex-col gap-2 min-h-24 transition-colors duration-150
${isOver ? 'bg-slate-50' : 'bg-transparent'}
`}
>
{assignments && assignments.length > 0 ? (
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{assignments.map(assignment => (
<AssignedPlaceItem
key={assignment.id}
assignment={assignment}
dayId={day.id}
onRemove={(id) => onRemoveAssignment(day.id, id)}
onEdit={onEditPlace}
/>
))}
</SortableContext>
) : (
<div className={`
flex-1 flex flex-col items-center justify-center py-6 rounded-lg border-2 border-dashed
text-xs text-center transition-colors
${isOver
? 'border-slate-400 bg-slate-100 text-slate-500'
: 'border-slate-200 text-slate-400'
}
`}>
<Package className="w-8 h-8 mb-2 opacity-50" />
<p className="font-medium">Drop places here</p>
<p className="text-[10px] mt-0.5 opacity-70">or drag from the left panel</p>
</div>
)}
{/* Quick add button */}
<button
onClick={() => onQuickAdd(day)}
className="flex items-center justify-center gap-1 py-1.5 text-xs text-slate-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg border border-dashed border-slate-200 hover:border-slate-300 transition-all mt-1"
>
<Plus className="w-3.5 h-3.5" />
Add place
</button>
</div>
)}
{isCollapsed && (
<div
className="px-3 py-2 text-xs text-slate-400 cursor-pointer hover:bg-slate-50"
onClick={() => setIsCollapsed(false)}
>
{assignments?.length || 0} place{(assignments?.length || 0) !== 1 ? 's' : ''} click to expand
</div>
)}
</div>
)
}
@@ -0,0 +1,598 @@
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'
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
const WEATHER_ICON_MAP = {
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
Thunderstorm: CloudLightning, Snow: CloudSnow, Mist: Wind, Fog: Wind, Haze: Wind,
}
interface WIconProps {
main: string
size?: number
}
function WIcon({ main, size = 14 }: WIconProps) {
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}`
}
interface DayDetailPanelProps {
day: Day
days: Day[]
places: Place[]
categories?: Category[]
tripId: number
assignments: AssignmentsMap
reservations?: Reservation[]
lat: number | null
lng: number | null
onClose: () => void
onAccommodationChange: () => void
}
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange }: DayDetailPanelProps) {
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>
)
)}
{/* ── 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 }}>
{day.date && lat && lng && <div style={{ height: 1, background: 'var(--border-faint)', margin: '12px 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?.includes('T') && (
<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 })}
{r.reservation_end_time && ` ${fmtTime(r.reservation_end_time)}`}
</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>
)
}
interface ChipProps {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
value: string
}
function Chip({ icon: Icon, value }: ChipProps) {
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>
)
}
interface InfoChipProps {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
label: string
value: string
placeholder: string
onEdit: (value: string) => void
type: 'text' | 'time'
}
function InfoChip({ icon: Icon, label, value, placeholder, onEdit, type }: InfoChipProps) {
const [editing, setEditing] = React.useState(false)
const [val, setVal] = React.useState(value || '')
const inputRef = React.useRef(null)
React.useEffect(() => { setVal(value || '') }, [value])
React.useEffect(() => { if (editing && inputRef.current) inputRef.current.focus() }, [editing])
const save = () => {
setEditing(false)
if (val !== (value || '')) onEdit(val)
}
return (
<div
onClick={() => setEditing(true)}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
cursor: 'pointer', minWidth: 0, flex: type === 'text' ? 1 : undefined,
}}
>
<Icon size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 8, color: 'var(--text-faint)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', lineHeight: 1 }}>{label}</div>
{editing ? (
<input
ref={inputRef}
type={type}
value={val}
onChange={e => setVal(e.target.value)}
onBlur={save}
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setVal(value || ''); setEditing(false) } }}
onClick={e => e.stopPropagation()}
style={{
border: 'none', outline: 'none', background: 'none', padding: 0, margin: 0,
fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', fontFamily: 'inherit',
width: type === 'time' ? 50 : '100%', lineHeight: 1.3,
}}
/>
) : (
<div style={{ fontSize: 11, fontWeight: 600, color: value ? 'var(--text-primary)' : 'var(--text-faint)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{value || placeholder}
</div>
)}
</div>
</div>
)
}
@@ -1,42 +1,25 @@
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string }
declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, AlertCircle, CheckCircle2, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown } from 'lucide-react'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react'
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
import { downloadTripPDF } from '../PDF/TripPDF'
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import WeatherWidget from '../Weather/WeatherWidget'
import { useToast } from '../shared/Toast'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
function formatDate(dateStr, locale) {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, {
weekday: 'short', day: 'numeric', month: 'short',
})
}
function formatTime(timeStr, locale, timeFormat) {
if (!timeStr) return ''
try {
const [h, m] = timeStr.split(':').map(Number)
if (timeFormat === '12h') {
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
return `${h12}:${String(m).padStart(2, '0')} ${period}`
}
const str = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
return locale?.startsWith('de') ? `${str} Uhr` : str
} catch { return timeStr }
}
function dayTotalCost(dayId, assignments, currency) {
const da = assignments[String(dayId)] || []
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
return total > 0 ? `${total.toFixed(0)} ${currency}` : null
}
import { formatDate, formatTime, dayTotalCost } from '../../utils/formatters'
import { useDayNotes } from '../../hooks/useDayNotes'
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
const NOTE_ICONS = [
{ id: 'FileText', Icon: FileText },
@@ -68,41 +51,67 @@ const TYPE_ICONS = {
car: '🚗', cruise: '🚢', event: '🎫', other: '📋',
}
interface DayPlanSidebarProps {
tripId: number
trip: Trip
days: Day[]
places: Place[]
categories: Category[]
assignments: AssignmentsMap
selectedDayId: number | null
selectedPlaceId: number | null
selectedAssignmentId: number | null
onSelectDay: (dayId: number | null) => void
onPlaceClick: (placeId: number) => void
onDayDetail: (day: Day) => void
accommodations?: Assignment[]
onReorder: (dayId: number, orderedIds: number[]) => void
onUpdateDayTitle: (dayId: number, title: string) => void
onRouteCalculated: (dayId: number, route: RouteResult | null) => void
onAssignToDay: (placeId: number, dayId: number) => void
onRemoveAssignment: (assignmentId: number, dayId: number) => void
onEditPlace: (place: Place) => void
onDeletePlace: (placeId: number) => void
reservations?: Reservation[]
onAddReservation: () => void
}
export default function DayPlanSidebar({
tripId,
trip, days, places, categories, assignments,
selectedDayId, selectedPlaceId,
onSelectDay, onPlaceClick,
selectedDayId, selectedPlaceId, selectedAssignmentId,
onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
onReorder, onUpdateDayTitle, onRouteCalculated,
onAssignToDay,
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
reservations = [],
onAddReservation,
}) {
}: DayPlanSidebarProps) {
const toast = useToast()
const { t, locale } = useTranslation()
const { t, language, locale } = useTranslation()
const ctxMenu = useContextMenu()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
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 { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
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 [editTitle, setEditTitle] = useState('')
const [transportMode, setTransportMode] = useState('driving')
const [isCalculating, setIsCalculating] = useState(false)
const [routeInfo, setRouteInfo] = useState(null)
const [draggingId, setDraggingId] = useState(null)
const [lockedIds, setLockedIds] = useState(new Set())
const [lockHoverId, setLockHoverId] = useState(null)
const [dropTargetKey, setDropTargetKey] = useState(null)
const [dragOverDayId, setDragOverDayId] = useState(null)
const [hoveredId, setHoveredId] = useState(null)
const [noteUi, setNoteUi] = useState({}) // { [dayId]: { mode, text, time, noteId?, sortOrder? } }
const inputRef = useRef(null)
const noteInputRef = useRef(null)
const dragDataRef = useRef(null) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
const currency = trip?.currency || 'EUR'
@@ -125,9 +134,20 @@ export default function DayPlanSidebar({
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(() => {
setExpandedDays(prev => new Set([...prev, ...days.map(d => d.id)]))
}, [days.length])
if (days.length > prevDayCount.current) {
// 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(() => {
if (editingDayId && inputRef.current) inputRef.current.focus()
@@ -151,6 +171,7 @@ export default function DayPlanSidebar({
setExpandedDays(prev => {
const n = new Set(prev)
n.has(dayId) ? n.delete(dayId) : n.add(dayId)
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
return n
})
}
@@ -169,52 +190,32 @@ export default function DayPlanSidebar({
const openAddNote = (dayId, e) => {
e?.stopPropagation()
const merged = getMergedItems(dayId)
const maxKey = merged.length > 0 ? Math.max(...merged.map(i => i.sortKey)) : -1
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', icon: 'FileText', sortOrder: maxKey + 1 } }))
if (!expandedDays.has(dayId)) setExpandedDays(prev => new Set([...prev, dayId]))
setTimeout(() => noteInputRef.current?.focus(), 50)
_openAddNote(dayId, getMergedItems, (id) => {
if (!expandedDays.has(id)) setExpandedDays(prev => new Set([...prev, id]))
})
}
const openEditNote = (dayId, note, e) => {
e?.stopPropagation()
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '', icon: note.icon || 'FileText' } }))
setTimeout(() => noteInputRef.current?.focus(), 50)
}
const cancelNote = (dayId) => {
setNoteUi(prev => { const n = { ...prev }; delete n[dayId]; return n })
}
const saveNote = async (dayId) => {
const ui = noteUi[dayId]
if (!ui?.text?.trim()) return
try {
if (ui.mode === 'add') {
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText', sort_order: ui.sortOrder })
} else {
await tripStore.updateDayNote(tripId, dayId, ui.noteId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText' })
}
cancelNote(dayId)
} catch (err) { toast.error(err.message) }
_openEditNote(dayId, note)
}
const deleteNote = async (dayId, noteId, e) => {
e?.stopPropagation()
try { await tripStore.deleteDayNote(tripId, dayId, noteId) }
catch (err) { toast.error(err.message) }
await _deleteNote(dayId, noteId)
}
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId) => {
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
const m = getMergedItems(dayId)
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
// Neue Reihenfolge erstellen — VOR dem Ziel einfügen (Standardkonvention)
// Neue Reihenfolge erstellen — VOR dem Ziel einfügen (Standard), oder NACH dem Ziel wenn insertAfter
const newOrder = [...m]
const [moved] = newOrder.splice(fromIdx, 1)
const adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx
let adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx
if (insertAfter) adjustedTo += 1
newOrder.splice(adjustedTo, 0, moved)
// Orte: neuer order_index über onReorder
@@ -241,26 +242,14 @@ export default function DayPlanSidebar({
for (const n of noteChanges) {
await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
}
} catch (err) { toast.error(err.message) }
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
setDraggingId(null)
setDropTargetKey(null)
dragDataRef.current = null
}
const moveNote = async (dayId, noteId, direction) => {
const merged = getMergedItems(dayId)
const idx = merged.findIndex(i => i.type === 'note' && i.data.id === noteId)
if (idx === -1) return
let newSortOrder
if (direction === 'up') {
if (idx === 0) return
newSortOrder = idx >= 2 ? (merged[idx - 2].sortKey + merged[idx - 1].sortKey) / 2 : merged[idx - 1].sortKey - 1
} else {
if (idx >= merged.length - 1) return
newSortOrder = idx < merged.length - 2 ? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2 : merged[idx + 1].sortKey + 1
}
try { await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder }) }
catch (err) { toast.error(err.message) }
await _moveNote(dayId, noteId, direction, getMergedItems)
}
const startEditTitle = (day, e) => {
@@ -281,7 +270,7 @@ export default function DayPlanSidebar({
if (waypoints.length < 2) { toast.error(t('dayplan.toast.needTwoPlaces')); return }
setIsCalculating(true)
try {
const result = await calculateRoute(waypoints, transportMode)
const result = await calculateRoute(waypoints, 'walking')
// Luftlinien zwischen Wegpunkten anzeigen
const lineCoords = waypoints.map(p => [p.lat, p.lng])
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
@@ -290,15 +279,45 @@ export default function DayPlanSidebar({
finally { setIsCalculating(false) }
}
const toggleLock = (assignmentId) => {
setLockedIds(prev => {
const next = new Set(prev)
if (next.has(assignmentId)) next.delete(assignmentId)
else next.add(assignmentId)
return next
})
}
const handleOptimize = async () => {
if (!selectedDayId) return
const da = getDayAssignments(selectedDayId)
if (da.length < 3) return
const withCoords = da.map(a => a.place).filter(p => p?.lat && p?.lng)
const optimized = optimizeRoute(withCoords)
const reorderedIds = optimized.map(p => da.find(a => a.place?.id === p.id)?.id).filter(Boolean)
for (const a of da) { if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id) }
await onReorder(selectedDayId, reorderedIds)
// Separate locked (stay at their index) and unlocked assignments
const locked = new Map() // index -> assignment
const unlocked = []
da.forEach((a, i) => {
if (lockedIds.has(a.id)) locked.set(i, a)
else unlocked.push(a)
})
// Optimize only unlocked assignments (work on assignments, not places)
const unlockedWithCoords = unlocked.filter(a => a.place?.lat && a.place?.lng)
const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng)
const optimizedAssignments = unlockedWithCoords.length >= 2
? optimizeRoute(unlockedWithCoords.map(a => ({ ...a.place, _assignmentId: a.id }))).map(p => unlockedWithCoords.find(a => a.id === p._assignmentId)).filter(Boolean)
: unlockedWithCoords
const optimizedQueue = [...optimizedAssignments, ...unlockedNoCoords]
// Merge: locked stay at their index, fill gaps with optimized
const result = new Array(da.length)
locked.forEach((a, i) => { result[i] = a })
let qi = 0
for (let i = 0; i < result.length; i++) {
if (!result[i]) result[i] = optimizedQueue[qi++]
}
await onReorder(selectedDayId, result.map(a => a.id))
toast.success(t('dayplan.toast.routeOptimized'))
}
@@ -317,9 +336,9 @@ export default function DayPlanSidebar({
if (placeId) {
onAssignToDay?.(parseInt(placeId), dayId)
} else if (assignmentId && fromDayId !== dayId) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch(err => toast.error(err.message))
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (noteId && fromDayId !== dayId) {
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch(err => toast.error(err.message))
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
}
setDraggingId(null)
setDropTargetKey(null)
@@ -415,7 +434,7 @@ export default function DayPlanSidebar({
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
<div
onClick={() => onSelectDay(isSelected ? null : day.id)}
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
onDrop={e => handleDropOnDay(e, day.id)}
@@ -423,14 +442,14 @@ export default function DayPlanSidebar({
display: 'flex', alignItems: 'center', gap: 10,
padding: '11px 14px 11px 16px',
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',
userSelect: 'none',
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
outlineOffset: -2,
borderRadius: isDragTarget ? 8 : 0,
}}
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
>
{/* Tages-Badge */}
@@ -461,8 +480,8 @@ export default function DayPlanSidebar({
}}
/>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<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', flexShrink: 1, minWidth: 0 }}>
{day.title || t('dayplan.dayN', { n: index + 1 })}
</span>
<button
@@ -471,11 +490,21 @@ export default function DayPlanSidebar({
>
<Pencil size={10} strokeWidth={1.8} color="var(--text-secondary)" />
</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 style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2, flexWrap: 'wrap' }}>
{formattedDate && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formattedDate}</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 && (() => {
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
@@ -504,27 +533,27 @@ export default function DayPlanSidebar({
{/* Aufgeklappte Orte + Notizen */}
{isExpanded && (
<div
style={{ background: 'var(--bg-hover)' }}
style={{ background: 'var(--bg-hover)', paddingTop: 6 }}
onDragOver={e => { e.preventDefault(); if (draggingId) setDropTargetKey(`end-${day.id}`) }}
onDrop={e => {
e.preventDefault()
const { assignmentId, noteId, fromDayId } = getDragData(e)
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
if (assignmentId && fromDayId !== day.id) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch(err => toast.error(err.message))
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
if (noteId && fromDayId !== day.id) {
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch(err => toast.error(err.message))
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
const m = getMergedItems(day.id)
if (m.length === 0) return
const lastItem = m[m.length - 1]
if (assignmentId && String(lastItem?.data?.id) !== assignmentId)
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id)
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true)
else if (noteId && String(lastItem?.data?.id) !== noteId)
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id)
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
}}
>
{merged.length === 0 && !dayNoteUi ? (
@@ -548,9 +577,7 @@ export default function DayPlanSidebar({
const place = assignment.place
if (!place) return null
const cat = categories.find(c => c.id === place.category_id)
const isPlaceSelected = place.id === selectedPlaceId
const hasReservation = place.reservation_status && place.reservation_status !== 'none'
const isConfirmed = place.reservation_status === 'confirmed'
const isPlaceSelected = selectedAssignmentId ? assignment.id === selectedAssignmentId : place.id === selectedPlaceId
const isDraggingThis = draggingId === assignment.id
const isHovered = hoveredId === assignment.id
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
@@ -582,7 +609,7 @@ export default function DayPlanSidebar({
dragDataRef.current = { assignmentId: String(assignment.id), fromDayId: String(day.id) }
setDraggingId(assignment.id)
}}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); setDropTargetKey(`place-${assignment.id}`) }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); if (dropTargetKey !== `place-${assignment.id}`) setDropTargetKey(`place-${assignment.id}`) }}
onDrop={e => {
e.preventDefault(); e.stopPropagation()
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
@@ -592,7 +619,7 @@ export default function DayPlanSidebar({
setDropTargetKey(null); window.__dragData = null
} else if (fromAssignmentId && fromDayId !== day.id) {
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch(err => toast.error(err.message))
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
@@ -600,32 +627,74 @@ export default function DayPlanSidebar({
const tm = getMergedItems(day.id)
const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch(err => toast.error(err.message))
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
}
}}
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id); if (!isPlaceSelected) onSelectDay(day.id) }}
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
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: () => onDeletePlace(place.id) },
])}
onMouseEnter={() => setHoveredId(assignment.id)}
onMouseLeave={() => setHoveredId(null)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px 7px 10px',
cursor: 'pointer',
background: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
borderLeft: hasReservation
? `3px solid ${isConfirmed ? '#10b981' : '#f59e0b'}`
background: lockedIds.has(assignment.id)
? 'rgba(220,38,38,0.08)'
: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
borderLeft: lockedIds.has(assignment.id)
? '3px solid #dc2626'
: '3px solid transparent',
transition: 'background 0.1s',
transition: 'background 0.15s, border-color 0.15s',
opacity: isDraggingThis ? 0.4 : 1,
}}
>
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>
<PlaceAvatar place={place} category={cat} size={28} />
<div
onClick={e => { e.stopPropagation(); toggleLock(assignment.id) }}
onMouseEnter={e => { e.stopPropagation(); setLockHoverId(assignment.id) }}
onMouseLeave={() => setLockHoverId(null)}
style={{ position: 'relative', flexShrink: 0, cursor: 'pointer' }}
>
<PlaceAvatar place={place} category={cat} size={28} />
{/* Hover/locked overlay */}
{(lockHoverId === assignment.id || lockedIds.has(assignment.id)) && (
<div style={{
position: 'absolute', inset: 0, borderRadius: '50%',
background: lockedIds.has(assignment.id) ? 'rgba(220,38,38,0.6)' : 'rgba(220,38,38,0.4)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background 0.15s',
}}>
<Lock size={14} strokeWidth={2.5} style={{ color: 'white', filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))' }} />
</div>
)}
{/* Custom tooltip */}
{lockHoverId === assignment.id && (
<div style={{
position: 'absolute', left: '100%', top: '50%', transform: 'translateY(-50%)',
marginLeft: 8, whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 50,
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
}}>
{lockedIds.has(assignment.id)
? t('planner.clickToUnlock')
: t('planner.keepPosition')}
</div>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
{cat && (() => {
@@ -638,26 +707,52 @@ export default function DayPlanSidebar({
{place.place_time && (
<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} />
{formatTime(place.place_time, locale, timeFormat)}
{formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` ${formatTime(place.end_time, locale, timeFormat)}` : ''}
</span>
)}
</div>
{(place.description || place.address || cat?.name) && !hasReservation && (
{(place.description || place.address || cat?.name) && (
<div style={{ marginTop: 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}
</span>
</div>
)}
{hasReservation && (
<div style={{ display: 'flex', alignItems: 'center', gap: 2, marginTop: 2 }}>
<span style={{ fontSize: 10, color: isConfirmed ? '#059669' : '#d97706', display: 'flex', alignItems: 'center', gap: 2, fontWeight: 600 }}>
{isConfirmed ? <><CheckCircle2 size={10} />
{place.reservation_datetime
? `Res. ${formatTime(new Date(place.reservation_datetime).toTimeString().slice(0,5), locale, timeFormat)}`
: place.place_time ? `Res. ${formatTime(place.place_time, locale, timeFormat)}` : t('dayplan.confirmed')}
</> : <><AlertCircle size={10} />{t('dayplan.pendingRes')}</>}
</span>
{(() => {
const res = reservations.find(r => r.assignment_id === assignment.id)
if (!res) return null
const confirmed = res.status === 'confirmed'
return (
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 9, fontWeight: 600,
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)',
color: confirmed ? '#16a34a' : '#d97706',
}}>
{(() => { 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?.includes('T') && (
<span style={{ fontWeight: 400 }}>
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
{res.reservation_end_time && ` ${res.reservation_end_time}`}
</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>
@@ -686,7 +781,7 @@ export default function DayPlanSidebar({
draggable
onDragStart={e => { e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDropTargetKey(`note-${note.id}`) }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
onDrop={e => {
e.preventDefault(); e.stopPropagation()
const { noteId: fromNoteId, assignmentId: fromAssignmentId, fromDayId } = getDragData(e)
@@ -694,7 +789,7 @@ export default function DayPlanSidebar({
const tm = getMergedItems(day.id)
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch(err => toast.error(err.message))
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null)
} else if (fromNoteId && fromNoteId !== String(note.id)) {
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
@@ -702,12 +797,17 @@ export default function DayPlanSidebar({
const tm = getMergedItems(day.id)
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch(err => toast.error(err.message))
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null)
} else if (fromAssignmentId) {
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}`)}
onMouseLeave={() => setHoveredId(null)}
style={{
@@ -728,12 +828,11 @@ export default function DayPlanSidebar({
<NoteIcon size={13} strokeWidth={1.8} color="var(--text-muted)" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)',
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', wordBreak: 'break-word' }}>
{note.text}
</span>
{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 className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
@@ -749,26 +848,44 @@ export default function DayPlanSidebar({
)
})
)}
{/* Drop-Indikator am Listenende */}
{!!draggingId && dropTargetKey === `end-${day.id}` && (
<div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />
)}
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
<div
style={{ minHeight: 12, padding: '2px 8px' }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `end-${day.id}`) setDropTargetKey(`end-${day.id}`) }}
onDrop={e => {
e.preventDefault(); e.stopPropagation()
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
// Neuer Ort von der Orte-Liste
if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id)
setDropTargetKey(null); window.__dragData = null; return
}
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
if (assignmentId && fromDayId !== day.id) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
if (noteId && fromDayId !== day.id) {
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
const m = getMergedItems(day.id)
if (m.length === 0) return
const lastItem = m[m.length - 1]
if (assignmentId && String(lastItem?.data?.id) !== assignmentId)
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true)
else if (noteId && String(lastItem?.data?.id) !== noteId)
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
}}
>
{dropTargetKey === `end-${day.id}` && (
<div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1 }} />
)}
</div>
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
{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={{ 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 && (
<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>
@@ -778,15 +895,6 @@ export default function DayPlanSidebar({
)}
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={handleCalculateRoute} disabled={isCalculating} style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer', fontFamily: 'inherit',
opacity: isCalculating ? 0.6 : 1,
}}>
<Navigation size={12} strokeWidth={2} />
{isCalculating ? t('dayplan.calculating') : t('dayplan.route')}
</button>
<button onClick={handleOptimize} style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
@@ -846,14 +954,16 @@ export default function DayPlanSidebar({
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)' }}
/>
<input
type="text"
<textarea
value={ui.time}
maxLength={150}
rows={3}
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')}
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' }}>
<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' }}>
@@ -872,6 +982,7 @@ export default function DayPlanSidebar({
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{totalCost.toFixed(2)} {currency}</span>
</div>
)}
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
</div>
)
}
-136
View File
@@ -1,136 +0,0 @@
import React from 'react'
import { CalendarDays, MapPin, Plus } from 'lucide-react'
import WeatherWidget from '../Weather/WeatherWidget'
function formatDate(dateStr) {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
function dayTotal(dayId, assignments) {
const dayAssignments = assignments[String(dayId)] || []
return dayAssignments.reduce((sum, a) => {
const cost = parseFloat(a.place?.cost) || 0
return sum + cost
}, 0)
}
export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }) {
const totalCost = days.reduce((sum, d) => sum + dayTotal(d.id, assignments), 0)
const currency = trip?.currency || 'EUR'
return (
<div className="flex flex-col h-full">
{/* Header */}
<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>
<p className="text-xs text-gray-400 mt-0.5">{days.length} Tage</p>
</div>
{/* All places overview option */}
<button
onClick={() => onSelectDay(null)}
className={`w-full text-left px-4 py-3 border-b border-gray-100 transition-colors flex items-center gap-2 flex-shrink-0 ${
selectedDayId === null
? 'bg-slate-50 border-l-2 border-l-slate-900'
: 'hover:bg-gray-50'
}`}
>
<MapPin className={`w-4 h-4 flex-shrink-0 ${selectedDayId === null ? 'text-slate-900' : 'text-gray-400'}`} />
<div>
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
Alle Orte
</p>
<p className="text-xs text-gray-400">Gesamtübersicht</p>
</div>
</button>
{/* Day list */}
<div className="flex-1 overflow-y-auto">
{days.length === 0 ? (
<div className="px-4 py-6 text-center">
<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-300 mt-1">Reise bearbeiten um Tage hinzuzufügen</p>
</div>
) : (
days.map((day, index) => {
const isSelected = selectedDayId === day.id
const dayAssignments = assignments[String(day.id)] || []
const cost = dayTotal(day.id, assignments)
const placeCount = dayAssignments.length
return (
<button
key={day.id}
onClick={() => onSelectDay(day.id)}
className={`w-full text-left px-4 py-3 border-b border-gray-50 transition-colors ${
isSelected
? 'bg-slate-50 border-l-2 border-l-slate-900'
: 'hover:bg-gray-50'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className={`text-xs font-bold px-1.5 py-0.5 rounded ${
isSelected ? 'bg-slate-900 text-white' : 'bg-gray-200 text-gray-600'
}`}>
{index + 1}
</span>
<span className={`text-sm font-medium truncate ${isSelected ? 'text-slate-900' : 'text-gray-700'}`}>
{day.title || `Tag ${index + 1}`}
</span>
</div>
{day.date && (
<p className="text-xs text-gray-400 mt-1 ml-0.5">
{formatDate(day.date)}
</p>
)}
<div className="flex items-center gap-3 mt-1.5">
{placeCount > 0 && (
<span className="text-xs text-gray-400">
{placeCount} {placeCount === 1 ? 'Ort' : 'Orte'}
</span>
)}
{cost > 0 && (
<span className="text-xs text-emerald-600 font-medium">
{cost.toFixed(0)} {currency}
</span>
)}
</div>
</div>
</div>
{/* Weather for this day */}
{day.date && isSelected && (
<div className="mt-2">
<WeatherWidget date={day.date} compact />
</div>
)}
</button>
)
})
)}
</div>
{/* Budget summary footer */}
{totalCost > 0 && (
<div className="flex-shrink-0 border-t border-gray-100 px-4 py-3 bg-gray-50">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">Gesamtkosten</span>
<span className="text-sm font-semibold text-gray-800">
{totalCost.toFixed(2)} {currency}
</span>
</div>
</div>
)}
</div>
)
}
@@ -1,107 +0,0 @@
import React from 'react'
import { useDraggable } from '@dnd-kit/core'
import { MapPin, DollarSign, Check } from 'lucide-react'
export default function DraggablePlaceCard({ place, isAssigned, onEdit }) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: `place-${place.id}`,
data: {
type: 'place',
place,
},
})
const style = transform ? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
zIndex: isDragging ? 999 : undefined,
} : undefined
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={`
group relative bg-white border rounded-lg p-3 cursor-grab active:cursor-grabbing
transition-all select-none
${isDragging
? 'opacity-50 shadow-2xl border-slate-400 scale-105'
: 'border-slate-200 hover:border-slate-300 hover:shadow-md place-card-hover'
}
`}
onClick={e => {
if (!isDragging && onEdit) {
e.stopPropagation()
onEdit(place)
}
}}
>
{/* Category left border accent */}
{place.category && (
<div
className="absolute left-0 top-3 bottom-3 w-0.5 rounded-r"
style={{ backgroundColor: place.category.color || '#6366f1' }}
/>
)}
<div className="pl-1">
{/* Header */}
<div className="flex items-start justify-between gap-1 mb-1">
<p className="text-sm font-medium text-slate-800 leading-tight line-clamp-2 flex-1">
{place.name}
</p>
{isAssigned && (
<span className="flex-shrink-0 w-5 h-5 bg-emerald-100 rounded-full flex items-center justify-center" title="Already assigned to a day">
<Check className="w-3 h-3 text-emerald-600" />
</span>
)}
</div>
{/* Address */}
{place.address && (
<p className="text-xs text-slate-400 truncate flex items-center gap-1 mb-1.5">
<MapPin className="w-3 h-3 flex-shrink-0" />
{place.address}
</p>
)}
{/* Category badge */}
{place.category && (
<span
className="inline-block text-[10px] px-1.5 py-0.5 rounded text-white font-medium mr-1"
style={{ backgroundColor: place.category.color || '#6366f1' }}
>
{place.category.name}
</span>
)}
{/* Price */}
{place.price != null && (
<span className="inline-flex items-center gap-0.5 text-[10px] text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
<DollarSign className="w-2.5 h-2.5" />
{Number(place.price).toLocaleString()} {place.currency || ''}
</span>
)}
{/* Tags */}
{place.tags && place.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{place.tags.slice(0, 3).map(tag => (
<span
key={tag.id}
className="text-[10px] px-1.5 py-0.5 rounded-full text-white font-medium"
style={{ backgroundColor: tag.color || '#6366f1' }}
>
{tag.name}
</span>
))}
{place.tags.length > 3 && (
<span className="text-[10px] text-slate-400">+{place.tags.length - 3}</span>
)}
</div>
)}
</div>
</div>
)
}
@@ -1,254 +0,0 @@
import React, { useState, useEffect } from 'react'
import { X, ExternalLink, Phone, MapPin, Clock, Euro, Edit2, Trash2, Plus, Minus } from 'lucide-react'
import { mapsApi } from '../../api/client'
const RESERVATION_STATUS = {
none: { label: 'Keine Reservierung', color: 'gray' },
pending: { label: 'Res. ausstehend', color: 'yellow' },
confirmed: { label: 'Bestätigt', color: 'green' },
}
export function PlaceDetailPanel({
place, categories, tags, selectedDayId, dayAssignments,
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
}) {
const [googlePhoto, setGooglePhoto] = useState(null)
const [photoAttribution, setPhotoAttribution] = useState(null)
useEffect(() => {
if (!place?.google_place_id || place?.image_url) {
setGooglePhoto(null)
return
}
mapsApi.placePhoto(place.google_place_id)
.then(data => {
setGooglePhoto(data.photoUrl || null)
setPhotoAttribution(data.attribution || null)
})
.catch(() => setGooglePhoto(null))
}, [place?.google_place_id, place?.image_url])
if (!place) return null
const displayPhoto = place.image_url || googlePhoto
const category = categories?.find(c => c.id === place.category_id)
const placeTags = (place.tags || []).map(t =>
tags?.find(tg => tg.id === (t.id || t)) || t
).filter(Boolean)
const assignmentInDay = selectedDayId
? dayAssignments?.find(a => a.place?.id === place.id)
: null
const status = RESERVATION_STATUS[place.reservation_status] || RESERVATION_STATUS.none
return (
<div className="bg-white">
{/* Image */}
{displayPhoto ? (
<div className="relative">
<img
src={displayPhoto}
alt={place.name}
className="w-full h-40 object-cover"
onError={e => { e.target.style.display = 'none' }}
/>
<button
onClick={onClose}
className="absolute top-2 right-2 bg-white/90 rounded-full p-1.5 shadow"
>
<X className="w-4 h-4 text-gray-600" />
</button>
{photoAttribution && !place.image_url && (
<div className="absolute bottom-1 right-2 text-[10px] text-white/70">
© {photoAttribution}
</div>
)}
</div>
) : (
<div
className="h-24 flex items-center justify-center relative"
style={{ backgroundColor: category?.color ? `${category.color}20` : '#f0f0ff' }}
>
<span className="text-4xl">{category?.icon || '📍'}</span>
<button
onClick={onClose}
className="absolute top-2 right-2 bg-white/90 rounded-full p-1.5 shadow"
>
<X className="w-4 h-4 text-gray-600" />
</button>
</div>
)}
{/* Content */}
<div className="p-4 space-y-3">
{/* Name + category */}
<div>
<h3 className="font-bold text-gray-900 text-base leading-snug">{place.name}</h3>
{category && (
<span
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full mt-1"
style={{ backgroundColor: `${category.color}20`, color: category.color }}
>
{category.icon} {category.name}
</span>
)}
</div>
{/* Quick info row */}
<div className="flex flex-wrap gap-2">
{place.place_time && (
<div className="flex items-center gap-1 text-xs text-gray-600 bg-gray-50 px-2 py-1 rounded-lg">
<Clock className="w-3 h-3" />
{place.place_time}
</div>
)}
{place.price > 0 && (
<div className="flex items-center gap-1 text-xs text-emerald-700 bg-emerald-50 px-2 py-1 rounded-lg">
<Euro className="w-3 h-3" />
{place.price} {place.currency}
</div>
)}
</div>
{/* Address */}
{place.address && (
<div className="flex items-start gap-1.5 text-xs text-gray-600">
<MapPin className="w-3.5 h-3.5 flex-shrink-0 mt-0.5 text-gray-400" />
<span>{place.address}</span>
</div>
)}
{/* Coordinates */}
{place.lat && place.lng && (
<div className="text-xs text-gray-400">
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
</div>
)}
{/* Links */}
<div className="flex gap-2">
{place.website && (
<a
href={place.website}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-slate-700 hover:underline"
>
<ExternalLink className="w-3 h-3" />
Website
</a>
)}
{place.phone && (
<a
href={`tel:${place.phone}`}
className="flex items-center gap-1 text-xs text-slate-700 hover:underline"
>
<Phone className="w-3 h-3" />
{place.phone}
</a>
)}
</div>
{/* Description */}
{place.description && (
<p className="text-xs text-gray-600 leading-relaxed">{place.description}</p>
)}
{/* Notes */}
{place.notes && (
<div className="bg-amber-50 border border-amber-100 rounded-lg px-3 py-2">
<p className="text-xs text-amber-800 leading-relaxed">📝 {place.notes}</p>
</div>
)}
{/* Tags */}
{placeTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{placeTags.map((tag, i) => (
<span
key={tag.id || i}
className="text-xs px-2 py-0.5 rounded-full"
style={{ backgroundColor: `${tag.color || '#6366f1'}20`, color: tag.color || '#6366f1' }}
>
{tag.name}
</span>
))}
</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 */}
{selectedDayId && (
<div className="pt-1">
{assignmentInDay ? (
<button
onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)}
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" />
Aus Tag entfernen
</button>
) : (
<button
onClick={() => onAssignToDay(place.id)}
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" />
Zum Tag hinzufügen
</button>
)}
</div>
)}
{/* Edit / Delete */}
<div className="flex gap-2 pt-1">
<button
onClick={onEdit}
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" />
Bearbeiten
</button>
<button
onClick={onDelete}
className="flex items-center justify-center gap-1.5 py-2 px-3 text-xs text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
)
}
function formatDateTime(dt) {
if (!dt) return ''
try {
return new Date(dt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
} catch {
return dt
}
}
@@ -1,21 +1,29 @@
import React, { useState, useEffect } from 'react'
import { useState, useEffect, useRef, useMemo } from 'react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { mapsApi } from '../../api/client'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast'
import { Search } from 'lucide-react'
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
import type { Place, Category, Assignment } from '../../types'
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' },
]
interface PlaceFormData {
name: string
description: string
address: string
lat: string
lng: string
category_id: string
place_time: string
end_time: string
notes: string
transport_mode: string
website: string
}
const DEFAULT_FORM = {
const DEFAULT_FORM: PlaceFormData = {
name: '',
description: '',
address: '',
@@ -23,18 +31,28 @@ const DEFAULT_FORM = {
lng: '',
category_id: '',
place_time: '',
end_time: '',
notes: '',
transport_mode: 'walking',
reservation_status: 'none',
reservation_notes: '',
reservation_datetime: '',
website: '',
}
interface PlaceFormModalProps {
isOpen: boolean
onClose: () => void
onSave: (data: PlaceFormData, files?: File[]) => Promise<void> | void
place: Place | null
tripId: number
categories: Category[]
onCategoryCreated: (category: Category) => void
assignmentId: number | null
dayAssignments?: Assignment[]
}
export default function PlaceFormModal({
isOpen, onClose, onSave, place, tripId, categories,
onCategoryCreated,
}) {
onCategoryCreated, assignmentId, dayAssignments = [],
}: PlaceFormModalProps) {
const [form, setForm] = useState(DEFAULT_FORM)
const [mapsSearch, setMapsSearch] = useState('')
const [mapsResults, setMapsResults] = useState([])
@@ -42,8 +60,11 @@ export default function PlaceFormModal({
const [newCategoryName, setNewCategoryName] = useState('')
const [showNewCategory, setShowNewCategory] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [pendingFiles, setPendingFiles] = useState([])
const fileRef = useRef(null)
const toast = useToast()
const { t, language } = useTranslation()
const { hasMapsKey } = useAuthStore()
useEffect(() => {
if (place) {
@@ -55,16 +76,15 @@ export default function PlaceFormModal({
lng: place.lng || '',
category_id: place.category_id || '',
place_time: place.place_time || '',
end_time: place.end_time || '',
notes: place.notes || '',
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 || '',
})
} else {
setForm(DEFAULT_FORM)
}
setPendingFiles([])
}, [place, isOpen])
const handleChange = (field, value) => {
@@ -77,7 +97,7 @@ export default function PlaceFormModal({
try {
const result = await mapsApi.search(mapsSearch, language)
setMapsResults(result.places || [])
} catch (err) {
} catch (err: unknown) {
toast.error(t('places.mapsSearchError'))
} finally {
setIsSearchingMaps(false)
@@ -104,11 +124,37 @@ export default function PlaceFormModal({
if (cat) setForm(prev => ({ ...prev, category_id: cat.id }))
setNewCategoryName('')
setShowNewCategory(false)
} catch (err) {
} catch (err: unknown) {
toast.error(t('places.categoryCreateError'))
}
}
const handleFileAdd = (e) => {
const files = Array.from((e.target as HTMLInputElement).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 Array.from(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) => {
e.preventDefault()
if (!form.name.trim()) {
@@ -122,10 +168,11 @@ export default function PlaceFormModal({
lat: form.lat ? parseFloat(form.lat) : null,
lng: form.lng ? parseFloat(form.lng) : null,
category_id: form.category_id || null,
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
})
onClose()
} catch (err) {
toast.error(err.message || t('places.saveError'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('places.saveError'))
} finally {
setIsSaving(false)
}
@@ -138,9 +185,14 @@ export default function PlaceFormModal({
title={place ? t('places.editPlace') : t('places.addPlace')}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Google Maps Search */}
<form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}>
{/* Place Search */}
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
{!hasMapsKey && (
<p className="mb-2 text-xs" style={{ color: 'var(--text-faint)' }}>
{t('places.osmActive')}
</p>
)}
<div className="flex gap-2">
<input
type="text"
@@ -270,14 +322,17 @@ export default function PlaceFormModal({
)}
</div>
{/* Time */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formTime')}</label>
<CustomTimePicker
value={form.place_time}
onChange={v => handleChange('place_time', v)}
{/* Time — only shown when editing, not when creating */}
{place && (
<TimeSection
form={form}
handleChange={handleChange}
assignmentId={assignmentId}
dayAssignments={dayAssignments}
hasTimeError={hasTimeError}
t={t}
/>
</div>
)}
{/* Website */}
<div>
@@ -291,45 +346,35 @@ export default function PlaceFormModal({
/>
</div>
{/* Reservation */}
<div className="border border-gray-200 rounded-xl p-3 space-y-3">
<div className="flex items-center gap-3">
<label className="block text-sm font-medium text-gray-700">{t('places.formReservation')}</label>
<div className="flex gap-2">
{['none', 'pending', 'confirmed'].map(status => (
<button
key={status}
type="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>
))}
{/* File Attachments */}
{true && (
<div className="border border-gray-200 rounded-xl p-3 space-y-2">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700">{t('files.title')}</label>
<button type="button" onClick={() => fileRef.current?.click()}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-700 transition-colors">
<Paperclip size={12} /> {t('files.attach')}
</button>
</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>
{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 */}
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
@@ -342,7 +387,7 @@ export default function PlaceFormModal({
</button>
<button
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"
>
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
@@ -352,3 +397,71 @@ export default function PlaceFormModal({
</Modal>
)
}
interface TimeSectionProps {
form: PlaceFormData
handleChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void
assignmentId: number | null
dayAssignments: Assignment[]
hasTimeError: boolean
t: (key: string, params?: Record<string, string | number>) => string
}
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, t }: TimeSectionProps) {
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>
)
}
@@ -1,10 +1,11 @@
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 { mapsApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
const detailsCache = new Map()
@@ -75,7 +76,10 @@ function convertHoursLine(line, timeFormat) {
function formatTime(timeStr, locale, timeFormat) {
if (!timeStr) return ''
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') {
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
@@ -86,16 +90,6 @@ function formatTime(timeStr, locale, timeFormat) {
} 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) {
if (!bytes) return ''
@@ -104,19 +98,68 @@ function formatFileSize(bytes) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
interface TripMember {
id: number
username: string
avatar_url?: string | null
}
interface PlaceInspectorProps {
place: Place | null
categories: Category[]
days: Day[]
selectedDayId: number | null
selectedAssignmentId: number | null
assignments: AssignmentsMap
reservations?: Reservation[]
onClose: () => void
onEdit: () => void
onDelete: () => void
onAssignToDay: (placeId: number, dayId: number) => void
onRemoveAssignment: (assignmentId: number, dayId: number) => void
files: TripFile[]
onFileUpload: (fd: FormData) => Promise<void>
tripMembers?: TripMember[]
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
}
export default function PlaceInspector({
place, categories, days, selectedDayId, assignments,
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
files, onFileUpload,
}) {
files, onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace,
}: PlaceInspectorProps) {
const { t, locale, language } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const [hoursExpanded, setHoursExpanded] = useState(false)
const [filesExpanded, setFilesExpanded] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [editingName, setEditingName] = useState(false)
const [nameValue, setNameValue] = useState('')
const nameInputRef = useRef(null)
const fileInputRef = useRef(null)
const googleDetails = useGoogleDetails(place?.google_place_id, language)
const startNameEdit = () => {
if (!onUpdatePlace) return
setNameValue(place.name || '')
setEditingName(true)
setTimeout(() => nameInputRef.current?.focus(), 0)
}
const commitNameEdit = () => {
if (!editingName) return
const trimmed = nameValue.trim()
setEditingName(false)
if (!trimmed || trimmed === place.name) return
onUpdatePlace(place.id, { name: trimmed })
}
const handleNameKeyDown = (e) => {
if (e.key === 'Enter') { e.preventDefault(); commitNameEdit() }
if (e.key === 'Escape') setEditingName(false)
}
if (!place) return null
const category = categories?.find(c => c.id === place.category_id)
@@ -131,7 +174,7 @@ export default function PlaceInspector({
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id))
const handleFileUpload = useCallback(async (e) => {
const selectedFiles = Array.from(e.target.files || [])
const selectedFiles = Array.from((e.target as HTMLInputElement).files || [])
if (!selectedFiles.length || !onFileUpload) return
setIsUploading(true)
try {
@@ -142,7 +185,7 @@ export default function PlaceInspector({
await onFileUpload(fd)
}
setFilesExpanded(true)
} catch (err) {
} catch (err: unknown) {
console.error('Upload failed', err)
} finally {
setIsUploading(false)
@@ -199,7 +242,21 @@ export default function PlaceInspector({
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3' }}>{place.name}</span>
{editingName ? (
<input
ref={nameInputRef}
value={nameValue}
onChange={e => setNameValue(e.target.value)}
onBlur={commitNameEdit}
onKeyDown={handleNameKeyDown}
style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3', background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '1px 6px', fontFamily: 'inherit', outline: 'none', width: '100%' }}
/>
) : (
<span
onDoubleClick={startNameEdit}
style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3', cursor: onUpdatePlace ? 'text' : 'default' }}
>{place.name}</span>
)}
{category && (() => {
const CatIcon = getCategoryIcon(category.icon)
return (
@@ -212,7 +269,7 @@ export default function PlaceInspector({
padding: '2px 8px', borderRadius: 99,
}}>
<CatIcon size={10} />
{category.name}
<span className="hidden sm:inline">{category.name}</span>
</span>
)
})()}
@@ -220,17 +277,17 @@ export default function PlaceInspector({
{place.address && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, marginTop: 6 }}>
<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>
)}
{place.place_time && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 3 }}>
<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>
)}
{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)}
</div>
)}
@@ -248,8 +305,8 @@ export default function PlaceInspector({
{/* Content — scrollable */}
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Info-Chips */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
{/* Info-Chips — hidden on mobile, shown on desktop */}
<div className="hidden sm:flex" style={{ flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
{googleDetails?.rating && (() => {
const shortReview = (googleDetails.reviews || []).find(r => r.text && r.text.length > 5)
return (
@@ -279,46 +336,83 @@ export default function PlaceInspector({
</div>
)}
{/* Description + Reservation in one box */}
{(place.description || place.notes || (place.reservation_status && place.reservation_status !== 'none')) && (
{/* Description */}
{(place.description || place.notes) && (
<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',
borderBottom: (place.reservation_status && place.reservation_status !== 'none') ? '1px solid var(--border-faint)' : 'none'
}}>
{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>
)}
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px' }}>
{place.description || place.notes}
</p>
</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?.includes('T') && (
<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' })}
{res.reservation_end_time && ` ${res.reservation_end_time}`}
</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 && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
<button
@@ -380,24 +474,17 @@ export default function PlaceInspector({
{filesExpanded && placeFiles.length > 0 && (
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{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" />}
<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>}
<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>
</a>
))}
</div>
)}
</div>
)}
</div>
</div>
@@ -428,7 +515,14 @@ export default function PlaceInspector({
)
}
function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hover)' }) {
interface ChipProps {
icon: React.ReactNode
text: React.ReactNode
color?: string
bg?: string
}
function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hover)' }: ChipProps) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 9px', borderRadius: 99, background: bg, color, fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
<span style={{ flexShrink: 0, display: 'flex' }}>{icon}</span>
@@ -437,7 +531,12 @@ function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hove
)
}
function Row({ icon, children }) {
interface RowProps {
icon: React.ReactNode
children: React.ReactNode
}
function Row({ icon, children }: RowProps) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ flexShrink: 0 }}>{icon}</div>
@@ -446,7 +545,14 @@ function Row({ icon, children }) {
)
}
function ActionButton({ onClick, variant, icon, label }) {
interface ActionButtonProps {
onClick: () => void
variant: 'primary' | 'ghost' | 'danger'
icon: React.ReactNode
label: React.ReactNode
}
function ActionButton({ onClick, variant, icon, label }: ActionButtonProps) {
const base = {
primary: { background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', hoverBg: 'var(--text-secondary)' },
ghost: { background: 'var(--bg-hover)', color: 'var(--text-secondary)', border: 'none', hoverBg: 'var(--bg-tertiary)' },
@@ -470,3 +576,126 @@ function ActionButton({ onClick, variant, icon, label }) {
</button>
)
}
interface ParticipantsBoxProps {
tripMembers: TripMember[]
participantIds: number[]
allJoined: boolean
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
selectedAssignmentId: number | null
selectedDayId: number | null
t: (key: string) => string
}
function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticipants, selectedAssignmentId, selectedDayId, t }: ParticipantsBoxProps) {
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,223 +0,0 @@
import React, { useState, useMemo } from 'react'
import DraggablePlaceCard from './DraggablePlaceCard'
import { Search, Plus, Filter, Map, X, SlidersHorizontal } from 'lucide-react'
export default function PlacesPanel({
places,
categories,
tags,
assignments,
tripId,
onAddPlace,
onEditPlace,
hasMapKey,
onSearchMaps,
}) {
const [search, setSearch] = useState('')
const [selectedCategory, setSelectedCategory] = useState('')
const [selectedTags, setSelectedTags] = useState([])
const [showFilters, setShowFilters] = useState(false)
// Get set of assigned place IDs (for any day)
const assignedPlaceIds = useMemo(() => {
const ids = new Set()
Object.values(assignments || {}).forEach(dayAssignments => {
dayAssignments.forEach(a => {
if (a.place?.id) ids.add(a.place.id)
})
})
return ids
}, [assignments])
const filteredPlaces = useMemo(() => {
return places.filter(place => {
if (search) {
const q = search.toLowerCase()
if (!place.name.toLowerCase().includes(q) &&
!place.address?.toLowerCase().includes(q) &&
!place.description?.toLowerCase().includes(q)) {
return false
}
}
if (selectedCategory && place.category_id !== parseInt(selectedCategory)) {
return false
}
if (selectedTags.length > 0) {
const placeTags = (place.tags || []).map(t => t.id)
if (!selectedTags.every(tagId => placeTags.includes(tagId))) {
return false
}
}
return true
})
}, [places, search, selectedCategory, selectedTags])
const toggleTag = (tagId) => {
setSelectedTags(prev =>
prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId]
)
}
const clearFilters = () => {
setSearch('')
setSelectedCategory('')
setSelectedTags([])
}
const hasActiveFilters = search || selectedCategory || selectedTags.length > 0
return (
<div className="flex flex-col h-full bg-white border-r border-slate-200">
{/* Header */}
<div className="p-3 border-b border-slate-100">
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-semibold text-slate-800">
Places
<span className="ml-1.5 text-xs font-normal text-slate-400">
({filteredPlaces.length}{filteredPlaces.length !== places.length ? `/${places.length}` : ''})
</span>
</h2>
<div className="flex gap-1">
{hasMapKey && (
<button
onClick={onSearchMaps}
className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg transition-colors"
title="Search Google Maps"
>
<Map className="w-4 h-4" />
</button>
)}
<button
onClick={() => setShowFilters(!showFilters)}
className={`p-1.5 rounded-lg transition-colors ${
showFilters || hasActiveFilters
? 'text-slate-700 bg-slate-50'
: 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'
}`}
title="Filters"
>
<SlidersHorizontal className="w-4 h-4" />
</button>
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search places..."
className="w-full pl-8 pr-3 py-1.5 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Filters */}
{showFilters && (
<div className="mt-2 space-y-2">
{/* Category filter */}
{categories.length > 0 && (
<select
value={selectedCategory}
onChange={e => setSelectedCategory(e.target.value)}
className="w-full px-2.5 py-1.5 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-900 focus:border-transparent bg-white"
>
<option value="">All categories</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
)}
{/* Tag filters */}
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.map(tag => (
<button
key={tag.id}
onClick={() => toggleTag(tag.id)}
className={`text-xs px-2 py-0.5 rounded-full font-medium transition-all ${
selectedTags.includes(tag.id)
? 'text-white shadow-sm'
: 'text-white opacity-50 hover:opacity-80'
}`}
style={{ backgroundColor: tag.color || '#6366f1' }}
>
{tag.name}
</button>
))}
</div>
)}
{hasActiveFilters && (
<button
onClick={clearFilters}
className="text-xs text-slate-500 hover:text-red-500 flex items-center gap-1"
>
<X className="w-3 h-3" />
Clear filters
</button>
)}
</div>
)}
</div>
{/* Add place button */}
<div className="px-3 py-2 border-b border-slate-100">
<button
onClick={onAddPlace}
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-slate-700 hover:text-slate-900 bg-slate-50 hover:bg-slate-100 rounded-lg transition-colors font-medium"
>
<Plus className="w-4 h-4" />
Add Place
</button>
</div>
{/* Places list */}
<div className="flex-1 overflow-y-auto p-3 space-y-2 scroll-container">
{filteredPlaces.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-3">
<Search className="w-6 h-6 text-slate-400" />
</div>
{places.length === 0 ? (
<>
<p className="text-sm font-medium text-slate-600">No places yet</p>
<p className="text-xs text-slate-400 mt-1">Add places and drag them to days</p>
<button
onClick={onAddPlace}
className="mt-3 text-sm text-slate-700 hover:text-slate-900 font-medium"
>
+ Add your first place
</button>
</>
) : (
<>
<p className="text-sm font-medium text-slate-600">No matches found</p>
<p className="text-xs text-slate-400 mt-1">Try adjusting your filters</p>
</>
)}
</div>
) : (
filteredPlaces.map(place => (
<DraggablePlaceCard
key={place.id}
place={place}
isAssigned={assignedPlaceIds.has(place.id)}
onEdit={onEditPlace}
/>
))
)}
</div>
</div>
)
}
@@ -1,16 +1,35 @@
import React, { useState } from 'react'
import ReactDOM from 'react-dom'
import { Search, Plus, X, CalendarDays } from 'lucide-react'
import { useState } from 'react'
import DOM from 'react-dom'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
interface PlacesSidebarProps {
places: Place[]
categories: Category[]
assignments: AssignmentsMap
selectedDayId: number | null
selectedPlaceId: number | null
onPlaceClick: (placeId: number | null) => void
onAddPlace: () => void
onAssignToDay: (placeId: number, dayId: number) => void
onEditPlace: (place: Place) => void
onDeletePlace: (placeId: number) => void
days: Day[]
isMobile: boolean
}
export default function PlacesSidebar({
places, categories, assignments, selectedDayId, selectedPlaceId,
onPlaceClick, onAddPlace, onAssignToDay, days, isMobile,
}) {
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile,
}: PlacesSidebarProps) {
const { t } = useTranslation()
const ctxMenu = useContextMenu()
const [search, setSearch] = useState('')
const [filter, setFilter] = useState('all')
const [categoryFilter, setCategoryFilter] = useState('')
@@ -138,6 +157,14 @@ export default function PlacesSidebar({
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: () => onDeletePlace(place.id) },
])}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 14px 9px 16px',
@@ -204,19 +231,17 @@ export default function PlacesSidebar({
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 16px' }}>
{days.map((day, i) => {
const alreadyAssigned = (assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id)
return (
<button
key={day.id}
disabled={alreadyAssigned}
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null) }}
style={{
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',
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'}
>
<div style={{
@@ -230,7 +255,7 @@ export default function PlacesSidebar({
</div>
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00').toLocaleDateString()}</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>
)
})}
@@ -239,6 +264,7 @@ export default function PlacesSidebar({
</div>,
document.body
)}
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
</div>
)
}
@@ -1,903 +0,0 @@
import React, { useState, useCallback, useEffect, useRef, useMemo, useLayoutEffect } from 'react'
import { FixedSizeList } from 'react-window'
import {
Plus, Search, X, Navigation, RotateCcw, ExternalLink,
ChevronDown, ChevronRight, ChevronUp, Clock, MapPin,
CalendarDays, FileText, Check, Pencil, Trash2,
} from 'lucide-react'
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
import PackingListPanel from '../Packing/PackingListPanel'
import FileManager from '../Files/FileManager'
import { ReservationModal } from './ReservationModal'
import { PlaceDetailPanel } from './PlaceDetailPanel'
import WeatherWidget from '../Weather/WeatherWidget'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
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) {
if (!dateStr) return ''
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
day: 'numeric', month: 'short',
})
}
function formatDateTime(dt) {
if (!dt) return ''
try {
return new Date(dt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
} catch { return dt }
}
export default function PlannerSidebar({
trip, days, places, categories, tags,
assignments, reservations, packingItems,
selectedDayId, selectedPlaceId,
onSelectDay, onPlaceClick, onPlaceEdit, onPlaceDelete,
onAssignToDay, onRemoveAssignment, onReorder,
onAddPlace, onEditTrip, onRouteCalculated, tripId,
}) {
const [activeSegment, setActiveSegment] = useState('plan')
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [transportMode, setTransportMode] = useState('driving')
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
const [showReservationModal, setShowReservationModal] = useState(false)
const [editingReservation, setEditingReservation] = useState(null)
const [routeInfo, setRouteInfo] = useState(null)
const [expandedDays, setExpandedDays] = useState(new Set())
// Day notes inline UI state: { [dayId]: { mode: 'add'|'edit', noteId?, text, time } }
const [noteUi, setNoteUi] = useState({})
const noteInputRef = useRef(null)
const tripStore = useTripStore()
const toast = useToast()
const dayNotes = tripStore.dayNotes || {}
const placesListRef = useRef(null)
const [placesListHeight, setPlacesListHeight] = useState(400)
useLayoutEffect(() => {
if (!placesListRef.current) return
const ro = new ResizeObserver(([entry]) => {
setPlacesListHeight(entry.contentRect.height)
})
ro.observe(placesListRef.current)
return () => ro.disconnect()
}, [activeSegment])
// Auto-expand selected day
useEffect(() => {
if (selectedDayId) {
setExpandedDays(prev => new Set([...prev, selectedDayId]))
}
}, [selectedDayId])
const toggleDay = (dayId) => {
setExpandedDays(prev => {
const next = new Set(prev)
if (next.has(dayId)) next.delete(dayId)
else next.add(dayId)
return next
})
}
const getDayAssignments = (dayId) =>
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const selectedDayAssignments = selectedDayId ? getDayAssignments(selectedDayId) : []
const selectedDay = selectedDayId ? days.find(d => d.id === selectedDayId) : null
const filteredPlaces = useMemo(() => places.filter(p => {
const matchSearch = !search || p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.address || '').toLowerCase().includes(search.toLowerCase())
const matchCat = !categoryFilter || String(p.category_id) === String(categoryFilter)
return matchSearch && matchCat
}), [places, search, categoryFilter])
const isAssignedToDay = (placeId) =>
selectedDayId && selectedDayAssignments.some(a => a.place?.id === placeId)
const totalCost = days.reduce((sum, d) => {
const da = assignments[String(d.id)] || []
return sum + da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
}, 0)
const currency = trip?.currency || 'EUR'
const filteredReservations = selectedDayId
? reservations.filter(r => String(r.day_id) === String(selectedDayId) || !r.day_id)
: reservations
// Get representative location for a day (first place with coords)
const getDayLocation = (dayId) => {
const da = getDayAssignments(dayId)
const p = da.find(a => a.place?.lat && a.place?.lng)
return p ? { lat: p.place.lat, lng: p.place.lng } : null
}
// Route handlers
const handleCalculateRoute = async () => {
if (!selectedDayId) return
const waypoints = selectedDayAssignments
.map(a => a.place)
.filter(p => p?.lat && p?.lng)
.map(p => ({ lat: p.lat, lng: p.lng }))
if (waypoints.length < 2) {
toast.error('Mindestens 2 Orte mit Koordinaten benötigt')
return
}
setIsCalculatingRoute(true)
try {
const result = await calculateRoute(waypoints, transportMode)
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
onRouteCalculated?.(result)
toast.success('Route berechnet')
} catch {
toast.error('Route konnte nicht berechnet werden')
} finally {
setIsCalculatingRoute(false)
}
}
const handleOptimizeRoute = async () => {
if (!selectedDayId || selectedDayAssignments.length < 3) return
const withCoords = selectedDayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
const optimized = optimizeRoute(withCoords)
const reorderedIds = optimized
.map(p => selectedDayAssignments.find(a => a.place?.id === p.id)?.id)
.filter(Boolean)
// Append assignments without coordinates at end
for (const a of selectedDayAssignments) {
if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id)
}
await onReorder(selectedDayId, reorderedIds)
toast.success('Route optimiert')
}
const handleOpenGoogleMaps = () => {
const ps = selectedDayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
const url = generateGoogleMapsUrl(ps)
if (url) window.open(url, '_blank')
else toast.error('Keine Orte mit Koordinaten vorhanden')
}
const handleMoveUp = async (dayId, idx) => {
const da = getDayAssignments(dayId)
if (idx === 0) return
const ids = da.map(a => a.id)
;[ids[idx - 1], ids[idx]] = [ids[idx], ids[idx - 1]]
await onReorder(dayId, ids)
}
const handleMoveDown = async (dayId, idx) => {
const da = getDayAssignments(dayId)
if (idx === da.length - 1) return
const ids = da.map(a => a.id)
;[ids[idx], ids[idx + 1]] = [ids[idx + 1], ids[idx]]
await onReorder(dayId, ids)
}
// Merge place assignments + day notes into a single sorted list
const getMergedDayItems = (dayId) => {
const da = getDayAssignments(dayId)
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
return [
...da.map(a => ({ type: 'place', sortKey: a.order_index, data: a })),
...dn.map(n => ({ type: 'note', sortKey: n.sort_order, data: n })),
].sort((a, b) => a.sortKey - b.sortKey)
}
const openAddNote = (dayId) => {
const merged = getMergedDayItems(dayId)
const maxKey = merged.length > 0 ? Math.max(...merged.map(i => i.sortKey)) : -1
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', sortOrder: maxKey + 1 } }))
setTimeout(() => noteInputRef.current?.focus(), 50)
}
const openEditNote = (dayId, note) => {
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '' } }))
setTimeout(() => noteInputRef.current?.focus(), 50)
}
const cancelNote = (dayId) => {
setNoteUi(prev => { const n = { ...prev }; delete n[dayId]; return n })
}
const saveNote = async (dayId) => {
const ui = noteUi[dayId]
if (!ui?.text?.trim()) return
try {
if (ui.mode === 'add') {
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, sort_order: ui.sortOrder })
} else {
await tripStore.updateDayNote(tripId, dayId, ui.noteId, { text: ui.text.trim(), time: ui.time || null })
}
cancelNote(dayId)
} catch (err) {
toast.error(err.message)
}
}
const handleDeleteNote = async (dayId, noteId) => {
try {
await tripStore.deleteDayNote(tripId, dayId, noteId)
} catch (err) {
toast.error(err.message)
}
}
const handleNoteMoveUp = async (dayId, noteId) => {
const merged = getMergedDayItems(dayId)
const idx = merged.findIndex(item => item.type === 'note' && item.data.id === noteId)
if (idx <= 0) return
const newSortOrder = idx >= 2
? (merged[idx - 2].sortKey + merged[idx - 1].sortKey) / 2
: merged[idx - 1].sortKey - 1
try {
await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder })
} catch (err) {
toast.error(err.message)
}
}
const handleNoteMoveDown = async (dayId, noteId) => {
const merged = getMergedDayItems(dayId)
const idx = merged.findIndex(item => item.type === 'note' && item.data.id === noteId)
if (idx === -1 || idx >= merged.length - 1) return
const newSortOrder = idx < merged.length - 2
? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2
: merged[idx + 1].sortKey + 1
try {
await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder })
} catch (err) {
toast.error(err.message)
}
}
const handleSaveReservation = async (data) => {
try {
if (editingReservation) {
await tripStore.updateReservation(tripId, editingReservation.id, data)
toast.success('Reservierung aktualisiert')
} else {
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
toast.success('Reservierung hinzugefügt')
}
setShowReservationModal(false)
} catch (err) {
toast.error(err.message)
}
}
const handleDeleteReservation = async (id) => {
if (!confirm('Reservierung löschen?')) return
try {
await tripStore.deleteReservation(tripId, id)
toast.success('Reservierung gelöscht')
} catch (err) {
toast.error(err.message)
}
}
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
return (
<div className="flex flex-col h-full bg-white relative overflow-hidden" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" }}>
<div className="px-4 pt-4 pb-3 flex-shrink-0 border-b border-gray-100">
<button onClick={onEditTrip} className="w-full text-left group">
<h1 className="font-semibold text-gray-900 text-[15px] leading-tight truncate group-hover:text-slate-600 transition-colors">
{trip?.title}
</h1>
{(trip?.start_date || trip?.end_date) && (
<p className="text-xs text-gray-400 mt-0.5">
{trip.start_date && formatShortDate(trip.start_date)}
{trip.start_date && trip.end_date && ' '}
{trip.end_date && formatShortDate(trip.end_date)}
{days.length > 0 && ` · ${days.length} Tage`}
</p>
)}
</button>
</div>
<div className="px-3 py-2 flex-shrink-0 border-b border-gray-100">
<div className="flex bg-gray-100 rounded-[10px] p-0.5 gap-0.5">
{SEGMENTS.map(seg => (
<button
key={seg.id}
onClick={() => setActiveSegment(seg.id)}
className={`flex-1 py-[5px] text-[11px] font-medium rounded-[8px] transition-all duration-150 leading-none ${
activeSegment === seg.id
? 'bg-white shadow-sm text-gray-900'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{seg.label}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
{/* ── PLAN ── */}
{activeSegment === 'plan' && (
<div className="pb-4">
<button
onClick={() => onSelectDay(null)}
className={`w-full text-left px-4 py-3 flex items-center gap-3 transition-colors border-b border-gray-50 ${
selectedDayId === null ? 'bg-slate-100/70' : 'hover:bg-gray-50/80'
}`}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
selectedDayId === null ? 'bg-slate-900' : 'bg-gray-100'
}`}>
<MapPin className={`w-4 h-4 ${selectedDayId === null ? 'text-white' : 'text-gray-400'}`} />
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
Alle Orte
</p>
<p className="text-xs text-gray-400">{places.length} Orte gesamt</p>
</div>
</button>
{days.length === 0 ? (
<div className="px-4 py-10 text-center">
<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>
<button onClick={onEditTrip} className="mt-2 text-slate-700 text-sm">
Reise bearbeiten
</button>
</div>
) : (
days.map((day, index) => {
const isSelected = selectedDayId === day.id
const isExpanded = expandedDays.has(day.id)
const da = getDayAssignments(day.id)
const cost = da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
const loc = getDayLocation(day.id)
const merged = getMergedDayItems(day.id)
const dayNoteUi = noteUi[day.id]
const placeItems = merged.filter(i => i.type === 'place')
return (
<div key={day.id} className="border-b border-gray-50">
<div
className={`flex items-center gap-3 px-4 py-3 cursor-pointer select-none transition-colors ${
isSelected ? 'bg-slate-100/60' : 'hover:bg-gray-50/80'
}`}
onClick={() => {
onSelectDay(day.id)
if (!isExpanded) toggleDay(day.id)
}}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
isSelected ? 'bg-slate-900 text-white' : 'bg-gray-100 text-gray-500'
}`}>
{index + 1}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className={`text-sm font-medium truncate ${isSelected ? 'text-slate-900' : 'text-gray-800'}`}>
{day.title || `Tag ${index + 1}`}
</p>
{da.length > 0 && (
<span className="text-xs text-gray-400 flex-shrink-0">
{da.length} {da.length === 1 ? 'Ort' : 'Orte'}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{day.date && <span className="text-xs text-gray-400">{formatShortDate(day.date)}</span>}
{cost > 0 && <span className="text-xs text-emerald-600">{cost.toFixed(0)} {currency}</span>}
{day.date && loc && (
<WeatherWidget lat={loc.lat} lng={loc.lng} date={day.date} compact />
)}
</div>
</div>
<button
onClick={e => { e.stopPropagation(); openAddNote(day.id); if (!isExpanded) toggleDay(day.id) }}
title="Notiz hinzufügen"
className="p-1 text-gray-300 hover:text-amber-500 flex-shrink-0 transition-colors"
>
<FileText className="w-4 h-4" />
</button>
<button
onClick={e => { e.stopPropagation(); toggleDay(day.id) }}
className="p-1 text-gray-300 hover:text-gray-500 flex-shrink-0"
>
{isExpanded
? <ChevronDown className="w-4 h-4" />
: <ChevronRight className="w-4 h-4" />
}
</button>
</div>
{isExpanded && (
<div className="bg-gray-50/40">
{merged.length === 0 && !dayNoteUi ? (
<div className="px-4 py-4 text-center">
<p className="text-xs text-gray-400">Keine Einträge für diesen Tag</p>
<button
onClick={() => { onSelectDay(day.id); setActiveSegment('orte') }}
className="mt-1 text-xs text-slate-700"
>
+ Ort hinzufügen
</button>
</div>
) : (
<div className="divide-y divide-gray-100/60">
{merged.map((item, idx) => {
if (item.type === 'place') {
const assignment = item.data
const place = assignment.place
if (!place) return null
const category = categories.find(c => c.id === place.category_id)
const isPlaceSelected = place.id === selectedPlaceId
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
return (
<div
key={`place-${assignment.id}`}
className={`group flex items-center gap-2.5 pl-4 pr-3 py-2.5 cursor-pointer transition-colors ${
isPlaceSelected ? 'bg-slate-50' : 'hover:bg-white/80'
}`}
onClick={() => onPlaceClick(isPlaceSelected ? null : place.id)}
>
<div
className="w-9 h-9 rounded-[10px] overflow-hidden flex items-center justify-center flex-shrink-0"
style={{ backgroundColor: (category?.color || '#6366f1') + '22' }}
>
{place.image_url ? (
<img src={place.image_url} alt={place.name} className="w-full h-full object-cover" />
) : (
<span className="text-lg">{category?.icon || '📍'}</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className={`text-[13px] font-medium truncate leading-snug ${isPlaceSelected ? 'text-slate-900' : 'text-gray-800'}`}>
{place.name}
</p>
{(place.description || place.notes) && (
<p className="text-[11px] text-gray-400 mt-0.5 leading-snug line-clamp-2">
{place.description || place.notes}
</p>
)}
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{place.place_time && (
<span className="text-[11px] text-slate-600 font-medium">{place.place_time}</span>
)}
{place.price > 0 && (
<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 className="flex flex-col gap-0 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={e => { e.stopPropagation(); handleMoveUp(day.id, placeIdx) }}
disabled={placeIdx === 0}
className="p-0.5 text-gray-300 hover:text-gray-600 disabled:opacity-20"
>
<ChevronUp className="w-3.5 h-3.5" />
</button>
<button
onClick={e => { e.stopPropagation(); handleMoveDown(day.id, placeIdx) }}
disabled={placeIdx === placeItems.length - 1}
className="p-0.5 text-gray-300 hover:text-gray-600 disabled:opacity-20"
>
<ChevronDown className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
}
const note = item.data
const isEditingThis = dayNoteUi?.mode === 'edit' && dayNoteUi.noteId === note.id
if (isEditingThis) {
return (
<div key={`note-edit-${note.id}`} className="px-3 py-2 bg-amber-50/60">
<div className="flex gap-2 mb-1.5">
<input
type="text"
value={dayNoteUi.time}
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
placeholder="Zeit (optional)"
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>
<textarea
ref={noteInputRef}
value={dayNoteUi.text}
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) }}
placeholder="Notiz…"
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"
/>
<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">
<Check className="w-3 h-3" /> Speichern
</button>
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
Abbrechen
</button>
</div>
</div>
)
}
return (
<div key={`note-${note.id}`} className="group flex items-start gap-2 pl-4 pr-3 py-2 bg-amber-50/40 hover:bg-amber-50/70 transition-colors">
<FileText className="w-3.5 h-3.5 text-amber-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
{note.time && (
<span className="text-[11px] font-semibold text-amber-600 mr-1.5">{note.time}</span>
)}
<span className="text-[12px] text-gray-700 leading-snug">{note.text}</span>
</div>
<div className="flex flex-col gap-0 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={e => { e.stopPropagation(); handleNoteMoveUp(day.id, note.id) }} className="p-0.5 text-gray-300 hover:text-gray-600">
<ChevronUp className="w-3.5 h-3.5" />
</button>
<button onClick={e => { e.stopPropagation(); handleNoteMoveDown(day.id, note.id) }} className="p-0.5 text-gray-300 hover:text-gray-600">
<ChevronDown className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={e => { e.stopPropagation(); openEditNote(day.id, note) }} className="p-1 text-gray-300 hover:text-amber-500 rounded">
<Pencil className="w-3 h-3" />
</button>
<button onClick={e => { e.stopPropagation(); handleDeleteNote(day.id, note.id) }} className="p-1 text-gray-300 hover:text-red-500 rounded">
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
)
})}
</div>
)}
{dayNoteUi?.mode === 'add' && (
<div className="px-3 py-2 border-t border-amber-100 bg-amber-50/60">
<div className="flex gap-2 mb-1.5">
<input
type="text"
value={dayNoteUi.time}
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
placeholder="Zeit (optional)"
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>
<textarea
ref={noteInputRef}
value={dayNoteUi.text}
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) }}
placeholder="z.B. S3 um 14:30 ab Hauptbahnhof, Fähre ab Pier 7, Mittagspause…"
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"
/>
<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">
<Check className="w-3 h-3" /> Hinzufügen
</button>
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
Abbrechen
</button>
</div>
</div>
)}
{!dayNoteUi && (
<div className="px-4 py-2 border-t border-gray-100/60 flex gap-2">
<button
onClick={() => openAddNote(day.id)}
className="flex items-center gap-1 text-[11px] text-amber-600 hover:text-amber-700 py-1"
>
<FileText className="w-3 h-3" />
Notiz hinzufügen
</button>
</div>
)}
{/* Route tools — only for the selected day */}
{isSelected && da.length >= 2 && (
<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 && (
<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-300">·</span>
<span className="text-slate-900"> {routeInfo.duration}</span>
</div>
)}
<div className="grid grid-cols-2 gap-1.5">
<button
onClick={handleCalculateRoute}
disabled={isCalculatingRoute}
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" />
{isCalculatingRoute ? 'Berechne...' : 'Route'}
</button>
<button
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"
>
<RotateCcw className="w-3.5 h-3.5" />
Optimieren
</button>
</div>
<button
onClick={handleOpenGoogleMaps}
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" />
In Google Maps öffnen
</button>
</div>
)}
</div>
)}
</div>
)
})
)}
{totalCost > 0 && (
<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-sm font-semibold text-gray-800">{totalCost.toFixed(2)} {currency}</span>
</div>
)}
</div>
)}
{/* ── ORTE ── */}
{activeSegment === 'orte' && (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div className="p-3 space-y-2 border-b border-gray-100">
<div className="relative">
<Search className="absolute left-3 top-[9px] w-3.5 h-3.5 text-gray-400 pointer-events-none" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Orte suchen…"
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 && (
<button onClick={() => setSearch('')} className="absolute right-3 top-[9px]">
<X className="w-3.5 h-3.5 text-gray-400" />
</button>
)}
</div>
<div className="flex items-center gap-2">
<select
value={categoryFilter}
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"
>
<option value="">Alle Kategorien</option>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
))}
</select>
<button
onClick={onAddPlace}
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" />
Neu
</button>
</div>
</div>
{filteredPlaces.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">📍</span>
<p className="text-sm">Keine Orte gefunden</p>
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm">
Ersten Ort hinzufügen
</button>
</div>
) : (
<div ref={placesListRef} style={{ flex: 1, minHeight: 0 }}>
<FixedSizeList
height={placesListHeight}
itemCount={filteredPlaces.length}
itemSize={68}
overscanCount={10}
width="100%"
>
{({ index, style }) => {
const place = filteredPlaces[index]
const category = categories.find(c => c.id === place.category_id)
const inDay = isAssignedToDay(place.id)
const isSelected = place.id === selectedPlaceId
return (
<div
style={style}
key={place.id}
onClick={() => onPlaceClick(isSelected ? null : place.id)}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors border-b border-gray-50 ${
isSelected ? 'bg-slate-50' : 'hover:bg-gray-50'
}`}
>
<div
className="w-9 h-9 rounded-[10px] overflow-hidden flex items-center justify-center flex-shrink-0"
style={{ backgroundColor: (category?.color || '#6366f1') + '22' }}
>
{place.image_url ? (
<img src={place.image_url} alt={place.name} className="w-full h-full object-cover" />
) : (
<span className="text-lg">{category?.icon || '📍'}</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1">
<span className="font-medium text-[13px] text-gray-900 truncate">{place.name}</span>
<div className="flex items-center gap-1 flex-shrink-0">
{inDay
? <span className="text-[11px] text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded-full"></span>
: selectedDayId && (
<button
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"
>
+ Tag
</button>
)
}
</div>
</div>
{category && <p className="text-xs text-gray-500 mt-0.5">{category.icon} {category.name}</p>}
{place.address && <p className="text-xs text-gray-400 truncate">{place.address}</p>}
</div>
</div>
)
}}
</FixedSizeList>
</div>
)}
</div>
)}
{/* ── RESERVIERUNGEN ── */}
{activeSegment === 'reservierungen' && (
<div>
<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">
Reservierungen
{selectedDay && <span className="text-gray-400 font-normal"> · Tag {selectedDay.day_number}</span>}
</h3>
<button
onClick={() => { setEditingReservation(null); setShowReservationModal(true) }}
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" />
Hinzufügen
</button>
</div>
{filteredReservations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">🎫</span>
<p className="text-sm">Keine Reservierungen</p>
</div>
) : (
<div className="p-3 space-y-2.5">
{filteredReservations.map(r => (
<div key={r.id} className="bg-white border border-gray-100 rounded-2xl p-3.5 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-semibold text-[13px] text-gray-900">{r.title}</div>
{r.reservation_time && (
<div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
<Clock className="w-3 h-3" />
{formatDateTime(r.reservation_time)}
</div>
)}
{r.location && <div className="text-xs text-gray-500 mt-0.5">📍 {r.location}</div>}
{r.confirmation_number && (
<div className="text-xs text-emerald-600 mt-1 bg-emerald-50 rounded-lg px-2 py-0.5 inline-block">
# {r.confirmation_number}
</div>
)}
{r.notes && <p className="text-xs text-gray-500 mt-1.5 leading-relaxed">{r.notes}</p>}
</div>
<div className="flex gap-1 flex-shrink-0">
<button
onClick={() => { setEditingReservation(r); setShowReservationModal(true) }}
className="p-1.5 text-gray-400 hover:text-slate-700 rounded-lg hover:bg-slate-50 transition-colors"
></button>
<button
onClick={() => handleDeleteReservation(r.id)}
className="p-1.5 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors"
>🗑</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* ── PACKLISTE ── */}
{activeSegment === 'packliste' && (
<PackingListPanel tripId={tripId} items={packingItems} />
)}
{/* ── DOKUMENTE ── */}
{activeSegment === 'dokumente' && (
<FileManager tripId={tripId} />
)}
</div>
{/* ── INSPECTOR OVERLAY ── */}
{selectedPlace && (
<div className="absolute inset-0 bg-white z-10 overflow-y-auto">
<PlaceDetailPanel
place={selectedPlace}
categories={categories}
tags={tags}
selectedDayId={selectedDayId}
dayAssignments={selectedDayAssignments}
onClose={() => onPlaceClick(null)}
onEdit={() => onPlaceEdit(selectedPlace)}
onDelete={() => onPlaceDelete(selectedPlace.id)}
onAssignToDay={onAssignToDay}
onRemoveAssignment={onRemoveAssignment}
/>
</div>
)}
<ReservationModal
isOpen={showReservationModal}
onClose={() => { setShowReservationModal(false); setEditingReservation(null) }}
onSave={handleSaveReservation}
reservation={editingReservation}
days={days}
places={places}
selectedDayId={selectedDayId}
/>
</div>
)
}
@@ -1,10 +1,12 @@
import React, { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useMemo } from 'react'
import Modal from '../shared/Modal'
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 { useTranslation } from '../../i18n'
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import type { Day, Place, Reservation, TripFile, AssignmentsMap } from '../../types'
const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
@@ -18,19 +20,64 @@ const TYPE_OPTIONS = [
{ 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)}` : ''
const groupLabel = `${dayLabel}${dateStr}`
// Group header (non-selectable)
options.push({ value: `_header_${day.id}`, label: groupLabel, 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}`,
searchLabel: place.name,
groupLabel,
dayDate: day.date || null,
})
}
}
return options
}
interface ReservationModalProps {
isOpen: boolean
onClose: () => void
onSave: (data: Record<string, string | number | null>) => Promise<void> | void
reservation: Reservation | null
days: Day[]
places: Place[]
assignments: AssignmentsMap
selectedDayId: number | null
files?: TripFile[]
onFileUpload: (fd: FormData) => Promise<void>
onFileDelete: (fileId: number) => Promise<void>
}
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete }: ReservationModalProps) {
const toast = useToast()
const { t } = useTranslation()
const { t, locale } = useTranslation()
const fileInputRef = useRef(null)
const [form, setForm] = useState({
title: '', type: 'other', status: 'pending',
reservation_time: '', location: '', confirmation_number: '',
notes: '', day_id: '', place_id: '',
notes: '', assignment_id: '',
})
const [isSaving, setIsSaving] = 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(() => {
if (reservation) {
@@ -39,17 +86,17 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
type: reservation.type || 'other',
status: reservation.status || 'pending',
reservation_time: reservation.reservation_time ? reservation.reservation_time.slice(0, 16) : '',
reservation_end_time: reservation.reservation_end_time || '',
location: reservation.location || '',
confirmation_number: reservation.confirmation_number || '',
notes: reservation.notes || '',
day_id: reservation.day_id || '',
place_id: reservation.place_id || '',
assignment_id: reservation.assignment_id || '',
})
} else {
setForm({
title: '', type: 'other', status: 'pending',
reservation_time: '', location: '', confirmation_number: '',
notes: '', day_id: selectedDayId || '', place_id: '',
reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
notes: '', assignment_id: '',
})
setPendingFiles([])
}
@@ -64,10 +111,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
try {
const saved = await onSave({
...form,
day_id: form.day_id || null,
place_id: form.place_id || null,
assignment_id: form.assignment_id || null,
})
// Upload pending files for newly created reservations
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
for (const file of pendingFiles) {
const fd = new FormData()
@@ -83,10 +128,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
}
const handleFileChange = async (e) => {
const file = e.target.files?.[0]
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
if (reservation?.id) {
// Existing reservation — upload immediately
setUploadingFile(true)
try {
const fd = new FormData()
@@ -102,7 +146,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
e.target.value = ''
}
} else {
// New reservation — stage locally
setPendingFiles(prev => [...prev, file])
e.target.value = ''
}
@@ -112,29 +155,29 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const inputStyle = {
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)',
}
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 (
<Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="md">
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="2xl">
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Type selector */}
<div>
<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 }) => (
<button key={value} type="button" onClick={() => set('type', value)} style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '6px 11px', borderRadius: 99, border: '1px solid',
fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
display: 'flex', alignItems: 'center', gap: 4,
padding: '5px 10px', borderRadius: 99, border: '1px solid',
fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
background: form.type === value ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: form.type === value ? 'var(--text-primary)' : 'var(--border-primary)',
color: form.type === value ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
<Icon size={12} /> {t(labelKey)}
<Icon size={11} /> {t(labelKey)}
</button>
))}
</div>
@@ -147,13 +190,66 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
</div>
{/* Date/Time + Status */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 10 }}>
<div>
<label style={labelStyle}>{t('reservations.datetime')}</label>
<CustomDateTimePicker value={form.reservation_time} onChange={v => set('reservation_time', v)} />
{/* Assignment Picker + Date */}
<div style={{ display: 'flex', gap: 8 }}>
{assignmentOptions.length > 0 && (
<div style={{ flex: 1, minWidth: 0 }}>
<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)
const opt = assignmentOptions.find(o => o.value === value)
if (opt?.dayDate) {
setForm(prev => {
if (prev.reservation_time) return prev
return { ...prev, reservation_time: opt.dayDate }
})
}
}}
placeholder={t('reservations.pickAssignment')}
options={[
{ value: '', label: t('reservations.noAssignment') },
...assignmentOptions,
]}
searchable
size="sm"
/>
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.date')}</label>
<CustomDatePicker
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
onChange={d => {
const [, t] = (form.reservation_time || '').split('T')
set('reservation_time', d ? (t ? `${d}T${t}` : d) : '')
}}
/>
</div>
<div>
</div>
{/* Start Time + End Time + Status */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.startTime')}</label>
<CustomTimePicker
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
onChange={t => {
const [d] = (form.reservation_time || '').split('T')
const date = d || new Date().toISOString().split('T')[0]
set('reservation_time', t ? `${date}T${t}` : date)
}}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.endTime')}</label>
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
@@ -167,108 +263,61 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div>
</div>
{/* Location */}
<div>
<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 }}>
{/* Location + Booking Code */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.day')}</label>
<CustomSelect
value={form.day_id}
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"
/>
<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>
<div>
<label style={labelStyle}>{t('reservations.place')}</label>
<CustomSelect
value={form.place_id}
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"
/>
<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>
</div>
{/* Notes */}
<div>
<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')}
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div>
{/* File upload — always visible */}
{/* Files */}
<div>
<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 => (
<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)' }}>
<FileText size={13} 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>
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }} title={t('common.open')}>
<ExternalLink size={12} />
</a>
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<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 }}><ExternalLink size={11} /></a>
{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 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
<X size={12} />
<button type="button" onClick={() => onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
<X size={11} />
</button>
)}
</div>
))}
{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)' }}>
<FileText size={13} 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={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{t('reservations.pendingSave')}</span>
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
<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 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
<X size={12} />
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
<X size={11} />
</button>
</div>
))}
<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={{
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 12px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'var(--bg-card)',
fontSize: 12.5, color: 'var(--text-muted)', cursor: uploadingFile ? 'default' : 'pointer',
fontFamily: 'inherit', transition: 'all 0.12s',
}}
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} />
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
}}>
<Paperclip size={11} />
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
</button>
</div>
@@ -276,10 +325,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
{/* Actions */}
<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')}
</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')}
</button>
</div>
@@ -288,8 +337,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
)
}
function formatDate(dateStr) {
function formatDate(dateStr, locale) {
if (!dateStr) return ''
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,453 +0,0 @@
import React, { useState, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
import CustomSelect from '../shared/CustomSelect'
import {
Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, MapPin,
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, MapPinned, X, Users,
ExternalLink, BookMarked, Lightbulb,
} from 'lucide-react'
const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils },
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket },
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users },
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
]
function typeIcon(type) {
return (TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]).Icon
}
function typeLabelKey(type) {
return (TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]).labelKey
}
function formatDateTimeWithLocale(str, locale, timeFormat) {
if (!str) return null
const d = new Date(str)
if (isNaN(d)) return str
const datePart = d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'long' })
const h = d.getHours(), m = d.getMinutes()
let timePart
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 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 }) {
const { toggleReservationStatus } = useTripStore()
const toast = useToast()
const { t, locale } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const TypeIcon = typeIcon(r.type)
const confirmed = r.status === 'confirmed'
const attachedFiles = files.filter(f => f.reservation_id === r.id)
const handleToggle = async () => {
try { await toggleReservationStatus(tripId, r.id) }
catch { toast.error(t('reservations.toast.updateError')) }
}
const handleDelete = async () => {
if (!confirm(t('reservations.confirm.delete', { name: r.title }))) return
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
}
return (
<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: 'stretch' }}>
<div style={{
width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(161,98,7,0.1)',
borderRight: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(161,98,7,0.2)'}`,
}}>
<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 (
<>
{editing && <PlaceReservationEditModal item={item} tripId={tripId} onClose={() => setEditing(false)} />}
<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: 'stretch' }}>
<div style={{
width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(161,98,7,0.1)',
borderRight: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(161,98,7,0.2)'}`,
}}>
<MapPinned 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 }}>{item.title}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 2, flexWrap: 'nowrap', overflow: 'hidden' }}>
<span className="hidden sm:inline" style={{ fontSize: 10.5, color: 'var(--text-faint)', flexShrink: 0 }}>{t('reservations.fromPlan')}</span>
{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>}
</div>
</div>
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
<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>
)}
{item.place_time && !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)' }} />{item.place_time}
</div>
)}
{item.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' }}>{item.location}</span>
</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>}
</div>
</div>
</div>
</>
)
}
function Section({ title, count, children, defaultOpen = true, accent }) {
const [open, setOpen] = useState(defaultOpen)
return (
<div style={{ marginBottom: 24 }}>
<button onClick={() => setOpen(o => !o)} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 10,
}}>
{open ? <ChevronDown size={15} style={{ color: 'var(--text-faint)' }} /> : <ChevronRight size={15} style={{ color: 'var(--text-faint)' }} />}
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>{title}</span>
<span style={{
fontSize: 11, fontWeight: 600, padding: '1px 8px', borderRadius: 99,
background: accent === 'green' ? 'rgba(22,163,74,0.12)' : 'var(--bg-tertiary)',
color: accent === 'green' ? '#16a34a' : 'var(--text-muted)',
}}>{count}</span>
</button>
{open && <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{children}</div>}
</div>
)
}
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }) {
const { t, locale } = useTranslation()
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
const placeReservations = useMemo(() => {
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 allConfirmed = [...reservations.filter(r => r.status === 'confirmed'), ...placeReservations.filter(r => r.status === 'confirmed')]
const total = allPending.length + allConfirmed.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 (
<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' }}>
<div>
<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)' }}>
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
</p>
</div>
<button onClick={onAdd} style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 14px', borderRadius: 99,
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
fontSize: 12.5, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
}}>
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
</button>
</div>
{/* Hinweis — einmalig wegklickbar */}
{showHint && (
<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 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<BookMarked size={40} style={{ marginBottom: 12, 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: 13, color: 'var(--text-faint)', margin: 0 }}>{t('reservations.emptyHint')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{allPending.length > 0 && (
<Section title={t('reservations.pending')} count={allPending.length} defaultOpen={true} accent="gray">
{allPending.map(renderCard)}
</Section>
)}
{allConfirmed.length > 0 && (
<Section title={t('reservations.confirmed')} count={allConfirmed.length} defaultOpen={true} accent="green">
{allConfirmed.map(renderCard)}
</Section>
)}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,298 @@
import { useState, useMemo } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import {
Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, MapPin,
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
} from 'lucide-react'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
interface AssignmentLookupEntry {
dayNumber: number
dayTitle: string | null
dayDate: string
placeName: string
startTime: string | null
endTime: string | null
}
const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane, color: '#3b82f6' },
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel, color: '#8b5cf6' },
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils, color: '#ef4444' },
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train, color: '#06b6d4' },
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car, color: '#6b7280' },
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship, color: '#0ea5e9' },
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket, color: '#f59e0b' },
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users, color: '#10b981' },
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText, color: '#6b7280' },
]
function getType(type) {
return TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]
}
function buildAssignmentLookup(days, assignments) {
const map = {}
for (const day of (days || [])) {
const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
for (const a of da) {
if (!a.place) continue
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 }
}
}
return map
}
interface ReservationCardProps {
r: Reservation
tripId: number
onEdit: (reservation: Reservation) => void
onDelete: (id: number) => void
files?: TripFile[]
onNavigateToFiles: () => void
assignmentLookup: Record<number, AssignmentLookupEntry>
}
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }: ReservationCardProps) {
const { toggleReservationStatus } = useTripStore()
const toast = useToast()
const { t, locale } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const typeInfo = getType(r.type)
const TypeIcon = typeInfo.Icon
const confirmed = r.status === 'confirmed'
const attachedFiles = files.filter(f => f.reservation_id === r.id)
const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null
const handleToggle = async () => {
try { await toggleReservationStatus(tripId, r.id) }
catch { toast.error(t('reservations.toast.updateError')) }
}
const handleDelete = async () => {
if (!confirm(t('reservations.confirm.delete', { name: r.title }))) return
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
}
const fmtDate = (str) => {
const d = new Date(str)
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
}
const fmtTime = (str) => {
const d = new Date(str)
return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
}
return (
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
{/* Header bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)' }}>
<div style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
</button>
<div style={{ width: 1, height: 10, background: 'var(--border-faint)' }} />
<TypeIcon size={11} style={{ color: typeInfo.color, flexShrink: 0 }} />
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{t(typeInfo.labelKey)}</span>
<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>
{/* Details */}
{(r.reservation_time || r.confirmation_number || r.location || linked) && (
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{/* Row 1: Date, Time, Code */}
{(r.reservation_time || r.confirmation_number) && (
<div style={{ display: 'flex', gap: 0, borderRadius: 8, overflow: 'hidden', background: 'var(--bg-secondary)', boxShadow: '0 1px 6px rgba(0,0,0,0.08)' }}>
{r.reservation_time && (
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.date')}</div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{fmtDate(r.reservation_time)}</div>
</div>
)}
{r.reservation_time?.includes('T') && (
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
<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)}{r.reservation_end_time ? ` ${r.reservation_end_time}` : ''}
</div>
</div>
)}
{r.confirmation_number && (
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center' }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.confirmationCode')}</div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{r.confirmation_number}</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>
)}
{/* 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>
)}
{/* 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>
)
}
interface SectionProps {
title: string
count: number
children: React.ReactNode
defaultOpen?: boolean
accent: 'green' | string
}
function Section({ title, count, children, defaultOpen = true, accent }: SectionProps) {
const [open, setOpen] = useState(defaultOpen)
return (
<div style={{ marginBottom: 16 }}>
<button onClick={() => setOpen(o => !o)} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 8, fontFamily: 'inherit',
}}>
{open ? <ChevronDown size={14} style={{ color: 'var(--text-faint)' }} /> : <ChevronRight size={14} style={{ color: 'var(--text-faint)' }} />}
<span style={{ fontWeight: 700, fontSize: 12, color: 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{title}</span>
<span style={{
fontSize: 10, fontWeight: 700, padding: '1px 7px', borderRadius: 99,
background: accent === 'green' ? 'rgba(22,163,74,0.1)' : 'var(--bg-tertiary)',
color: accent === 'green' ? '#16a34a' : 'var(--text-faint)',
}}>{count}</span>
</button>
{open && <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{children}</div>}
</div>
)
}
interface ReservationsPanelProps {
tripId: number
reservations: Reservation[]
days: Day[]
assignments: AssignmentsMap
files?: TripFile[]
onAdd: () => void
onEdit: (reservation: Reservation) => void
onDelete: (id: number) => void
onNavigateToFiles: () => void
}
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
const { t, locale } = useTranslation()
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
const allPending = reservations.filter(r => r.status !== 'confirmed')
const allConfirmed = reservations.filter(r => r.status === 'confirmed')
const total = reservations.length
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Header */}
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid var(--border-faint)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('reservations.title')}</h2>
<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 })}
</p>
</div>
<button onClick={onAdd} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}>
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
</button>
</div>
{/* Content */}
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px' }}>
{total === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<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: 12, color: 'var(--text-faint)', margin: 0 }}>{t('reservations.emptyHint')}</p>
</div>
) : (
<>
{allPending.length > 0 && (
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
<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>
)}
{allConfirmed.length > 0 && (
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
<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>
)}
</>
)}
</div>
</div>
)
}
@@ -1,612 +0,0 @@
import React, { useState, useCallback } from 'react'
import { Plus, Search, ChevronUp, ChevronDown, X, Map, ExternalLink, Navigation, RotateCcw, Clock, Euro, FileText, Package } from 'lucide-react'
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
import PackingListPanel from '../Packing/PackingListPanel'
import { ReservationModal } from './ReservationModal'
import { PlaceDetailPanel } from './PlaceDetailPanel'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
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({
trip, days, places, categories, tags,
assignments, reservations, packingItems,
selectedDay, selectedDayId, selectedPlaceId,
onPlaceClick, onPlaceEdit, onPlaceDelete,
onAssignToDay, onRemoveAssignment, onReorder,
onAddPlace, onEditTrip, onRouteCalculated, tripId,
}) {
const [activeTab, setActiveTab] = useState('orte')
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [transportMode, setTransportMode] = useState('driving')
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
const [showReservationModal, setShowReservationModal] = useState(false)
const [editingReservation, setEditingReservation] = useState(null)
const [routeInfo, setRouteInfo] = useState(null)
const tripStore = useTripStore()
const toast = useToast()
// Filtered places for Orte tab
const filteredPlaces = places.filter(p => {
const matchesSearch = !search || p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.address || '').toLowerCase().includes(search.toLowerCase())
const matchesCategory = !categoryFilter || String(p.category_id) === String(categoryFilter)
return matchesSearch && matchesCategory
})
// Ordered assignments for selected day
const dayAssignments = selectedDayId
? (assignments[String(selectedDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
: []
const isAssignedToSelectedDay = (placeId) =>
selectedDayId && dayAssignments.some(a => a.place?.id === placeId)
// Calculate schedule with times
const getSchedule = () => {
if (!dayAssignments.length) return []
let currentTime = null
return dayAssignments.map((assignment, idx) => {
const place = assignment.place
const startTime = place?.place_time || (currentTime ? currentTime : null)
const duration = place?.duration_minutes || 60
if (startTime) {
const [h, m] = startTime.split(':').map(Number)
const endMinutes = h * 60 + m + duration
const endH = Math.floor(endMinutes / 60) % 24
const endM = endMinutes % 60
currentTime = `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`
}
return { assignment, startTime, endTime: currentTime }
})
}
const handleCalculateRoute = async () => {
if (!selectedDayId) return
const waypoints = dayAssignments
.map(a => a.place)
.filter(p => p?.lat && p?.lng)
.map(p => ({ lat: p.lat, lng: p.lng }))
if (waypoints.length < 2) {
toast.error('Mindestens 2 Orte mit Koordinaten benötigt')
return
}
setIsCalculatingRoute(true)
try {
const result = await calculateRoute(waypoints, transportMode)
if (result) {
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
onRouteCalculated?.(result)
toast.success('Route berechnet')
} else {
toast.error('Route konnte nicht berechnet werden')
}
} catch (err) {
toast.error('Fehler bei der Routenberechnung')
} finally {
setIsCalculatingRoute(false)
}
}
const handleOptimizeRoute = async () => {
if (!selectedDayId || dayAssignments.length < 3) return
const places = dayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
const optimized = optimizeRoute(places)
const optimizedIds = optimized.map(p => {
const a = dayAssignments.find(a => a.place?.id === p.id)
return a?.id
}).filter(Boolean)
await onReorder(selectedDayId, optimizedIds)
toast.success('Route optimiert')
}
const handleOpenGoogleMaps = () => {
const places = dayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
const url = generateGoogleMapsUrl(places)
if (url) window.open(url, '_blank')
else toast.error('Keine Orte mit Koordinaten vorhanden')
}
const handleMoveUp = async (idx) => {
if (idx === 0) return
const ids = dayAssignments.map(a => a.id)
;[ids[idx - 1], ids[idx]] = [ids[idx], ids[idx - 1]]
await onReorder(selectedDayId, ids)
}
const handleMoveDown = async (idx) => {
if (idx === dayAssignments.length - 1) return
const ids = dayAssignments.map(a => a.id)
;[ids[idx], ids[idx + 1]] = [ids[idx + 1], ids[idx]]
await onReorder(selectedDayId, ids)
}
const handleAddReservation = () => {
setEditingReservation(null)
setShowReservationModal(true)
}
const handleSaveReservation = async (data) => {
try {
if (editingReservation) {
await tripStore.updateReservation(tripId, editingReservation.id, data)
toast.success('Reservierung aktualisiert')
} else {
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
toast.success('Reservierung hinzugefügt')
}
setShowReservationModal(false)
} catch (err) {
toast.error(err.message)
}
}
const handleDeleteReservation = async (id) => {
if (!confirm('Reservierung löschen?')) return
try {
await tripStore.deleteReservation(tripId, id)
toast.success('Reservierung gelöscht')
} catch (err) {
toast.error(err.message)
}
}
// Reservations for selected day (or all if no day selected)
const filteredReservations = selectedDayId
? reservations.filter(r => String(r.day_id) === String(selectedDayId) || !r.day_id)
: reservations
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
return (
<div className="flex flex-col h-full bg-white">
{/* Tabs */}
<div className="flex border-b border-gray-200 flex-shrink-0">
{TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex-1 py-2.5 text-xs font-medium transition-colors flex flex-col items-center gap-0.5 ${
activeTab === tab.id
? 'text-slate-700 border-b-2 border-slate-700'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<span className="text-base leading-none">{tab.icon}</span>
<span>{tab.label}</span>
</button>
))}
</div>
{/* Tab Content */}
<div className="flex-1 overflow-y-auto">
{/* ORTE TAB */}
{activeTab === 'orte' && (
<div className="flex flex-col h-full">
{/* Place detail (when selected) */}
{selectedPlace && (
<div className="border-b border-gray-100">
<PlaceDetailPanel
place={selectedPlace}
categories={categories}
tags={tags}
selectedDayId={selectedDayId}
dayAssignments={dayAssignments}
onClose={() => onPlaceClick(null)}
onEdit={() => onPlaceEdit(selectedPlace)}
onDelete={() => onPlaceDelete(selectedPlace.id)}
onAssignToDay={onAssignToDay}
onRemoveAssignment={onRemoveAssignment}
/>
</div>
)}
{/* Search & filter */}
<div className="p-3 space-y-2 border-b border-gray-100 flex-shrink-0">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 w-4 h-4 text-gray-400" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Orte suchen..."
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 && (
<button onClick={() => setSearch('')} className="absolute right-2.5 top-2.5">
<X className="w-4 h-4 text-gray-400" />
</button>
)}
</div>
<div className="flex items-center gap-2">
<select
value={categoryFilter}
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"
>
<option value="">Alle Kategorien</option>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
))}
</select>
<button
onClick={onAddPlace}
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" />
Ort hinzufügen
</button>
</div>
</div>
{/* Places list */}
<div className="flex-1 overflow-y-auto">
{filteredPlaces.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">📍</span>
<p className="text-sm">Keine Orte gefunden</p>
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm hover:underline">
Ersten Ort hinzufügen
</button>
</div>
) : (
<div className="divide-y divide-gray-50">
{filteredPlaces.map(place => {
const category = categories.find(c => c.id === place.category_id)
const isInDay = isAssignedToSelectedDay(place.id)
const isSelected = place.id === selectedPlaceId
return (
<div
key={place.id}
onClick={() => onPlaceClick(isSelected ? null : place.id)}
className={`px-3 py-2.5 cursor-pointer transition-colors ${
isSelected ? 'bg-slate-50' : 'hover:bg-gray-50'
}`}
>
<div className="flex items-start gap-2">
{/* Category color bar */}
<div
className="w-1 rounded-full flex-shrink-0 mt-1 self-stretch"
style={{ backgroundColor: category?.color || '#6366f1', minHeight: 16 }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1">
<span className="font-medium text-sm text-gray-900 truncate">{place.name}</span>
<div className="flex items-center gap-1 flex-shrink-0">
{isInDay && (
<span className="text-xs text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded"></span>
)}
{!isInDay && selectedDayId && (
<button
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"
>
+ Tag
</button>
)}
</div>
</div>
{category && (
<span className="text-xs text-gray-500">{category.icon} {category.name}</span>
)}
{place.address && (
<p className="text-xs text-gray-400 truncate mt-0.5">{place.address}</p>
)}
<div className="flex items-center gap-2 mt-1">
{place.place_time && (
<span className="text-xs text-gray-500">🕐 {place.place_time}</span>
)}
{place.price > 0 && (
<span className="text-xs text-gray-500">
{place.price} {place.currency || trip?.currency}
</span>
)}
</div>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)}
{/* TAGESPLAN TAB */}
{activeTab === 'tagesplan' && (
<div className="flex flex-col h-full">
{!selectedDayId ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400 px-6">
<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>
</div>
) : (
<>
{/* Day header */}
<div className="px-4 py-3 bg-slate-50 border-b border-slate-100 flex-shrink-0">
<h3 className="font-semibold text-slate-900 text-sm">
Tag {selectedDay?.day_number}
{selectedDay?.date && (
<span className="font-normal text-slate-700 ml-2">
{formatGermanDate(selectedDay.date)}
</span>
)}
</h3>
<p className="text-xs text-slate-700 mt-0.5">
{dayAssignments.length} Ort{dayAssignments.length !== 1 ? 'e' : ''}
{dayAssignments.length > 0 && ` · ${dayAssignments.reduce((s, a) => s + (a.place?.duration_minutes || 60), 0)} Min. gesamt`}
</p>
</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 */}
<div className="flex-1 overflow-y-auto">
{dayAssignments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">🗺</span>
<p className="text-sm">Noch keine Orte für diesen Tag</p>
<button
onClick={() => setActiveTab('orte')}
className="mt-3 text-slate-700 text-sm hover:underline"
>
Orte hinzufügen
</button>
</div>
) : (
<div className="divide-y divide-gray-50">
{getSchedule().map(({ assignment, startTime, endTime }, idx) => {
const place = assignment.place
if (!place) return null
const category = categories.find(c => c.id === place.category_id)
return (
<div key={assignment.id} className="px-3 py-3 flex items-start gap-2">
{/* Order number */}
<div
className="w-7 h-7 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 mt-0.5"
style={{ backgroundColor: category?.color || '#6366f1' }}
>
{idx + 1}
</div>
{/* Place info */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-gray-900 truncate">{place.name}</div>
<div className="flex items-center gap-2 mt-0.5">
{startTime && (
<span className="text-xs text-slate-700">🕐 {startTime}</span>
)}
<span className="text-xs text-gray-400">
{place.duration_minutes || 60} Min.
</span>
{place.price > 0 && (
<span className="text-xs text-gray-400">
{place.price} {place.currency || trip?.currency}
</span>
)}
</div>
{place.address && (
<p className="text-xs text-gray-400 mt-0.5 truncate">{place.address}</p>
)}
{assignment.notes && (
<p className="text-xs text-gray-500 mt-1 bg-gray-50 rounded px-2 py-1">{assignment.notes}</p>
)}
</div>
{/* Actions */}
<div className="flex flex-col items-center gap-0.5 flex-shrink-0">
<button
onClick={() => handleMoveUp(idx)}
disabled={idx === 0}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
>
<ChevronUp className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleMoveDown(idx)}
disabled={idx === dayAssignments.length - 1}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
>
<ChevronDown className="w-3.5 h-3.5" />
</button>
<button
onClick={() => onRemoveAssignment(selectedDayId, assignment.id)}
className="p-1 text-red-400 hover:text-red-600"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
})}
</div>
)}
</div>
{/* Route buttons */}
{dayAssignments.length >= 2 && (
<div className="p-3 border-t border-gray-100 flex-shrink-0 space-y-2">
{routeInfo && (
<div className="flex items-center justify-center gap-3 text-sm bg-slate-50 rounded-lg px-3 py-2">
<span className="text-slate-900">🛣 {routeInfo.distance}</span>
<span className="text-slate-400">·</span>
<span className="text-slate-900"> {routeInfo.duration}</span>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<button
onClick={handleCalculateRoute}
disabled={isCalculatingRoute}
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" />
{isCalculatingRoute ? 'Berechne...' : 'Route berechnen'}
</button>
<button
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"
>
<RotateCcw className="w-3.5 h-3.5" />
Optimieren
</button>
</div>
<button
onClick={handleOpenGoogleMaps}
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" />
In Google Maps öffnen
</button>
</div>
)}
</>
)}
</div>
)}
{/* RESERVIERUNGEN TAB */}
{activeTab === 'reservierungen' && (
<div className="flex flex-col h-full">
<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">
Reservierungen
{selectedDay && <span className="text-gray-500 font-normal"> · Tag {selectedDay.day_number}</span>}
</h3>
<button
onClick={handleAddReservation}
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" />
Hinzufügen
</button>
</div>
<div className="flex-1 overflow-y-auto">
{filteredReservations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">🎫</span>
<p className="text-sm">Keine Reservierungen</p>
<button onClick={handleAddReservation} className="mt-3 text-slate-700 text-sm hover:underline">
Erste Reservierung hinzufügen
</button>
</div>
) : (
<div className="p-3 space-y-3">
{filteredReservations.map(reservation => (
<div key={reservation.id} className="bg-white border border-gray-200 rounded-xl p-3 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm text-gray-900">{reservation.title}</div>
{reservation.reservation_time && (
<div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
<Clock className="w-3 h-3" />
{formatDateTime(reservation.reservation_time)}
</div>
)}
{reservation.location && (
<div className="text-xs text-gray-500 mt-0.5">📍 {reservation.location}</div>
)}
{reservation.confirmation_number && (
<div className="text-xs text-emerald-600 mt-1 bg-emerald-50 rounded px-2 py-0.5 inline-block">
# {reservation.confirmation_number}
</div>
)}
{reservation.notes && (
<p className="text-xs text-gray-500 mt-1.5 leading-relaxed">{reservation.notes}</p>
)}
</div>
<div className="flex gap-1 flex-shrink-0">
<button
onClick={() => { setEditingReservation(reservation); setShowReservationModal(true) }}
className="p-1.5 text-gray-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg"
>
</button>
<button
onClick={() => handleDeleteReservation(reservation.id)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
>
🗑
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* PACKLISTE TAB */}
{activeTab === 'packliste' && (
<PackingListPanel
tripId={tripId}
items={packingItems}
/>
)}
</div>
{/* Reservation Modal */}
<ReservationModal
isOpen={showReservationModal}
onClose={() => { setShowReservationModal(false); setEditingReservation(null) }}
onSave={handleSaveReservation}
reservation={editingReservation}
days={days}
places={places}
selectedDayId={selectedDayId}
/>
</div>
)
}
function formatGermanDate(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr + 'T00:00:00')
return date.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })
}
function formatDateTime(dt) {
if (!dt) return ''
try {
return new Date(dt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
} catch {
return dt
}
}
@@ -1,12 +1,21 @@
import React, { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef } from 'react'
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 { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import type { Trip } from '../../types'
export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }) {
interface TripFormModalProps {
isOpen: boolean
onClose: () => void
onSave: (data: Record<string, string | number | null>) => Promise<void> | void
trip: Trip | null
onCoverUpdate: (tripId: number, coverUrl: string) => void
}
export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }: TripFormModalProps) {
const isEditing = !!trip
const fileRef = useRef(null)
const toast = useToast()
@@ -21,6 +30,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [coverPreview, setCoverPreview] = useState(null)
const [pendingCoverFile, setPendingCoverFile] = useState(null)
const [uploadingCover, setUploadingCover] = useState(false)
useEffect(() => {
@@ -36,6 +46,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
setFormData({ title: '', description: '', start_date: '', end_date: '' })
setCoverPreview(null)
}
setPendingCoverFile(null)
setError('')
}, [trip, isOpen])
@@ -48,23 +59,49 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
}
setIsLoading(true)
try {
await onSave({
const result = await onSave({
title: formData.title.trim(),
description: formData.description.trim() || null,
start_date: formData.start_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()
} catch (err) {
setError(err.message || t('places.saveError'))
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('places.saveError'))
} finally {
setIsLoading(false)
}
}
const handleCoverChange = async (e) => {
const file = e.target.files?.[0]
if (!file || !trip?.id) return
const handleCoverSelect = (file) => {
if (!file) 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 as HTMLInputElement).files?.[0])
e.target.value = ''
}
const uploadCoverNow = async (file) => {
setUploadingCover(true)
try {
const fd = new FormData()
@@ -77,11 +114,15 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
toast.error(t('dashboard.coverUploadError'))
} finally {
setUploadingCover(false)
e.target.value = ''
}
}
const handleRemoveCover = async () => {
if (pendingCoverFile) {
setPendingCoverFile(null)
setCoverPreview(null)
return
}
if (!trip?.id) return
try {
await tripsApi.update(trip.id, { cover_image: null })
@@ -92,7 +133,36 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
}
}
const update = (field, value) => setFormData(prev => ({ ...prev, [field]: value }))
// Paste support for cover image
const handlePaste = (e) => {
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
e.preventDefault()
const file = item.getAsFile()
if (file) handleCoverSelect(file)
return
}
}
}
const update = (field, value) => setFormData(prev => {
const next = { ...prev, [field]: value }
if (field === 'start_date' && value) {
if (!prev.end_date || prev.end_date < value) {
next.end_date = value
} else if (prev.start_date) {
const oldStart = new Date(prev.start_date + 'T00:00:00')
const oldEnd = new Date(prev.end_date + 'T00:00:00')
const duration = Math.round((oldEnd - oldStart) / 86400000)
const newEnd = new Date(value + 'T00:00:00')
newEnd.setDate(newEnd.getDate() + duration)
next.end_date = newEnd.toISOString().split('T')[0]
}
}
return next
})
const inputCls = "w-full px-3 py-2.5 border border-slate-200 rounded-lg text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-300 focus:border-transparent text-sm"
@@ -117,40 +187,38 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
</div>
}
>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}>
{error && (
<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 */}
{isEditing && (
<div>
<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} />
{coverPreview ? (
<div style={{ position: 'relative', borderRadius: 10, overflow: 'hidden', height: 130 }}>
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<div style={{ position: 'absolute', bottom: 8, right: 8, display: 'flex', gap: 6 }}>
<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)' }}>
<Camera size={12} /> {uploadingCover ? t('common.uploading') : t('common.change')}
</button>
<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)' }}>
<X size={12} />
</button>
</div>
{/* Cover image — available for both create and edit */}
<div>
<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} />
{coverPreview ? (
<div style={{ position: 'relative', borderRadius: 10, overflow: 'hidden', height: 130 }}>
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<div style={{ position: 'absolute', bottom: 8, right: 8, display: 'flex', gap: 6 }}>
<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)' }}>
<Camera size={12} /> {uploadingCover ? t('common.uploading') : t('common.change')}
</button>
<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)' }}>
<X size={12} />
</button>
</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' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.color = '#6b7280' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
</button>
)}
</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' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.color = '#6b7280' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
</button>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
@@ -1,13 +1,20 @@
import React, { useState, useEffect } from 'react'
import { useState, useEffect } from 'react'
import Modal from '../shared/Modal'
import { tripsApi, authApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useAuthStore } from '../../store/authStore'
import { Crown, UserMinus, UserPlus, Users, LogOut } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../types'
import CustomSelect from '../shared/CustomSelect'
function Avatar({ username, avatarUrl, size = 32 }) {
interface AvatarProps {
username: string
avatarUrl: string | null
size?: number
}
function Avatar({ username, avatarUrl, size = 32 }: AvatarProps) {
if (avatarUrl) {
return <img src={avatarUrl} alt="" style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
}
@@ -25,7 +32,14 @@ function Avatar({ username, avatarUrl, size = 32 }) {
)
}
export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }) {
interface TripMembersModalProps {
isOpen: boolean
onClose: () => void
tripId: number
tripTitle: string
}
export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }: TripMembersModalProps) {
const [data, setData] = useState(null)
const [allUsers, setAllUsers] = useState([])
const [loading, setLoading] = useState(false)
@@ -71,8 +85,8 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle })
setSelectedUserId('')
await loadMembers()
toast.success(`${target.username} ${t('members.added')}`)
} catch (err) {
toast.error(err.response?.data?.error || t('members.addError'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('members.addError')))
} finally {
setAdding(false)
}
@@ -144,7 +158,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle })
disabled={adding || !selectedUserId}
style={{
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',
fontFamily: 'inherit', opacity: adding || !selectedUserId ? 0.4 : 1, flexShrink: 0,
}}
@@ -0,0 +1,96 @@
import { useMemo, useState, useCallback } from 'react'
import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n'
import { isWeekend } from './holidays'
import VacayMonthCard from './VacayMonthCard'
import { Building2, MousePointer2 } from 'lucide-react'
export default function VacayCalendar() {
const { t } = useTranslation()
const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays } = useVacayStore()
const [companyMode, setCompanyMode] = useState(false)
const companyHolidaySet = useMemo(() => {
const s = new Set()
companyHolidays.forEach(h => s.add(h.date))
return s
}, [companyHolidays])
const entryMap = useMemo(() => {
const map = {}
entries.forEach(e => {
if (!map[e.date]) map[e.date] = []
map[e.date].push(e)
})
return map
}, [entries])
const blockWeekends = plan?.block_weekends !== false
const companyHolidaysEnabled = plan?.company_holidays_enabled !== false
const handleCellClick = useCallback(async (dateStr) => {
if (companyMode) {
if (!companyHolidaysEnabled) return
await toggleCompanyHoliday(dateStr)
return
}
if (holidays[dateStr]) return
if (blockWeekends && isWeekend(dateStr)) return
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
await toggleEntry(dateStr, selectedUserId || undefined)
}, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId])
const selectedUser = users.find(u => u.id === selectedUserId)
return (
<div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{Array.from({ length: 12 }, (_, i) => (
<VacayMonthCard
key={i}
year={selectedYear}
month={i}
holidays={holidays}
companyHolidaySet={companyHolidaySet}
companyHolidaysEnabled={companyHolidaysEnabled}
entryMap={entryMap}
onCellClick={handleCellClick}
companyMode={companyMode}
blockWeekends={blockWeekends}
/>
))}
</div>
{/* Floating toolbar */}
<div className="sticky bottom-3 sm:bottom-4 mt-3 sm:mt-4 flex items-center justify-center z-30 px-2">
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
<button
onClick={() => setCompanyMode(false)}
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-all"
style={{
background: !companyMode ? 'var(--text-primary)' : 'transparent',
color: !companyMode ? 'var(--bg-card)' : 'var(--text-muted)',
border: companyMode ? '1px solid var(--border-primary)' : '1px solid transparent',
}}>
<MousePointer2 size={13} />
{selectedUser && <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: selectedUser.color }} />}
{selectedUser ? selectedUser.username : t('vacay.modeVacation')}
</button>
{companyHolidaysEnabled && (
<button
onClick={() => setCompanyMode(true)}
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-all"
style={{
background: companyMode ? '#d97706' : 'transparent',
color: companyMode ? '#fff' : 'var(--text-muted)',
border: !companyMode ? '1px solid var(--border-primary)' : '1px solid transparent',
}}>
<Building2 size={13} />
{t('vacay.modeCompany')}
</button>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,131 @@
import { useMemo } from 'react'
import { useTranslation } from '../../i18n'
import { isWeekend } from './holidays'
import type { HolidaysMap, VacayEntry } from '../../types'
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
interface VacayMonthCardProps {
year: number
month: number
holidays: HolidaysMap
companyHolidaySet: Set<string>
companyHolidaysEnabled?: boolean
entryMap: Record<string, VacayEntry[]>
onCellClick: (date: string) => void
companyMode: boolean
blockWeekends: boolean
}
export default function VacayMonthCard({
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
onCellClick, companyMode, blockWeekends
}: VacayMonthCardProps) {
const { language } = useTranslation()
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN
const monthNames = language === 'de' ? MONTHS_DE : MONTHS_EN
const weeks = useMemo(() => {
const firstDay = new Date(year, month, 1)
const daysInMonth = new Date(year, month + 1, 0).getDate()
let startDow = firstDay.getDay() - 1
if (startDow < 0) startDow = 6
const cells = []
for (let i = 0; i < startDow; i++) cells.push(null)
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
while (cells.length % 7 !== 0) cells.push(null)
const w = []
for (let i = 0; i < cells.length; i += 7) w.push(cells.slice(i, i + 7))
return w
}, [year, month])
const pad = (n) => String(n).padStart(2, '0')
return (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-3 py-2 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
<span className="text-xs font-semibold" style={{ color: 'var(--text-primary)' }}>{monthNames[month]}</span>
</div>
<div className="grid grid-cols-7 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
{weekdays.map((wd, i) => (
<div key={wd} className="text-center text-[10px] font-medium py-1" style={{ color: i >= 5 ? 'var(--text-faint)' : 'var(--text-muted)' }}>
{wd}
</div>
))}
</div>
<div>
{weeks.map((week, wi) => (
<div key={wi} className="grid grid-cols-7">
{week.map((day, di) => {
if (day === null) return <div key={di} style={{ height: 28 }} />
const dateStr = `${year}-${pad(month + 1)}-${pad(day)}`
const weekend = di >= 5
const holiday = holidays[dateStr]
const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr)
const dayEntries = entryMap[dateStr] || []
const isBlocked = !!holiday || (weekend && blockWeekends) || (isCompany && !companyMode)
return (
<div
key={di}
className="relative flex items-center justify-center cursor-pointer transition-colors"
style={{
height: 28,
background: weekend ? 'var(--bg-secondary)' : 'transparent',
borderTop: '1px solid var(--border-secondary)',
borderRight: '1px solid var(--border-secondary)',
cursor: isBlocked ? 'default' : 'pointer',
}}
onClick={() => onCellClick(dateStr)}
onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = weekend ? 'var(--bg-secondary)' : 'transparent' }}
>
{holiday && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(239,68,68,0.12)' }} />}
{isCompany && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(245,158,11,0.15)' }} />}
{dayEntries.length === 1 && (
<div className="absolute inset-0.5 rounded" style={{ backgroundColor: dayEntries[0].person_color, opacity: 0.4 }} />
)}
{dayEntries.length === 2 && (
<div className="absolute inset-0.5 rounded" style={{
background: `linear-gradient(135deg, ${dayEntries[0].person_color} 50%, ${dayEntries[1].person_color} 50%)`,
opacity: 0.4,
}} />
)}
{dayEntries.length === 3 && (
<div className="absolute inset-0.5 rounded overflow-hidden" style={{ opacity: 0.4 }}>
<div className="absolute top-0 left-0 w-1/2 h-full" style={{ backgroundColor: dayEntries[0].person_color }} />
<div className="absolute top-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[1].person_color }} />
<div className="absolute bottom-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[2].person_color }} />
</div>
)}
{dayEntries.length >= 4 && (
<div className="absolute inset-0.5 rounded overflow-hidden" style={{ opacity: 0.4 }}>
<div className="absolute top-0 left-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[0].person_color }} />
<div className="absolute top-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[1].person_color }} />
<div className="absolute bottom-0 left-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[2].person_color }} />
<div className="absolute bottom-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[3].person_color }} />
</div>
)}
<span className="relative z-[1] text-[11px] font-medium" style={{
color: holiday ? '#dc2626' : weekend ? 'var(--text-faint)' : 'var(--text-primary)',
fontWeight: dayEntries.length > 0 ? 700 : 500,
}}>
{day}
</span>
</div>
)
})}
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,192 @@
import ReactDOM from 'react-dom'
import { useState, useEffect } from 'react'
import DOM from 'react-dom'
import { UserPlus, Unlink, Check, Loader2, Clock, X } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../types'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import apiClient from '../../api/client'
const PRESET_COLORS = [
'#6366f1', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444',
'#3b82f6', '#22c55e', '#06b6d4', '#f43f5e', '#a855f7',
'#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488',
]
export default function VacayPersons() {
const { t } = useTranslation()
const toast = useToast()
const { users, pendingInvites, invite, cancelInvite, updateColor, selectedUserId, setSelectedUserId, isFused } = useVacayStore()
const { user: currentUser } = useAuthStore()
// Default selectedUserId to current user
useEffect(() => {
if (!selectedUserId && currentUser) setSelectedUserId(currentUser.id)
}, [currentUser, selectedUserId])
const [showInvite, setShowInvite] = useState(false)
const [showColorPicker, setShowColorPicker] = useState(false)
const [colorEditUserId, setColorEditUserId] = useState(null)
const [availableUsers, setAvailableUsers] = useState([])
const [selectedInviteUser, setSelectedInviteUser] = useState(null)
const [inviting, setInviting] = useState(false)
const loadAvailable = async () => {
try {
const data = await apiClient.get('/addons/vacay/available-users').then(r => r.data)
setAvailableUsers(data.users)
} catch { /* */ }
}
const handleInvite = async () => {
if (!selectedInviteUser) return
setInviting(true)
try {
await invite(selectedInviteUser)
toast.success(t('vacay.inviteSent'))
setShowInvite(false)
setSelectedInviteUser(null)
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('vacay.inviteError')))
} finally {
setInviting(false)
}
}
const handleColorChange = async (color) => {
await updateColor(color, colorEditUserId)
setShowColorPicker(false)
setColorEditUserId(null)
}
const editingUserColor = users.find(u => u.id === colorEditUserId)?.color || '#6366f1'
return (
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>{t('vacay.persons')}</span>
<button onClick={() => { setShowInvite(true); loadAvailable() }}
className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }}>
<UserPlus size={14} />
</button>
</div>
<div className="flex flex-col gap-0.5">
{users.map(u => {
const isSelected = selectedUserId === u.id
return (
<div key={u.id}
onClick={() => { if (isFused) setSelectedUserId(u.id) }}
className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group transition-all"
style={{
background: isSelected ? 'var(--bg-hover)' : 'transparent',
border: isSelected ? '1px solid var(--border-primary)' : '1px solid transparent',
cursor: isFused ? 'pointer' : 'default',
}}>
<button
onClick={(e) => { e.stopPropagation(); setColorEditUserId(u.id); setShowColorPicker(true) }}
className="w-3.5 h-3.5 rounded-full shrink-0 transition-transform hover:scale-125"
style={{ backgroundColor: u.color, cursor: 'pointer' }}
title={t('vacay.changeColor')}
/>
<span className="text-xs font-medium flex-1 truncate" style={{ color: 'var(--text-primary)' }}>
{u.username}
{u.id === currentUser?.id && <span style={{ color: 'var(--text-faint)' }}> ({t('vacay.you')})</span>}
</span>
{isSelected && isFused && (
<Check size={12} style={{ color: 'var(--text-primary)' }} />
)}
</div>
)
})}
{/* Pending invites */}
{pendingInvites.map(inv => (
<div key={inv.id} className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group"
style={{ background: 'var(--bg-secondary)', opacity: 0.7 }}>
<Clock size={12} style={{ color: 'var(--text-faint)' }} />
<span className="text-xs flex-1 truncate" style={{ color: 'var(--text-muted)' }}>
{inv.username} <span className="text-[10px]">({t('vacay.pending')})</span>
</span>
<button onClick={() => cancelInvite(inv.user_id)}
className="opacity-0 group-hover:opacity-100 text-[10px] px-1.5 py-0.5 rounded transition-all"
style={{ color: 'var(--text-faint)' }}>
{t('common.cancel')}
</button>
</div>
))}
</div>
{/* Invite Modal — Portal to body to avoid z-index issues */}
{showInvite && ReactDOM.createPortal(
<div className="fixed inset-0 flex items-center justify-center px-4" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
onClick={() => setShowInvite(false)}>
<div className="rounded-2xl shadow-2xl w-full max-w-sm" style={{ background: 'var(--bg-card)', animation: 'modalIn 0.2s ease-out' }}
onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.inviteUser')}</h2>
<button onClick={() => setShowInvite(false)} className="p-1.5 rounded-lg transition-colors" style={{ color: 'var(--text-faint)' }}>
<X size={16} />
</button>
</div>
<div className="p-5 space-y-4">
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{t('vacay.inviteHint')}</p>
{availableUsers.length === 0 ? (
<p className="text-xs text-center py-4" style={{ color: 'var(--text-faint)' }}>{t('vacay.noUsersAvailable')}</p>
) : (
<CustomSelect
value={selectedInviteUser}
onChange={setSelectedInviteUser}
options={availableUsers.map(u => ({ value: u.id, label: `${u.username} (${u.email})` }))}
placeholder={t('vacay.selectUser')}
searchable
/>
)}
<div className="flex gap-3 justify-end pt-2">
<button onClick={() => setShowInvite(false)} className="px-4 py-2 text-sm rounded-lg"
style={{ color: 'var(--text-muted)', border: '1px solid var(--border-primary)' }}>
{t('common.cancel')}
</button>
<button onClick={handleInvite} disabled={!selectedInviteUser || inviting}
className="px-4 py-2 text-sm rounded-lg transition-colors flex items-center gap-1.5 disabled:opacity-40"
style={{ background: 'var(--text-primary)', color: 'var(--bg-card)' }}>
{inviting && <Loader2 size={13} className="animate-spin" />}
{t('vacay.sendInvite')}
</button>
</div>
</div>
</div>
</div>,
document.body
)}
{/* Color Picker Modal — Portal to body */}
{showColorPicker && ReactDOM.createPortal(
<div className="fixed inset-0 flex items-center justify-center px-4" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
onClick={() => { setShowColorPicker(false); setColorEditUserId(null) }}>
<div className="rounded-2xl shadow-2xl w-full max-w-xs" style={{ background: 'var(--bg-card)', animation: 'modalIn 0.2s ease-out' }}
onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.changeColor')}</h2>
<button onClick={() => { setShowColorPicker(false); setColorEditUserId(null) }} className="p-1.5 rounded-lg transition-colors" style={{ color: 'var(--text-faint)' }}>
<X size={16} />
</button>
</div>
<div className="p-5">
<div className="flex flex-wrap gap-2 justify-center">
{PRESET_COLORS.map(c => (
<button key={c} onClick={() => handleColorChange(c)}
className={`w-8 h-8 rounded-full transition-all ${editingUserColor === c ? 'ring-2 ring-offset-2 scale-110' : 'hover:scale-110'}`}
style={{ backgroundColor: c }} />
))}
</div>
</div>
</div>
</div>,
document.body
)}
</div>
)
}
@@ -0,0 +1,225 @@
import { useState, useEffect } from 'react'
import { MapPin, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import apiClient from '../../api/client'
interface VacaySettingsProps {
onClose: () => void
}
export default function VacaySettings({ onClose }: VacaySettingsProps) {
const { t } = useTranslation()
const toast = useToast()
const { plan, updatePlan, isFused, dissolve, users } = useVacayStore()
const [countries, setCountries] = useState([])
const [regions, setRegions] = useState([])
const [loadingRegions, setLoadingRegions] = useState(false)
const { language } = useTranslation()
// Load available countries with localized names
useEffect(() => {
apiClient.get('/addons/vacay/holidays/countries').then(r => {
let displayNames
try { displayNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) } catch { /* */ }
const list = r.data.map(c => ({
value: c.countryCode,
label: displayNames ? (displayNames.of(c.countryCode) || c.name) : c.name,
}))
list.sort((a, b) => a.label.localeCompare(b.label))
setCountries(list)
}).catch(() => {})
}, [language])
// When country changes, check if it has regions
const selectedCountry = plan?.holidays_region?.split('-')[0] || ''
const selectedRegion = plan?.holidays_region?.includes('-') ? plan.holidays_region : ''
useEffect(() => {
if (!selectedCountry || !plan?.holidays_enabled) { setRegions([]); return }
setLoadingRegions(true)
const year = new Date().getFullYear()
apiClient.get(`/addons/vacay/holidays/${year}/${selectedCountry}`).then(r => {
const allCounties = new Set()
r.data.forEach(h => {
if (h.counties) h.counties.forEach(c => allCounties.add(c))
})
if (allCounties.size > 0) {
let subdivisionNames
try { subdivisionNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) } catch { /* */ }
const regionList = [...allCounties].sort().map(c => {
let label = c.split('-')[1] || c
// Try Intl for full subdivision name (not all browsers support subdivision codes)
// Fallback: use known mappings for DE
if (c.startsWith('DE-')) {
const deRegions = { BW:'Baden-Württemberg',BY:'Bayern',BE:'Berlin',BB:'Brandenburg',HB:'Bremen',HH:'Hamburg',HE:'Hessen',MV:'Mecklenburg-Vorpommern',NI:'Niedersachsen',NW:'Nordrhein-Westfalen',RP:'Rheinland-Pfalz',SL:'Saarland',SN:'Sachsen',ST:'Sachsen-Anhalt',SH:'Schleswig-Holstein',TH:'Thüringen' }
label = deRegions[c.split('-')[1]] || label
} else if (c.startsWith('CH-')) {
const chRegions = { AG:'Aargau',AI:'Appenzell Innerrhoden',AR:'Appenzell Ausserrhoden',BE:'Bern',BL:'Basel-Landschaft',BS:'Basel-Stadt',FR:'Freiburg',GE:'Genf',GL:'Glarus',GR:'Graubünden',JU:'Jura',LU:'Luzern',NE:'Neuenburg',NW:'Nidwalden',OW:'Obwalden',SG:'St. Gallen',SH:'Schaffhausen',SO:'Solothurn',SZ:'Schwyz',TG:'Thurgau',TI:'Tessin',UR:'Uri',VD:'Waadt',VS:'Wallis',ZG:'Zug',ZH:'Zürich' }
label = chRegions[c.split('-')[1]] || label
}
return { value: c, label }
})
setRegions(regionList)
} else {
setRegions([])
// If no regions, just set country code as region
if (plan.holidays_region !== selectedCountry) {
updatePlan({ holidays_region: selectedCountry })
}
}
}).catch(() => setRegions([])).finally(() => setLoadingRegions(false))
}, [selectedCountry, plan?.holidays_enabled])
if (!plan) return null
const toggle = (key) => updatePlan({ [key]: !plan[key] })
const handleCountryChange = (countryCode) => {
updatePlan({ holidays_region: countryCode })
}
const handleRegionChange = (regionCode) => {
updatePlan({ holidays_region: regionCode })
}
return (
<div className="space-y-5">
{/* Block weekends */}
<SettingToggle
icon={CalendarOff}
label={t('vacay.blockWeekends')}
hint={t('vacay.blockWeekendsHint')}
value={plan.block_weekends}
onChange={() => toggle('block_weekends')}
/>
{/* Carry-over */}
<SettingToggle
icon={ArrowRightLeft}
label={t('vacay.carryOver')}
hint={t('vacay.carryOverHint')}
value={plan.carry_over_enabled}
onChange={() => toggle('carry_over_enabled')}
/>
{/* Company holidays */}
<div>
<SettingToggle
icon={Building2}
label={t('vacay.companyHolidays')}
hint={t('vacay.companyHolidaysHint')}
value={plan.company_holidays_enabled}
onChange={() => toggle('company_holidays_enabled')}
/>
{plan.company_holidays_enabled && (
<div className="ml-7 mt-2">
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md" style={{ background: 'var(--bg-secondary)' }}>
<AlertCircle size={12} style={{ color: 'var(--text-faint)' }} />
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{t('vacay.companyHolidaysNoDeduct')}</span>
</div>
</div>
)}
</div>
{/* Public holidays */}
<div>
<SettingToggle
icon={Globe}
label={t('vacay.publicHolidays')}
hint={t('vacay.publicHolidaysHint')}
value={plan.holidays_enabled}
onChange={() => toggle('holidays_enabled')}
/>
{plan.holidays_enabled && (
<div className="ml-7 mt-2 space-y-2">
<CustomSelect
value={selectedCountry}
onChange={handleCountryChange}
options={countries}
placeholder={t('vacay.selectCountry')}
searchable
/>
{regions.length > 0 && (
<CustomSelect
value={selectedRegion}
onChange={handleRegionChange}
options={regions}
placeholder={t('vacay.selectRegion')}
searchable
/>
)}
</div>
)}
</div>
{/* Dissolve fusion */}
{isFused && (
<div className="pt-4 mt-2 border-t" style={{ borderColor: 'var(--border-secondary)' }}>
<div className="rounded-xl overflow-hidden" style={{ border: '1px solid rgba(239,68,68,0.2)' }}>
<div className="px-4 py-3 flex items-center gap-3" style={{ background: 'rgba(239,68,68,0.06)' }}>
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ background: 'rgba(239,68,68,0.1)' }}>
<Unlink size={16} className="text-red-500" />
</div>
<div>
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.dissolve')}</p>
<p className="text-[11px]" style={{ color: 'var(--text-faint)' }}>{t('vacay.dissolveHint')}</p>
</div>
</div>
<div className="px-4 py-3 flex items-center gap-2 flex-wrap" style={{ borderTop: '1px solid rgba(239,68,68,0.1)' }}>
{users.map(u => (
<div key={u.id} className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ background: 'var(--bg-secondary)' }}>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: u.color || '#6366f1' }} />
<span className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>{u.username}</span>
</div>
))}
</div>
<div className="px-4 py-3" style={{ borderTop: '1px solid rgba(239,68,68,0.1)' }}>
<button
onClick={async () => {
await dissolve()
toast.success(t('vacay.dissolved'))
onClose()
}}
className="w-full px-3 py-2 text-xs font-medium bg-red-500 hover:bg-red-600 text-white rounded-lg transition-colors"
>
{t('vacay.dissolveAction')}
</button>
</div>
</div>
</div>
)}
</div>
)
}
interface SettingToggleProps {
icon: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>
label: string
hint: string
value: boolean
onChange: (value: boolean) => void
}
function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingToggleProps) {
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<Icon size={15} className="shrink-0" style={{ color: 'var(--text-muted)' }} />
<div className="min-w-0">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
<p className="text-[11px]" style={{ color: 'var(--text-faint)' }}>{hint}</p>
</div>
</div>
<button onClick={onChange}
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: value ? 'var(--text-primary)' : 'var(--border-primary)' }}>
<span className="absolute left-1 h-4 w-4 rounded-full transition-transform duration-200"
style={{ background: 'var(--bg-card)', transform: value ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
)
}
+141
View File
@@ -0,0 +1,141 @@
import { useEffect, useState } from 'react'
import { Briefcase, Pencil } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import type { VacayStat } from '../../types'
interface VacayStatExtended extends VacayStat {
username: string
avatar_url: string | null
color: string | null
total_available: number
}
export default function VacayStats() {
const { t } = useTranslation()
const { stats, selectedYear, loadStats, updateVacationDays, isFused } = useVacayStore()
const { user: currentUser } = useAuthStore()
useEffect(() => { loadStats(selectedYear) }, [selectedYear])
return (
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center gap-1.5 mb-3">
<Briefcase size={13} style={{ color: 'var(--text-faint)' }} />
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>
{t('vacay.entitlement')} {selectedYear}
</span>
</div>
{stats.length === 0 ? (
<p className="text-[11px] text-center py-3" style={{ color: 'var(--text-faint)' }}>{t('vacay.noData')}</p>
) : (
<div className="space-y-2">
{stats.map(s => (
<StatCard
key={s.user_id}
stat={s}
isMe={s.user_id === currentUser?.id}
canEdit={s.user_id === currentUser?.id || isFused}
selectedYear={selectedYear}
onSave={updateVacationDays}
t={t}
/>
))}
</div>
)}
</div>
)
}
interface StatCardProps {
stat: VacayStatExtended
isMe: boolean
canEdit: boolean
selectedYear: number
onSave: (userId: number, year: number, days: number) => Promise<void>
t: (key: string) => string
}
function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardProps) {
const [editing, setEditing] = useState(false)
const [localDays, setLocalDays] = useState(s.vacation_days)
const pct = s.total_available > 0 ? Math.min(100, (s.used / s.total_available) * 100) : 0
// Sync local state when stats reload from server
useEffect(() => {
if (!editing) setLocalDays(s.vacation_days)
}, [s.vacation_days, editing])
const handleSave = () => {
setEditing(false)
const days = parseInt(localDays)
if (!isNaN(days) && days >= 0 && days <= 365 && days !== s.vacation_days) {
onSave(selectedYear, days, s.user_id)
}
}
return (
<div className="rounded-lg p-2.5 space-y-2" style={{ border: '1px solid var(--border-secondary)' }}>
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: s.person_color }} />
<span className="text-xs font-semibold flex-1 truncate" style={{ color: 'var(--text-primary)' }}>
{s.person_name}
{isMe && <span style={{ color: 'var(--text-faint)' }}> ({t('vacay.you')})</span>}
</span>
<span className="text-[10px] tabular-nums" style={{ color: 'var(--text-faint)' }}>{s.used}/{s.total_available}</span>
</div>
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--bg-secondary)' }}>
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${pct}%`, backgroundColor: s.person_color }} />
</div>
<div className="grid grid-cols-3 gap-1.5">
{/* Days — editable */}
<div
className="rounded-md px-2 py-2 group/days"
style={{
background: canEdit ? 'var(--bg-card)' : 'var(--bg-secondary)',
border: canEdit ? '1px solid var(--border-primary)' : '1px solid transparent',
cursor: canEdit ? 'pointer' : 'default',
}}
onClick={() => { if (canEdit && !editing) setEditing(true) }}
>
<div className="text-[10px] mb-1" style={{ color: 'var(--text-faint)', height: 14, lineHeight: '14px' }}>
{t('vacay.entitlementDays')} {canEdit && !editing && <Pencil size={9} className="inline opacity-0 group-hover/days:opacity-100 transition-opacity" style={{ color: 'var(--text-faint)', verticalAlign: 'middle' }} />}
</div>
{editing ? (
<input
type="number"
value={localDays}
onChange={e => setLocalDays(e.target.value)}
onBlur={handleSave}
onKeyDown={e => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') { setEditing(false); setLocalDays(s.vacation_days) } }}
autoFocus
className="w-full bg-transparent text-sm font-bold outline-none p-0 m-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
style={{ color: 'var(--text-primary)', height: 18, lineHeight: '18px' }}
/>
) : (
<div className="text-sm font-bold" style={{ color: 'var(--text-primary)', height: 18, lineHeight: '18px' }}>{s.vacation_days}</div>
)}
</div>
{/* Used */}
<div className="rounded-md px-2 py-2" style={{ background: 'var(--bg-secondary)' }}>
<div className="text-[10px] mb-1" style={{ color: 'var(--text-faint)', height: 14, lineHeight: '14px' }}>{t('vacay.used')}</div>
<div className="text-sm font-bold" style={{ color: 'var(--text-primary)', height: 18, lineHeight: '18px' }}>{s.used}</div>
</div>
{/* Remaining */}
<div className="rounded-md px-2 py-2" style={{ background: 'var(--bg-secondary)' }}>
<div className="text-[10px] mb-1" style={{ color: 'var(--text-faint)', height: 14, lineHeight: '14px' }}>{t('vacay.remaining')}</div>
<div className="text-sm font-bold" style={{ color: s.remaining < 0 ? '#ef4444' : s.remaining <= 3 ? '#f59e0b' : '#22c55e', height: 18, lineHeight: '18px' }}>
{s.remaining}
</div>
</div>
</div>
{s.carried_over > 0 && (
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.15)' }}>
<span className="text-[10px]" style={{ color: '#d97706' }}>+{s.carried_over} {t('vacay.carriedOver', { year: selectedYear - 1 })}</span>
</div>
)}
</div>
)
}
+131
View File
@@ -0,0 +1,131 @@
const BUNDESLAENDER: Record<string, string> = {
BW: 'Baden-Württemberg',
BY: 'Bayern',
BE: 'Berlin',
BB: 'Brandenburg',
HB: 'Bremen',
HH: 'Hamburg',
HE: 'Hessen',
MV: 'Mecklenburg-Vorpommern',
NI: 'Niedersachsen',
NW: 'Nordrhein-Westfalen',
RP: 'Rheinland-Pfalz',
SL: 'Saarland',
SN: 'Sachsen',
ST: 'Sachsen-Anhalt',
SH: 'Schleswig-Holstein',
TH: 'Thüringen',
}
function easterSunday(year: number): Date {
const a = year % 19
const b = Math.floor(year / 100)
const c = year % 100
const d = Math.floor(b / 4)
const e = b % 4
const f = Math.floor((b + 8) / 25)
const g = Math.floor((b - f + 1) / 3)
const h = (19 * a + b - d - g + 15) % 30
const i = Math.floor(c / 4)
const k = c % 4
const l = (32 + 2 * e + 2 * i - h - k) % 7
const m = Math.floor((a + 11 * h + 22 * l) / 451)
const month = Math.floor((h + l - 7 * m + 114) / 31)
const day = ((h + l - 7 * m + 114) % 31) + 1
return new Date(year, month - 1, day)
}
function addDays(date: Date, days: number): Date {
const d = new Date(date)
d.setDate(d.getDate() + days)
return d
}
function fmt(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
export function getHolidays(year: number, bundesland: string = 'NW'): Record<string, string> {
const easter = easterSunday(year)
const holidays: Record<string, string> = {}
holidays[`${year}-01-01`] = 'Neujahr'
holidays[`${year}-05-01`] = 'Tag der Arbeit'
holidays[`${year}-10-03`] = 'Tag der Deutschen Einheit'
holidays[`${year}-12-25`] = '1. Weihnachtsfeiertag'
holidays[`${year}-12-26`] = '2. Weihnachtsfeiertag'
holidays[fmt(addDays(easter, -2))] = 'Karfreitag'
holidays[fmt(addDays(easter, 1))] = 'Ostermontag'
holidays[fmt(addDays(easter, 39))] = 'Christi Himmelfahrt'
holidays[fmt(addDays(easter, 50))] = 'Pfingstmontag'
const bl = bundesland.toUpperCase()
if (['BW', 'BY', 'ST'].includes(bl)) {
holidays[`${year}-01-06`] = 'Heilige Drei Könige'
}
if (['BE', 'MV'].includes(bl)) {
holidays[`${year}-03-08`] = 'Internationaler Frauentag'
}
if (['BW', 'BY', 'HE', 'NW', 'RP', 'SL'].includes(bl)) {
holidays[fmt(addDays(easter, 60))] = 'Fronleichnam'
}
if (['SL'].includes(bl)) {
holidays[`${year}-08-15`] = 'Mariä Himmelfahrt'
}
if (bl === 'TH') {
holidays[`${year}-09-20`] = 'Weltkindertag'
}
if (['BB', 'HB', 'HH', 'MV', 'NI', 'SN', 'ST', 'SH', 'TH'].includes(bl)) {
holidays[`${year}-10-31`] = 'Reformationstag'
}
if (['BW', 'BY', 'NW', 'RP', 'SL'].includes(bl)) {
holidays[`${year}-11-01`] = 'Allerheiligen'
}
if (bl === 'SN') {
const nov23 = new Date(year, 10, 23)
const bbt = new Date(nov23)
while (bbt.getDay() !== 3) bbt.setDate(bbt.getDate() - 1)
holidays[fmt(bbt)] = 'Buß- und Bettag'
}
return holidays
}
export function isWeekend(dateStr: string): boolean {
const d = new Date(dateStr + 'T00:00:00')
const day = d.getDay()
return day === 0 || day === 6
}
export function getWeekday(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00')
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getDay()]
}
export function getWeekdayFull(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00')
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getDay()]
}
export function daysInMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate()
}
export function formatDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
}
export { BUNDESLAENDER }
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import { useState, useEffect } from 'react'
import { Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind } from 'lucide-react'
import { weatherApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
@@ -15,7 +15,12 @@ const WEATHER_ICON_MAP = {
Haze: Wind,
}
function WeatherIcon({ main, size = 13 }) {
interface WeatherIconProps {
main: string
size?: number
}
function WeatherIcon({ main, size = 13 }: WeatherIconProps) {
const Icon = WEATHER_ICON_MAP[main] || Cloud
return <Icon size={size} strokeWidth={1.8} />
}
@@ -32,7 +37,14 @@ function setWeatherCache(key, value) {
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
}
export default function WeatherWidget({ lat, lng, date, compact = false }) {
interface WeatherWidgetProps {
lat: number | null
lng: number | null
date: string
compact?: boolean
}
export default function WeatherWidget({ lat, lng, date, compact = false }: WeatherWidgetProps) {
const [weather, setWeather] = useState(null)
const [loading, setLoading] = useState(false)
const [failed, setFailed] = useState(false)
@@ -46,21 +58,35 @@ export default function WeatherWidget({ lat, lng, date, compact = false }) {
const cached = getWeatherCache(cacheKey)
if (cached !== undefined) {
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
}
setLoading(true)
weatherApi.get(lat, lng, date)
.then(data => {
if (data.error || data.temp === undefined) {
setWeatherCache(cacheKey, null)
setFailed(true)
} else {
setWeatherCache(cacheKey, data)
setWeather(data)
}
})
.catch(() => { setWeatherCache(cacheKey, null); setFailed(true) })
.catch(() => { setFailed(true) })
.finally(() => setLoading(false))
}, [lat, lng, date])
@@ -83,20 +109,21 @@ export default function WeatherWidget({ lat, lng, date, compact = false }) {
const rawTemp = weather.temp
const temp = rawTemp !== undefined ? Math.round(isFahrenheit ? rawTemp * 9/5 + 32 : rawTemp) : null
const unit = isFahrenheit ? '°F' : '°C'
const isClimate = weather.type === 'climate'
if (compact) {
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} />
{temp !== null && <span>{temp}{unit}</span>}
{temp !== null && <span>{isClimate ? 'Ø ' : ''}{temp}{unit}</span>}
</span>
)
}
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} />
{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>}
</div>
)
@@ -0,0 +1,101 @@
import React, { useEffect, useCallback } from 'react'
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
interface ConfirmDialogProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
title?: string
message?: string
confirmLabel?: string
cancelLabel?: string
danger?: boolean
}
export default function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmLabel,
cancelLabel,
danger = true,
}: ConfirmDialogProps) {
const { t } = useTranslation()
const handleEsc = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleEsc)
}
return () => document.removeEventListener('keydown', handleEsc)
}, [isOpen, handleEsc])
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center px-4"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
onClick={onClose}
>
<div
className="rounded-2xl shadow-2xl w-full max-w-sm p-6"
style={{
animation: 'modalIn 0.2s ease-out forwards',
background: 'var(--bg-card)',
}}
onClick={e => e.stopPropagation()}
>
<div className="flex items-start gap-4">
{danger && (
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-red-600" />
</div>
)}
<div className="flex-1">
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>
{title || t('common.confirm')}
</h3>
<p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)' }}>
{message}
</p>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
style={{
color: 'var(--text-secondary)',
border: '1px solid var(--border-secondary)',
}}
>
{cancelLabel || t('common.cancel')}
</button>
<button
onClick={() => { onConfirm(); onClose() }}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors text-white ${
danger ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'
}`}
>
{confirmLabel || t('common.delete')}
</button>
</div>
</div>
<style>{`
@keyframes modalIn {
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
`}</style>
</div>
)
}
@@ -0,0 +1,102 @@
import React, { useState, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import { LucideIcon } from 'lucide-react'
interface MenuItem {
label?: string
icon?: LucideIcon
onClick?: () => void
danger?: boolean
divider?: boolean
}
interface MenuState {
x: number
y: number
items: MenuItem[]
}
export function useContextMenu() {
const [menu, setMenu] = useState<MenuState | null>(null)
const open = (e: React.MouseEvent, items: MenuItem[]) => {
e.preventDefault()
e.stopPropagation()
setMenu({ x: e.clientX, y: e.clientY, items })
}
const close = () => setMenu(null)
return { menu, open, close }
}
interface ContextMenuProps {
menu: MenuState | null
onClose: () => void
}
export function ContextMenu({ menu, onClose }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(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])
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
)
}
@@ -3,24 +3,30 @@ import ReactDOM from 'react-dom'
import { Calendar, Clock, ChevronLeft, ChevronRight, ChevronUp, ChevronDown } from 'lucide-react'
import { useTranslation } from '../../i18n'
function daysInMonth(year, month) { return new Date(year, month + 1, 0).getDate() }
function getWeekday(year, month, day) { return new Date(year, month, day).getDay() }
function daysInMonth(year: number, month: number): number { return new Date(year, month + 1, 0).getDate() }
function getWeekday(year: number, month: number, day: number): number { return new Date(year, month, day).getDay() }
// ── Datum-Only Picker ────────────────────────────────────────────────────────
export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
interface CustomDatePickerProps {
value: string
onChange: (value: string) => void
placeholder?: string
style?: React.CSSProperties
}
export function CustomDatePicker({ value, onChange, placeholder, style = {} }: CustomDatePickerProps) {
const { locale, t } = useTranslation()
const [open, setOpen] = useState(false)
const ref = useRef(null)
const dropRef = useRef(null)
const ref = useRef<HTMLDivElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
const parsed = value ? new Date(value + 'T00:00:00') : null
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || new Date().getFullYear())
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() ?? new Date().getMonth())
useEffect(() => {
const handler = (e) => {
if (ref.current?.contains(e.target)) return
if (dropRef.current?.contains(e.target)) return
const handler = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return
if (dropRef.current?.contains(e.target as Node)) return
setOpen(false)
}
if (open) document.addEventListener('mousedown', handler)
@@ -36,12 +42,12 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
const monthLabel = new Date(viewYear, viewMonth).toLocaleDateString(locale, { month: 'long', year: 'numeric' })
const days = daysInMonth(viewYear, viewMonth)
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7 // Mo=0
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7
const weekdays = Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' }))
const displayValue = parsed ? parsed.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' }) : null
const selectDay = (day) => {
const selectDay = (day: number) => {
const y = String(viewYear)
const m = String(viewMonth + 1).padStart(2, '0')
const d = String(day).padStart(2, '0')
@@ -51,7 +57,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
const selectedDay = parsed && parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth ? parsed.getDate() : null
const today = new Date()
const isToday = (d) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
const isToday = (d: number) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
return (
<div ref={ref} style={{ position: 'relative', ...style }}>
@@ -67,17 +73,29 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}>
<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>
{open && ReactDOM.createPortal(
<div ref={dropRef} style={{
position: 'fixed',
top: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(),
left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(),
...(() => {
const r = ref.current?.getBoundingClientRect()
if (!r) return { top: 0, left: 0 }
const w = 268, pad = 8
const vw = window.innerWidth
const vh = window.innerHeight
let left = r.left
let top = r.bottom + 4
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
if (top + 320 > vh) top = Math.max(pad, r.top - 320)
if (vw < 360) left = Math.max(pad, (vw - w) / 2)
return { top, left }
})(),
zIndex: 99999,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)',
borderRadius: 14, boxShadow: '0 8px 32px rgba(0,0,0,0.12)', padding: 12, width: 268,
maxWidth: 'calc(100vw - 16px)',
animation: 'selectIn 0.15s ease-out',
backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
}}>
@@ -146,18 +164,23 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
)
}
// ── DateTime Picker (Datum + Uhrzeit kombiniert) ─────────────────────────────
export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }) {
interface CustomDateTimePickerProps {
value: string
onChange: (value: string) => void
placeholder?: string
style?: React.CSSProperties
}
export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }: CustomDateTimePickerProps) {
const { locale } = useTranslation()
// value = "2024-03-15T14:30" oder ""
const [datePart, timePart] = (value || '').split('T')
const handleDateChange = (d) => {
const handleDateChange = (d: string) => {
onChange(d ? `${d}T${timePart || '12:00'}` : '')
}
const handleTimeChange = (t) => {
const handleTimeChange = (t: string) => {
const d = datePart || new Date().toISOString().split('T')[0]
onChange(t ? `${d}T${t}` : `${d}T00:00`)
onChange(t ? `${d}T${t}` : d)
}
return (
@@ -170,5 +193,4 @@ export function CustomDateTimePicker({ value, onChange, placeholder, style = {}
)
}
// Inline re-export for convenience
import CustomTimePicker from './CustomTimePicker'
@@ -2,29 +2,48 @@ import React, { useState, useRef, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { ChevronDown, Check } from 'lucide-react'
interface SelectOption {
value: string
label: string
icon?: React.ReactNode
isHeader?: boolean
searchLabel?: string
groupLabel?: string
}
interface CustomSelectProps {
value: string
onChange: (value: string) => void
options?: SelectOption[]
placeholder?: string
searchable?: boolean
style?: React.CSSProperties
size?: 'sm' | 'md'
}
export default function CustomSelect({
value,
onChange,
options = [], // [{ value, label, icon? }]
options = [],
placeholder = '',
searchable = false,
style = {},
size = 'md', // 'sm' | 'md'
}) {
size = 'md',
}: CustomSelectProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const ref = useRef(null)
const dropRef = useRef(null)
const searchRef = useRef(null)
const ref = useRef<HTMLDivElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open && searchable && searchRef.current) searchRef.current.focus()
}, [open, searchable])
useEffect(() => {
const handleClick = (e) => {
if (ref.current?.contains(e.target)) return
if (dropRef.current?.contains(e.target)) return
const handleClick = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return
if (dropRef.current?.contains(e.target as Node)) return
setOpen(false)
}
if (open) document.addEventListener('mousedown', handleClick)
@@ -33,7 +52,28 @@ export default function CustomSelect({
const selected = options.find(o => o.value === value)
const filtered = searchable && search
? options.filter(o => o.label.toLowerCase().includes(search.toLowerCase()))
? (() => {
const q = search.toLowerCase()
const result: SelectOption[] = []
let currentHeader: SelectOption | null = null
let headerAdded = false
for (const o of options) {
if (o.isHeader) {
currentHeader = o
headerAdded = false
continue
}
const haystack = [o.label, o.searchLabel, o.groupLabel].filter(Boolean).join(' ').toLowerCase()
if (haystack.includes(q)) {
if (currentHeader && !headerAdded) {
result.push(currentHeader)
headerAdded = true
}
result.push(o)
}
}
return result
})()
: options
const sm = size === 'sm'
@@ -51,7 +91,7 @@ export default function CustomSelect({
background: 'var(--bg-input)', color: 'var(--text-primary)',
fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
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)'}
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
@@ -105,6 +145,17 @@ export default function CustomSelect({
<div style={{ padding: '10px 12px', fontSize: 12, color: 'var(--text-faint)', textAlign: 'center' }}></div>
) : (
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
return (
<button
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'
import { Clock, ChevronUp, ChevronDown } from 'lucide-react'
import { useSettingsStore } from '../../store/settingsStore'
function formatDisplay(val, is12h) {
function formatDisplay(val: string, is12h: boolean): string {
if (!val) return ''
const [h, m] = val.split(':').map(Number)
if (isNaN(h) || isNaN(m)) return val
@@ -13,28 +13,35 @@ function formatDisplay(val, is12h) {
return `${h12}:${String(m).padStart(2, '0')} ${period}`
}
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {} }) {
interface CustomTimePickerProps {
value: string
onChange: (value: string) => void
placeholder?: string
style?: React.CSSProperties
}
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {} }: CustomTimePickerProps) {
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const [open, setOpen] = useState(false)
const [inputFocused, setInputFocused] = useState(false)
const ref = useRef(null)
const dropRef = useRef(null)
const ref = useRef<HTMLDivElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
const [h, m] = (value || '').split(':').map(Number)
const hour = isNaN(h) ? null : h
const minute = isNaN(m) ? null : m
useEffect(() => {
const handler = (e) => {
if (ref.current?.contains(e.target)) return
if (dropRef.current?.contains(e.target)) return
const handler = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return
if (dropRef.current?.contains(e.target as Node)) return
setOpen(false)
}
if (open) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const update = (newH, newM) => {
const update = (newH: number, newM: number) => {
const hh = String(Math.max(0, Math.min(23, newH))).padStart(2, '0')
const mm = String(Math.max(0, Math.min(59, newM))).padStart(2, '0')
onChange(`${hh}:${mm}`)
@@ -53,16 +60,15 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
update(newH, newM)
}
const btnStyle = {
const btnStyle: React.CSSProperties = {
background: 'none', border: 'none', cursor: 'pointer', padding: 2,
color: 'var(--text-faint)', display: 'flex', borderRadius: 4,
transition: 'color 0.15s',
}
const handleInput = (e) => {
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value
onChange(raw)
// Auto-format: wenn "1430" → "14:30"
const clean = raw.replace(/[^0-9:]/g, '')
if (/^\d{2}:\d{2}$/.test(clean)) onChange(clean)
else if (/^\d{4}$/.test(clean)) onChange(clean.slice(0, 2) + ':' + clean.slice(2))
@@ -85,6 +91,9 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
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))))
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')
}
}
@@ -136,7 +145,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
animation: 'selectIn 0.15s ease-out',
backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
}}>
{/* Stunden */}
{/* Hours */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<button type="button" onClick={incHour} style={btnStyle}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
@@ -160,7 +169,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--text-faint)', marginTop: -2 }}>:</span>
{/* Minuten */}
{/* Minutes */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<button type="button" onClick={incMin} style={btnStyle}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
@@ -1,7 +1,7 @@
import React, { useEffect, useCallback, useRef } from 'react'
import { X } from 'lucide-react'
const sizeClasses = {
const sizeClasses: Record<string, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
@@ -9,6 +9,16 @@ const sizeClasses = {
'2xl': 'max-w-4xl',
}
interface ModalProps {
isOpen: boolean
onClose: () => void
title?: React.ReactNode
children?: React.ReactNode
size?: string
footer?: React.ReactNode
hideCloseButton?: boolean
}
export default function Modal({
isOpen,
onClose,
@@ -17,8 +27,8 @@ export default function Modal({
size = 'md',
footer,
hideCloseButton = false,
}) {
const handleEsc = useCallback((e) => {
}: ModalProps) {
const handleEsc = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}, [onClose])
@@ -33,14 +43,14 @@ export default function Modal({
}
}, [isOpen, handleEsc])
const mouseDownTarget = useRef(null)
const mouseDownTarget = useRef<EventTarget | null>(null)
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center px-4 modal-backdrop"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20 }}
className="fixed inset-0 z-50 flex items-start sm:items-center justify-center px-4 modal-backdrop"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20, overflow: 'hidden' }}
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
@@ -50,7 +60,7 @@ export default function Modal({
<div
className={`
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
flex flex-col max-h-[90vh]
flex flex-col max-h-[calc(100vh-90px)]
animate-in fade-in zoom-in-95 duration-200
`}
style={{
@@ -1,25 +1,37 @@
import React, { useState, useEffect } from 'react'
import { mapsApi } from '../../api/client'
import { getCategoryIcon } from './categoryIcons'
import type { Place } from '../../types'
const googlePhotoCache = new Map()
interface Category {
color?: string
icon?: string
}
export default function PlaceAvatar({ place, size = 32, category }) {
const [photoSrc, setPhotoSrc] = useState(place.image_url || null)
interface PlaceAvatarProps {
place: Pick<Place, 'id' | 'name' | 'image_url' | 'google_place_id'>
size?: number
category?: Category | null
}
const googlePhotoCache = new Map<string, string>()
export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
useEffect(() => {
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)) {
setPhotoSrc(googlePhotoCache.get(place.google_place_id))
setPhotoSrc(googlePhotoCache.get(place.google_place_id)!)
return
}
mapsApi.placePhoto(place.google_place_id)
.then(data => {
.then((data: { photoUrl?: string }) => {
if (data.photoUrl) {
googlePhotoCache.set(place.google_place_id, data.photoUrl)
googlePhotoCache.set(place.google_place_id!, data.photoUrl)
setPhotoSrc(data.photoUrl)
}
})
@@ -30,7 +42,7 @@ export default function PlaceAvatar({ place, size = 32, category }) {
const IconComp = getCategoryIcon(category?.icon)
const iconSize = Math.round(size * 0.46)
const containerStyle = {
const containerStyle: React.CSSProperties = {
width: size, height: size,
borderRadius: '50%',
overflow: 'hidden',
@@ -1,14 +1,28 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
const ToastContext = createContext(null)
type ToastType = 'success' | 'error' | 'warning' | 'info'
interface Toast {
id: number
message: string
type: ToastType
duration: number
removing: boolean
}
declare global {
interface Window {
__addToast?: (message: string, type?: ToastType, duration?: number) => number
}
}
let toastIdCounter = 0
export function ToastContainer() {
const [toasts, setToasts] = useState([])
const [toasts, setToasts] = useState<Toast[]>([])
const addToast = useCallback((message, type = 'info', duration = 3000) => {
const addToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
const id = ++toastIdCounter
setToasts(prev => [...prev, { id, message, type, duration, removing: false }])
@@ -24,27 +38,26 @@ export function ToastContainer() {
return id
}, [])
const removeToast = useCallback((id) => {
const removeToast = useCallback((id: number) => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 300)
}, [])
// Make addToast globally accessible
useEffect(() => {
window.__addToast = addToast
return () => { delete window.__addToast }
}, [addToast])
const icons = {
const icons: Record<ToastType, React.ReactNode> = {
success: <CheckCircle className="w-5 h-5 text-emerald-500 flex-shrink-0" />,
error: <XCircle className="w-5 h-5 text-red-500 flex-shrink-0" />,
warning: <AlertCircle className="w-5 h-5 text-amber-500 flex-shrink-0" />,
info: <Info className="w-5 h-5 text-blue-500 flex-shrink-0" />,
}
const bgColors = {
const bgColors: Record<ToastType, string> = {
success: 'bg-white border-l-4 border-emerald-500',
error: 'bg-white border-l-4 border-red-500',
warning: 'bg-white border-l-4 border-amber-500',
@@ -78,17 +91,17 @@ export function ToastContainer() {
}
export const useToast = () => {
const show = useCallback((message, type, duration) => {
const show = useCallback((message: string, type: ToastType, duration?: number) => {
if (window.__addToast) {
window.__addToast(message, type, duration)
}
}, [])
return {
success: (message, duration) => show(message, 'success', duration),
error: (message, duration) => show(message, 'error', duration),
warning: (message, duration) => show(message, 'warning', duration),
info: (message, duration) => show(message, 'info', duration),
success: (message: string, duration?: number) => show(message, 'success', duration),
error: (message: string, duration?: number) => show(message, 'error', duration),
warning: (message: string, duration?: number) => show(message, 'warning', duration),
info: (message: string, duration?: number) => show(message, 'info', duration),
}
}
@@ -1,43 +0,0 @@
import {
MapPin, Building2, BedDouble, UtensilsCrossed, Landmark, ShoppingBag,
Bus, Train, Car, Plane, Ship, Bike,
Activity, Dumbbell, Mountain, Tent, Anchor,
Coffee, Beer, Wine, Utensils,
Camera, Music, Theater, Ticket,
TreePine, Waves, Leaf, Flower2, Sun,
Globe, Compass, Flag, Navigation, Map,
Church, Library, Store, Home, Cross,
Heart, Star, CreditCard, Wifi,
Luggage, Backpack, Zap,
} from 'lucide-react'
export const CATEGORY_ICON_MAP = {
MapPin, Building2, BedDouble, UtensilsCrossed, Landmark, ShoppingBag,
Bus, Train, Car, Plane, Ship, Bike,
Activity, Dumbbell, Mountain, Tent, Anchor,
Coffee, Beer, Wine, Utensils,
Camera, Music, Theater, Ticket,
TreePine, Waves, Leaf, Flower2, Sun,
Globe, Compass, Flag, Navigation, Map,
Church, Library, Store, Home, Cross,
Heart, Star, CreditCard, Wifi,
Luggage, Backpack, Zap,
}
export const ICON_LABELS = {
MapPin: 'Pin', Building2: 'Gebäude', BedDouble: 'Hotel', UtensilsCrossed: 'Restaurant',
Landmark: 'Sehenswürdigkeit', ShoppingBag: 'Shopping', Bus: 'Bus', Train: 'Zug',
Car: 'Auto', Plane: 'Flugzeug', Ship: 'Schiff', Bike: 'Fahrrad',
Activity: 'Aktivität', Dumbbell: 'Fitness', Mountain: 'Berg', Tent: 'Camping',
Anchor: 'Hafen', Coffee: 'Café', Beer: 'Bar', Wine: 'Wein', Utensils: 'Essen',
Camera: 'Foto', Music: 'Musik', Theater: 'Theater', Ticket: 'Events',
TreePine: 'Natur', Waves: 'Strand', Leaf: 'Grün', Flower2: 'Garten', Sun: 'Sonne',
Globe: 'Welt', Compass: 'Erkundung', Flag: 'Flagge', Navigation: 'Navigation', Map: 'Karte',
Church: 'Kirche', Library: 'Museum', Store: 'Markt', Home: 'Unterkunft', Cross: 'Medizin',
Heart: 'Favorit', Star: 'Top', CreditCard: 'Bank', Wifi: 'Internet',
Luggage: 'Gepäck', Backpack: 'Rucksack', Zap: 'Abenteuer',
}
export function getCategoryIcon(iconName) {
return CATEGORY_ICON_MAP[iconName] || MapPin
}
@@ -0,0 +1,44 @@
import {
MapPin, Building2, BedDouble, UtensilsCrossed, Landmark, ShoppingBag,
Bus, Train, Car, Plane, Ship, Bike,
Activity, Dumbbell, Mountain, Tent, Anchor,
Coffee, Beer, Wine, Utensils,
Camera, Music, Theater, Ticket,
TreePine, Waves, Leaf, Flower2, Sun,
Globe, Compass, Flag, Navigation, Map,
Church, Library, Store, Home, Cross,
Heart, Star, CreditCard, Wifi,
Luggage, Backpack, Zap,
LucideIcon,
} from 'lucide-react'
export const CATEGORY_ICON_MAP: Record<string, LucideIcon> = {
MapPin, Building2, BedDouble, UtensilsCrossed, Landmark, ShoppingBag,
Bus, Train, Car, Plane, Ship, Bike,
Activity, Dumbbell, Mountain, Tent, Anchor,
Coffee, Beer, Wine, Utensils,
Camera, Music, Theater, Ticket,
TreePine, Waves, Leaf, Flower2, Sun,
Globe, Compass, Flag, Navigation, Map,
Church, Library, Store, Home, Cross,
Heart, Star, CreditCard, Wifi,
Luggage, Backpack, Zap,
}
export const ICON_LABELS: Record<string, string> = {
MapPin: 'Pin', Building2: 'Building', BedDouble: 'Hotel', UtensilsCrossed: 'Restaurant',
Landmark: 'Attraction', ShoppingBag: 'Shopping', Bus: 'Bus', Train: 'Train',
Car: 'Car', Plane: 'Plane', Ship: 'Ship', Bike: 'Bicycle',
Activity: 'Activity', Dumbbell: 'Fitness', Mountain: 'Mountain', Tent: 'Camping',
Anchor: 'Harbor', Coffee: 'Cafe', Beer: 'Bar', Wine: 'Wine', Utensils: 'Food',
Camera: 'Photo', Music: 'Music', Theater: 'Theater', Ticket: 'Events',
TreePine: 'Nature', Waves: 'Beach', Leaf: 'Green', Flower2: 'Garden', Sun: 'Sun',
Globe: 'World', Compass: 'Explore', Flag: 'Flag', Navigation: 'Navigation', Map: 'Map',
Church: 'Church', Library: 'Museum', Store: 'Market', Home: 'Accommodation', Cross: 'Medicine',
Heart: 'Favorite', Star: 'Top', CreditCard: 'Bank', Wifi: 'Internet',
Luggage: 'Luggage', Backpack: 'Backpack', Zap: 'Adventure',
}
export function getCategoryIcon(iconName: string | null | undefined): LucideIcon {
return (iconName && CATEGORY_ICON_MAP[iconName]) || MapPin
}
+78
View File
@@ -0,0 +1,78 @@
import { useState, useRef } from 'react'
import { useTripStore } from '../store/tripStore'
import { useToast } from '../components/shared/Toast'
import type { MergedItem, DayNotesMap, DayNote } from '../types'
interface NoteUiState {
mode: 'add' | 'edit'
noteId?: number
text: string
time: string
icon: string
sortOrder?: number
}
interface NoteUiMap {
[dayId: string]: NoteUiState
}
export function useDayNotes(tripId: number | string) {
const [noteUi, setNoteUi] = useState<NoteUiMap>({})
const noteInputRef = useRef<HTMLInputElement | null>(null)
const tripStore = useTripStore()
const toast = useToast()
const dayNotes: DayNotesMap = tripStore.dayNotes || {}
const openAddNote = (dayId: number, getMergedItems: (dayId: number) => MergedItem[], expandDay?: (dayId: number) => void) => {
const merged = getMergedItems(dayId)
const maxKey = merged.length > 0 ? Math.max(...merged.map((i) => i.sortKey)) : -1
setNoteUi((prev) => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', icon: 'FileText', sortOrder: maxKey + 1 } }))
expandDay?.(dayId)
setTimeout(() => noteInputRef.current?.focus(), 50)
}
const openEditNote = (dayId: number, note: DayNote) => {
setNoteUi((prev) => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '', icon: note.icon || 'FileText' } }))
setTimeout(() => noteInputRef.current?.focus(), 50)
}
const cancelNote = (dayId: number) => {
setNoteUi((prev) => { const n = { ...prev }; delete n[dayId]; return n })
}
const saveNote = async (dayId: number) => {
const ui = noteUi[dayId]
if (!ui?.text?.trim()) return
try {
if (ui.mode === 'add') {
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText', sort_order: ui.sortOrder })
} else {
await tripStore.updateDayNote(tripId, dayId, ui.noteId!, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText' })
}
cancelNote(dayId)
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}
const deleteNote = async (dayId: number, noteId: number) => {
try { await tripStore.deleteDayNote(tripId, dayId, noteId) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}
const moveNote = async (dayId: number, noteId: number, direction: 'up' | 'down', getMergedItems: (dayId: number) => MergedItem[]) => {
const merged = getMergedItems(dayId)
const idx = merged.findIndex((i) => i.type === 'note' && (i.data as DayNote).id === noteId)
if (idx === -1) return
let newSortOrder: number
if (direction === 'up') {
if (idx === 0) return
newSortOrder = idx >= 2 ? (merged[idx - 2].sortKey + merged[idx - 1].sortKey) / 2 : merged[idx - 1].sortKey - 1
} else {
if (idx >= merged.length - 1) return
newSortOrder = idx < merged.length - 2 ? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2 : merged[idx + 1].sortKey + 1
}
try { await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder }) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}
return { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote, openEditNote, cancelNote, saveNote, deleteNote, moveNote }
}
+18
View File
@@ -0,0 +1,18 @@
import { useState, useCallback } from 'react'
export function usePlaceSelection() {
const [selectedPlaceId, _setSelectedPlaceId] = useState<number | null>(null)
const [selectedAssignmentId, setSelectedAssignmentId] = useState<number | null>(null)
const setSelectedPlaceId = useCallback((placeId: number | null) => {
_setSelectedPlaceId(placeId)
setSelectedAssignmentId(null)
}, [])
const selectAssignment = useCallback((assignmentId: number | null, placeId: number | null) => {
setSelectedAssignmentId(assignmentId)
_setSelectedPlaceId(placeId)
}, [])
return { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment }
}
+45
View File
@@ -0,0 +1,45 @@
import { useState, useEffect, useRef } from 'react'
const MIN_SIDEBAR = 200
const MAX_SIDEBAR = 520
export function useResizablePanels() {
const [leftWidth, setLeftWidth] = useState<number>(() => parseInt(localStorage.getItem('sidebarLeftWidth') || '') || 340)
const [rightWidth, setRightWidth] = useState<number>(() => parseInt(localStorage.getItem('sidebarRightWidth') || '') || 300)
const [leftCollapsed, setLeftCollapsed] = useState(false)
const [rightCollapsed, setRightCollapsed] = useState(false)
const isResizingLeft = useRef(false)
const isResizingRight = useRef(false)
useEffect(() => {
const onMove = (e: MouseEvent) => {
if (isResizingLeft.current) {
const w = Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, e.clientX - 10))
setLeftWidth(w)
localStorage.setItem('sidebarLeftWidth', String(w))
}
if (isResizingRight.current) {
const w = Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, window.innerWidth - e.clientX - 10))
setRightWidth(w)
localStorage.setItem('sidebarRightWidth', String(w))
}
}
const onUp = () => {
isResizingLeft.current = false
isResizingRight.current = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
return () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
}, [])
const startResizeLeft = () => { isResizingLeft.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }
const startResizeRight = () => { isResizingRight.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }
return { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight }
}
+44
View File
@@ -0,0 +1,44 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useSettingsStore } from '../store/settingsStore'
import { calculateSegments } from '../components/Map/RouteCalculator'
import type { TripStoreState } from '../store/tripStore'
import type { RouteSegment, RouteResult } from '../types'
/**
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
* day assignments, draws a straight-line route, and optionally fetches per-segment
* driving/walking durations via OSRM. Aborts in-flight requests when the day changes.
*/
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null) {
const [route, setRoute] = useState<[number, number][] | null>(null)
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
const routeAbortRef = useRef<AbortController | null>(null)
const updateRouteForDay = useCallback(async (dayId: number | null) => {
if (routeAbortRef.current) routeAbortRef.current.abort()
if (!dayId) { setRoute(null); setRouteSegments([]); return }
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)
if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
setRoute(waypoints.map((p) => [p.lat!, p.lng!]))
if (!routeCalcEnabled) { setRouteSegments([]); return }
const controller = new AbortController()
routeAbortRef.current = controller
try {
const segments = await calculateSegments(waypoints as { lat: number; lng: number }[], { signal: controller.signal })
if (!controller.signal.aborted) setRouteSegments(segments)
} catch (err: unknown) {
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
else if (!(err instanceof Error)) setRouteSegments([])
}
}, [tripStore, routeCalcEnabled])
useEffect(() => {
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
updateRouteForDay(selectedDayId)
}, [selectedDayId, tripStore.assignments])
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
}
+29
View File
@@ -0,0 +1,29 @@
import { useEffect } from 'react'
import { useTripStore } from '../store/tripStore'
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
import type { WebSocketEvent } from '../types'
export function useTripWebSocket(tripId: number | string | undefined) {
const tripStore = useTripStore()
useEffect(() => {
if (!tripId) return
const handler = useTripStore.getState().handleRemoteEvent
joinTrip(tripId)
addListener(handler)
const collabFileSync = (event: WebSocketEvent) => {
if (event?.type === 'collab:note:deleted' || event?.type === 'collab:note:updated') {
tripStore.loadFiles?.(tripId)
}
}
addListener(collabFileSync)
const localFileSync = () => tripStore.loadFiles?.(tripId)
window.addEventListener('collab-files-changed', localFileSync)
return () => {
leaveTrip(tripId)
removeListener(handler)
removeListener(collabFileSync)
window.removeEventListener('collab-files-changed', localFileSync)
}
}, [tripId])
}
-36
View File
@@ -1,36 +0,0 @@
import React, { createContext, useContext, useMemo } from 'react'
import { useSettingsStore } from '../store/settingsStore'
import de from './translations/de'
import en from './translations/en'
const translations = { de, en }
const TranslationContext = createContext({ t: (k) => k, language: 'de', locale: 'de-DE' })
export function TranslationProvider({ children }) {
const language = useSettingsStore(s => s.settings.language) || 'de'
const value = useMemo(() => {
const strings = translations[language] || translations.de
const fallback = translations.de
function t(key, params) {
let val = strings[key] ?? fallback[key] ?? key
// Arrays/Objects direkt zurückgeben (z.B. Vorschläge-Liste)
if (typeof val !== 'string') return val
if (params) {
Object.entries(params).forEach(([k, v]) => {
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), v)
})
}
return val
}
return { t, language, locale: language === 'en' ? 'en-US' : 'de-DE' }
}, [language])
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
}
export function useTranslation() {
return useContext(TranslationContext)
}
+47
View File
@@ -0,0 +1,47 @@
import React, { createContext, useContext, useMemo, ReactNode } from 'react'
import { useSettingsStore } from '../store/settingsStore'
import de from './translations/de'
import en from './translations/en'
type TranslationStrings = Record<string, string>
const translations: Record<string, TranslationStrings> = { de, en }
interface TranslationContextValue {
t: (key: string, params?: Record<string, string | number>) => string
language: string
locale: string
}
const TranslationContext = createContext<TranslationContextValue>({ t: (k: string) => k, language: 'de', locale: 'de-DE' })
interface TranslationProviderProps {
children: ReactNode
}
export function TranslationProvider({ children }: TranslationProviderProps) {
const language = useSettingsStore((s) => s.settings.language) || 'de'
const value = useMemo((): TranslationContextValue => {
const strings = translations[language] || translations.de
const fallback = translations.de
function t(key: string, params?: Record<string, string | number>): string {
let val: string = strings[key] ?? fallback[key] ?? key
if (params) {
Object.entries(params).forEach(([k, v]) => {
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
})
}
return val
}
return { t, language, locale: language === 'en' ? 'en-US' : 'de-DE' }
}, [language])
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
}
export function useTranslation(): TranslationContextValue {
return useContext(TranslationContext)
}
@@ -1,4 +1,4 @@
const de = {
const de: Record<string, string> = {
// Allgemein
'common.save': 'Speichern',
'common.cancel': 'Abbrechen',
@@ -28,6 +28,8 @@ const de = {
'common.update': 'Aktualisieren',
'common.change': 'Ändern',
'common.uploading': 'Hochladen…',
'common.backToPlanning': 'Zurück zur Planung',
'common.reset': 'Zurücksetzen',
// Navbar
'nav.trip': 'Reise',
@@ -37,6 +39,7 @@ const de = {
'nav.logout': 'Abmelden',
'nav.lightMode': 'Heller Modus',
'nav.darkMode': 'Dunkler Modus',
'nav.autoMode': 'Automatischer Modus',
'nav.administrator': 'Administrator',
// Dashboard
@@ -48,6 +51,9 @@ const de = {
'dashboard.subtitle.activeMany': '{count} aktive Reisen',
'dashboard.subtitle.archivedSuffix': ' · {count} archiviert',
'dashboard.newTrip': 'Neue Reise',
'dashboard.currency': 'Währung',
'dashboard.timezone': 'Zeitzonen',
'dashboard.localTime': 'Lokal',
'dashboard.emptyTitle': 'Noch keine Reisen',
'dashboard.emptyText': 'Erstelle deine erste Reise und beginne mit der Planung von Orten, Tagesabläufen und Packlisten.',
'dashboard.emptyButton': 'Erste Reise erstellen',
@@ -117,16 +123,38 @@ const de = {
'settings.colorMode': 'Farbmodus',
'settings.light': 'Hell',
'settings.dark': 'Dunkel',
'settings.auto': 'Automatisch',
'settings.language': 'Sprache',
'settings.temperature': 'Temperatureinheit',
'settings.timeFormat': 'Zeitformat',
'settings.routeCalculation': 'Routenberechnung',
'settings.on': 'An',
'settings.off': 'Aus',
'settings.account': 'Konto',
'settings.username': 'Benutzername',
'settings.email': 'E-Mail',
'settings.role': 'Rolle',
'settings.roleAdmin': 'Administrator',
'settings.oidcLinked': 'Verknüpft mit',
'settings.changePassword': 'Passwort ändern',
'settings.currentPassword': 'Aktuelles Passwort',
'settings.currentPasswordRequired': 'Aktuelles Passwort wird benötigt',
'settings.newPassword': 'Neues Passwort',
'settings.confirmPassword': 'Neues Passwort bestätigen',
'settings.updatePassword': 'Passwort aktualisieren',
'settings.passwordRequired': 'Bitte aktuelles und neues Passwort eingeben',
'settings.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein',
'settings.passwordMismatch': 'Passwörter stimmen nicht überein',
'settings.passwordWeak': 'Passwort muss Groß-, Kleinbuchstaben und eine Zahl enthalten',
'settings.passwordChanged': 'Passwort erfolgreich geändert',
'settings.deleteAccount': 'Löschen',
'settings.deleteAccountTitle': 'Account wirklich löschen?',
'settings.deleteAccountWarning': 'Dein Account und alle deine Reisen, Orte und Dateien werden unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.',
'settings.deleteAccountConfirm': 'Endgültig löschen',
'settings.deleteBlockedTitle': 'Löschung nicht möglich',
'settings.deleteBlockedMessage': 'Du bist der einzige Administrator. Ernenne zuerst einen anderen Benutzer zum Admin, bevor du deinen Account löschen kannst.',
'settings.roleUser': 'Benutzer',
'settings.saveProfile': 'Profil speichern',
'settings.saveProfile': 'Speichern',
'settings.toast.mapSaved': 'Karteneinstellungen gespeichert',
'settings.toast.keysSaved': 'API-Schlüssel gespeichert',
'settings.toast.displaySaved': 'Anzeigeeinstellungen gespeichert',
@@ -172,6 +200,35 @@ const de = {
'login.register': 'Registrieren',
'login.emailPlaceholder': 'deine@email.de',
'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.title': 'Administration',
@@ -188,6 +245,7 @@ const de = {
'admin.table.email': 'E-Mail',
'admin.table.role': 'Rolle',
'admin.table.created': 'Erstellt',
'admin.table.lastLogin': 'Letzter Login',
'admin.table.actions': 'Aktionen',
'admin.you': '(Du)',
'admin.editUser': 'Benutzer bearbeiten',
@@ -210,20 +268,205 @@ const de = {
'admin.allowRegistration': 'Registrierung erlauben',
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
'admin.apiKeys': 'API-Schlüssel',
'admin.apiKeysHint': 'Optional. Aktiviert erweiterte Ortsdaten wie Fotos und Wetter.',
'admin.mapsKey': 'Google Maps API Key',
'admin.mapsKeyHint': 'Für Ortsuche benötigt. Erstellen unter console.cloud.google.com',
'admin.mapsKeyHintLong': 'Ohne API Key wird OpenStreetMap für die Ortssuche genutzt. Mit Google API Key können zusätzlich Bilder, Bewertungen und Öffnungszeiten geladen werden. Erstellen unter console.cloud.google.com.',
'admin.recommended': 'Empfohlen',
'admin.weatherKey': 'OpenWeatherMap API Key',
'admin.weatherKeyHint': 'Für Wetterdaten. Kostenlos unter openweathermap.org',
'admin.validateKey': 'Test',
'admin.keyValid': 'Verbunden',
'admin.keyInvalid': 'Ungültig',
'admin.keySaved': 'API-Schlüssel gespeichert',
'admin.oidcTitle': 'Single Sign-On (OIDC)',
'admin.oidcSubtitle': 'Anmeldung über externe Anbieter wie Google, Apple, Authentik oder Keycloak.',
'admin.oidcDisplayName': 'Anzeigename',
'admin.oidcIssuer': 'Issuer URL',
'admin.oidcIssuerHint': 'Die OpenID Connect Issuer URL des Anbieters. z.B. https://accounts.google.com',
'admin.oidcSaved': 'OIDC-Konfiguration gespeichert',
// 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
'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um NOMAD nach deinen Wünschen anzupassen.',
'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ',
'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.',
'admin.addons.enabled': 'Aktiviert',
'admin.addons.disabled': 'Deaktiviert',
'admin.addons.type.trip': 'Trip',
'admin.addons.type.global': 'Global',
'admin.addons.tripHint': 'Verfügbar als Tab innerhalb jedes Trips',
'admin.addons.globalHint': 'Verfügbar als eigenständiger Bereich in der Navigation',
'admin.addons.toast.updated': 'Addon aktualisiert',
'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden',
'admin.addons.noAddons': 'Keine Addons verfügbar',
// 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.subtitle': 'Urlaubstage planen und verwalten',
'vacay.settings': 'Einstellungen',
'vacay.year': 'Jahr',
'vacay.addYear': 'Jahr hinzufügen',
'vacay.removeYear': 'Jahr entfernen',
'vacay.removeYearConfirm': '{year} entfernen?',
'vacay.removeYearHint': 'Alle Urlaubseinträge und Betriebsferien für dieses Jahr werden unwiderruflich gelöscht.',
'vacay.remove': 'Entfernen',
'vacay.persons': 'Personen',
'vacay.noPersons': 'Keine Personen angelegt',
'vacay.addPerson': 'Person hinzufügen',
'vacay.editPerson': 'Person bearbeiten',
'vacay.removePerson': 'Person entfernen',
'vacay.removePersonConfirm': '{name} wirklich entfernen?',
'vacay.removePersonHint': 'Alle Urlaubseinträge dieser Person werden unwiderruflich gelöscht.',
'vacay.personName': 'Name',
'vacay.personNamePlaceholder': 'Name eingeben',
'vacay.color': 'Farbe',
'vacay.add': 'Hinzufügen',
'vacay.legend': 'Legende',
'vacay.publicHoliday': 'Feiertag',
'vacay.companyHoliday': 'Betriebsferien',
'vacay.weekend': 'Wochenende',
'vacay.modeVacation': 'Urlaub',
'vacay.modeCompany': 'Betriebsferien',
'vacay.entitlement': 'Urlaubsanspruch',
'vacay.entitlementDays': 'Tage',
'vacay.used': 'Weg',
'vacay.remaining': 'Rest',
'vacay.carriedOver': 'aus {year}',
'vacay.blockWeekends': 'Wochenenden sperren',
'vacay.blockWeekendsHint': 'Verhindert Urlaubseinträge an Samstagen und Sonntagen',
'vacay.publicHolidays': 'Feiertage',
'vacay.publicHolidaysHint': 'Feiertage im Kalender markieren',
'vacay.selectCountry': 'Land wählen',
'vacay.selectRegion': 'Region wählen (optional)',
'vacay.companyHolidays': 'Betriebsferien',
'vacay.companyHolidaysHint': 'Erlaubt das Markieren von unternehmensweiten Feiertagen',
'vacay.companyHolidaysNoDeduct': 'Betriebsferien werden nicht vom Urlaubskontingent abgezogen.',
'vacay.carryOver': 'Urlaubsmitnahme',
'vacay.carryOverHint': 'Resturlaub automatisch ins Folgejahr übertragen',
'vacay.sharing': 'Teilen',
'vacay.sharingHint': 'Teile deinen Urlaubsplan mit anderen NOMAD-Benutzern',
'vacay.owner': 'Besitzer',
'vacay.shareEmailPlaceholder': 'E-Mail des NOMAD-Benutzers',
'vacay.shareSuccess': 'Plan erfolgreich geteilt',
'vacay.shareError': 'Plan konnte nicht geteilt werden',
'vacay.dissolve': 'Fusion auflösen',
'vacay.dissolveHint': 'Kalender wieder trennen. Deine Einträge bleiben erhalten.',
'vacay.dissolveAction': 'Auflösen',
'vacay.dissolved': 'Kalender getrennt',
'vacay.fusedWith': 'Fusioniert mit',
'vacay.you': 'du',
'vacay.noData': 'Keine Daten',
'vacay.changeColor': 'Farbe ändern',
'vacay.inviteUser': 'Benutzer einladen',
'vacay.inviteHint': 'Lade einen anderen NOMAD-Benutzer ein, um einen gemeinsamen Urlaubskalender zu teilen.',
'vacay.selectUser': 'Benutzer wählen',
'vacay.sendInvite': 'Einladung senden',
'vacay.inviteSent': 'Einladung gesendet',
'vacay.inviteError': 'Einladung konnte nicht gesendet werden',
'vacay.pending': 'ausstehend',
'vacay.noUsersAvailable': 'Keine Benutzer verfügbar',
'vacay.accept': 'Annehmen',
'vacay.decline': 'Ablehnen',
'vacay.acceptFusion': 'Annehmen & Fusionieren',
'vacay.inviteTitle': 'Fusionsanfrage',
'vacay.inviteWantsToFuse': 'möchte einen Urlaubskalender mit dir teilen.',
'vacay.fuseInfo1': 'Beide sehen alle Urlaubseinträge in einem gemeinsamen Kalender.',
'vacay.fuseInfo2': 'Beide können Einträge für den jeweils anderen erstellen und bearbeiten.',
'vacay.fuseInfo3': 'Beide können Einträge löschen und den Urlaubsanspruch ändern.',
'vacay.fuseInfo4': 'Einstellungen wie Feiertage und Betriebsferien werden geteilt.',
'vacay.fuseInfo5': 'Die Fusion kann jederzeit von beiden Seiten aufgelöst werden. Einträge bleiben erhalten.',
'nav.myTrips': 'Meine Trips',
// Atlas addon
'atlas.subtitle': 'Dein Reise-Fußabdruck auf der Welt',
'atlas.countries': 'Länder',
'atlas.trips': 'Reisen',
'atlas.places': 'Orte',
'atlas.days': 'Tage',
'atlas.visitedCountries': 'Besuchte Länder',
'atlas.cities': 'Städte',
'atlas.noData': 'Noch keine Reisedaten',
'atlas.noDataHint': 'Erstelle einen Trip und füge Orte hinzu',
'atlas.lastTrip': 'Letzter Trip',
'atlas.nextTrip': 'Nächster Trip',
'atlas.daysLeft': 'Tage',
'atlas.streak': 'Streak',
'atlas.year': 'Jahr',
'atlas.years': 'Jahre',
'atlas.yearInRow': 'Jahr in Folge',
'atlas.yearsInRow': 'Jahre in Folge',
'atlas.tripIn': 'Reise in',
'atlas.tripsIn': 'Reisen in',
'atlas.since': 'seit',
'atlas.europe': 'Europa',
'atlas.asia': 'Asien',
'atlas.northAmerica': 'N-Amerika',
'atlas.southAmerica': 'S-Amerika',
'atlas.africa': 'Afrika',
'atlas.oceania': 'Ozeanien',
'atlas.other': 'Andere',
'atlas.firstVisit': 'Erste Reise',
'atlas.lastVisitLabel': 'Letzte Reise',
'atlas.tripSingular': 'Reise',
'atlas.tripPlural': 'Reisen',
'atlas.placeVisited': 'Ort besucht',
'atlas.placesVisited': 'Orte besucht',
// Trip Planner
'trip.tabs.plan': 'Planung',
'trip.tabs.plan': 'Karte',
'trip.tabs.reservations': 'Buchungen',
'trip.tabs.packing': 'Packliste',
'trip.tabs.packingShort': 'Packliste',
'trip.tabs.reservationsShort': 'Buchung',
'trip.tabs.packing': 'Liste',
'trip.tabs.packingShort': 'Liste',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Dateien',
'trip.loading': 'Reise wird geladen...',
@@ -241,9 +484,6 @@ const de = {
'trip.confirm.deletePlace': 'Möchtest du diesen Ort wirklich löschen?',
// 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.addNote': 'Notiz hinzufügen',
'dayplan.editNote': 'Notiz bearbeiten',
@@ -259,6 +499,9 @@ const de = {
'dayplan.optimize': 'Optimieren',
'dayplan.optimized': 'Route optimiert',
'dayplan.routeError': 'Fehler bei der Routenberechnung',
'dayplan.toast.needTwoPlaces': 'Mindestens zwei Orte für Routenoptimierung nötig',
'dayplan.toast.routeOptimized': 'Route optimiert',
'dayplan.toast.noGeoPlaces': 'Keine Orte mit Koordinaten für Routenberechnung gefunden',
'dayplan.confirmed': 'Bestätigt',
'dayplan.pendingRes': 'Ausstehend',
'dayplan.pdf': 'PDF',
@@ -266,7 +509,7 @@ const de = {
'dayplan.pdfError': 'Fehler beim PDF-Export',
// Places Sidebar
'places.addPlace': 'Ort hinzufügen',
'places.addPlace': 'Ort/Aktivität hinzufügen',
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
'places.all': 'Alle',
'places.unplanned': 'Ungeplant',
@@ -289,20 +532,21 @@ const de = {
'places.noCategory': 'Keine Kategorie',
'places.categoryNamePlaceholder': 'Kategoriename',
'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.formNotesPlaceholder': 'Persönliche Notizen...',
'places.formReservation': 'Reservierung',
'places.reservationNotesPlaceholder': 'Reservierungsnotizen, Bestätigungsnummer...',
'places.mapsSearchPlaceholder': 'Google Maps suchen...',
'places.mapsSearchError': 'Google Maps Suche fehlgeschlagen. Bitte API-Schlüssel in den Einstellungen hinterlegen.',
'places.mapsSearchPlaceholder': 'Ortssuche...',
'places.mapsSearchError': 'Ortssuche fehlgeschlagen.',
'places.osmHint': 'OpenStreetMap-Suche aktiv (ohne Bilder, Öffnungszeiten, Bewertungen). Für erweiterte Daten Google API Key in den Einstellungen hinterlegen.',
'places.osmActive': 'Suche via OpenStreetMap (ohne Bilder, Bewertungen & Öffnungszeiten). Google API Key in den Einstellungen hinterlegen für erweiterte Daten.',
'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie',
'places.nameRequired': 'Bitte einen Namen eingeben',
'places.saveError': 'Fehler beim Speichern',
'places.transport.walking': '🚶 Zu Fuß',
'places.transport.driving': '🚗 Auto',
'places.transport.cycling': '🚲 Fahrrad',
'places.transport.transit': '🚌 ÖPNV',
// Place Inspector
'inspector.opened': 'Geöffnet',
'inspector.closed': 'Geschlossen',
@@ -316,6 +560,9 @@ const de = {
'inspector.pendingRes': 'Ausstehende Reservierung',
'inspector.google': 'In Google Maps öffnen',
'inspector.website': 'Webseite öffnen',
'inspector.addRes': 'Reservierung',
'inspector.editRes': 'Reservierung bearbeiten',
'inspector.participants': 'Teilnehmer',
// Reservations
'reservations.title': 'Buchungen',
@@ -332,6 +579,10 @@ const de = {
'reservations.editTitle': 'Reservierung bearbeiten',
'reservations.status': 'Status',
'reservations.datetime': 'Datum & Uhrzeit',
'reservations.startTime': 'Startzeit',
'reservations.endTime': 'Endzeit',
'reservations.date': 'Datum',
'reservations.time': 'Uhrzeit',
'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)',
'reservations.notes': 'Notizen',
'reservations.notesPlaceholder': 'Zusätzliche Notizen...',
@@ -359,7 +610,7 @@ const de = {
'reservations.titlePlaceholder': 'z.B. Lufthansa LH123, Hotel Adlon, ...',
'reservations.locationAddress': 'Ort / Adresse',
'reservations.locationPlaceholder': 'Adresse, Flughafen, Hotel...',
'reservations.confirmationCode': 'Bestätigungsnummer / Buchungscode',
'reservations.confirmationCode': 'Buchungscode',
'reservations.confirmationPlaceholder': 'z.B. ABC12345',
'reservations.day': 'Tag',
'reservations.noDay': 'Kein Tag',
@@ -368,6 +619,9 @@ const de = {
'reservations.pendingSave': 'wird gespeichert…',
'reservations.uploading': 'Wird hochgeladen...',
'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.title': 'Budget',
@@ -383,7 +637,7 @@ const de = {
'budget.table.days': 'Tage',
'budget.table.perPerson': 'Pro Person',
'budget.table.perDay': 'Pro Tag',
'budget.table.perPersonDay': 'Pro Person/Tag',
'budget.table.perPersonDay': 'P. p / Tag',
'budget.table.note': 'Notiz',
'budget.newEntry': 'Neuer Eintrag',
'budget.defaultEntry': 'Neuer Eintrag',
@@ -394,6 +648,10 @@ const de = {
'budget.editTooltip': 'Klicken zum Bearbeiten',
'budget.confirm.deleteCategory': 'Möchtest du die Kategorie "{name}" mit {count} Einträgen wirklich löschen?',
'budget.deleteCategory': 'Kategorie löschen',
'budget.perPerson': 'Pro Person',
'budget.paid': 'Bezahlt',
'budget.open': 'Offen',
'budget.noMembers': 'Keine Teilnehmer zugewiesen',
// Files
'files.title': 'Dateien',
@@ -403,11 +661,14 @@ const de = {
'files.uploadError': 'Fehler beim Hochladen',
'files.dropzone': 'Dateien hier ablegen',
'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.filterAll': 'Alle',
'files.filterPdf': 'PDFs',
'files.filterImages': 'Bilder',
'files.filterDocs': 'Dokumente',
'files.filterCollab': 'Collab Notizen',
'files.sourceCollab': 'Aus Collab Notizen',
'files.empty': 'Keine Dateien vorhanden',
'files.emptyHint': 'Lade Dateien hoch, um sie mit deiner Reise zu verknüpfen',
'files.openTab': 'In neuem Tab öffnen',
@@ -416,6 +677,8 @@ const de = {
'files.toast.deleteError': 'Fehler beim Löschen der Datei',
'files.sourcePlan': 'Tagesplan',
'files.sourceBooking': 'Buchung',
'files.attach': 'Anhängen',
'files.pasteHint': 'Du kannst auch Bilder aus der Zwischenablage einfügen (Strg+V)',
// Packing
'packing.title': 'Packliste',
@@ -568,6 +831,21 @@ const de = {
'backup.keep.30days': '30 Tage',
'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.travelPlan': 'Reiseplan',
'pdf.planned': 'Eingeplant',
@@ -575,6 +853,68 @@ const de = {
'pdf.preview': 'PDF Vorschau',
'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
'stats.countries': 'Länder',
'stats.cities': 'Städte',
@@ -584,6 +924,97 @@ const de = {
'stats.visited': 'besucht',
'stats.remaining': 'verbleibend',
'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.emptyDesc': 'Teile Ideen, Pläne und Updates mit deiner Reisegruppe',
'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
@@ -1,4 +1,4 @@
const en = {
const en: Record<string, string> = {
// Common
'common.save': 'Save',
'common.cancel': 'Cancel',
@@ -28,6 +28,8 @@ const en = {
'common.update': 'Update',
'common.change': 'Change',
'common.uploading': 'Uploading…',
'common.backToPlanning': 'Back to Planning',
'common.reset': 'Reset',
// Navbar
'nav.trip': 'Trip',
@@ -37,6 +39,7 @@ const en = {
'nav.logout': 'Log out',
'nav.lightMode': 'Light Mode',
'nav.darkMode': 'Dark Mode',
'nav.autoMode': 'Auto Mode',
'nav.administrator': 'Administrator',
// Dashboard
@@ -48,6 +51,9 @@ const en = {
'dashboard.subtitle.activeMany': '{count} active trips',
'dashboard.subtitle.archivedSuffix': ' · {count} archived',
'dashboard.newTrip': 'New Trip',
'dashboard.currency': 'Currency',
'dashboard.timezone': 'Timezones',
'dashboard.localTime': 'Local',
'dashboard.emptyTitle': 'No trips yet',
'dashboard.emptyText': 'Create your first trip and start planning!',
'dashboard.emptyButton': 'Create First Trip',
@@ -117,14 +123,36 @@ const en = {
'settings.colorMode': 'Color Mode',
'settings.light': 'Light',
'settings.dark': 'Dark',
'settings.auto': 'Auto',
'settings.language': 'Language',
'settings.temperature': 'Temperature Unit',
'settings.timeFormat': 'Time Format',
'settings.routeCalculation': 'Route Calculation',
'settings.on': 'On',
'settings.off': 'Off',
'settings.account': 'Account',
'settings.username': 'Username',
'settings.email': 'Email',
'settings.role': 'Role',
'settings.roleAdmin': 'Administrator',
'settings.oidcLinked': 'Linked with',
'settings.changePassword': 'Change Password',
'settings.currentPassword': 'Current password',
'settings.currentPasswordRequired': 'Current password is required',
'settings.newPassword': 'New password',
'settings.confirmPassword': 'Confirm new password',
'settings.updatePassword': 'Update password',
'settings.passwordRequired': 'Please enter current and new password',
'settings.passwordTooShort': 'Password must be at least 8 characters',
'settings.passwordMismatch': 'Passwords do not match',
'settings.passwordWeak': 'Password must contain uppercase, lowercase, and a number',
'settings.passwordChanged': 'Password changed successfully',
'settings.deleteAccount': 'Delete account',
'settings.deleteAccountTitle': 'Delete your account?',
'settings.deleteAccountWarning': 'Your account and all your trips, places, and files will be permanently deleted. This action cannot be undone.',
'settings.deleteAccountConfirm': 'Delete permanently',
'settings.deleteBlockedTitle': 'Deletion not possible',
'settings.deleteBlockedMessage': 'You are the only administrator. Promote another user to admin before deleting your account.',
'settings.roleUser': 'User',
'settings.saveProfile': 'Save Profile',
'settings.toast.mapSaved': 'Map settings saved',
@@ -172,6 +200,35 @@ const en = {
'login.register': 'Register',
'login.emailPlaceholder': 'your@email.com',
'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.title': 'Administration',
@@ -188,6 +245,7 @@ const en = {
'admin.table.email': 'Email',
'admin.table.role': 'Role',
'admin.table.created': 'Created',
'admin.table.lastLogin': 'Last Login',
'admin.table.actions': 'Actions',
'admin.you': '(You)',
'admin.editUser': 'Edit User',
@@ -210,18 +268,203 @@ const en = {
'admin.allowRegistration': 'Allow Registration',
'admin.allowRegistrationHint': 'New users can register themselves',
'admin.apiKeys': 'API Keys',
'admin.apiKeysHint': 'Optional. Enables extended place data like photos and weather.',
'admin.mapsKey': 'Google Maps API Key',
'admin.mapsKeyHint': 'Required for place search. Get at console.cloud.google.com',
'admin.mapsKeyHintLong': 'Without an API key, OpenStreetMap is used for place search. With a Google API key, photos, ratings, and opening hours can be loaded as well. Get one at console.cloud.google.com.',
'admin.recommended': 'Recommended',
'admin.weatherKey': 'OpenWeatherMap API Key',
'admin.weatherKeyHint': 'For weather data. Free at openweathermap.org',
'admin.validateKey': 'Test',
'admin.keyValid': 'Connected',
'admin.keyInvalid': 'Invalid',
'admin.keySaved': 'API keys saved',
'admin.oidcTitle': 'Single Sign-On (OIDC)',
'admin.oidcSubtitle': 'Allow login via external providers like Google, Apple, Authentik or Keycloak.',
'admin.oidcDisplayName': 'Display Name',
'admin.oidcIssuer': 'Issuer URL',
'admin.oidcIssuerHint': 'The OpenID Connect Issuer URL of the provider. e.g. https://accounts.google.com',
'admin.oidcSaved': 'OIDC configuration saved',
// 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
'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Enable or disable features to customize your NOMAD experience.',
'admin.addons.subtitleBefore': 'Enable or disable features to customize your ',
'admin.addons.subtitleAfter': ' experience.',
'admin.addons.enabled': 'Enabled',
'admin.addons.disabled': 'Disabled',
'admin.addons.type.trip': 'Trip',
'admin.addons.type.global': 'Global',
'admin.addons.tripHint': 'Available as a tab within each trip',
'admin.addons.globalHint': 'Available as a standalone section in the main navigation',
'admin.addons.toast.updated': 'Addon updated',
'admin.addons.toast.error': 'Failed to update addon',
'admin.addons.noAddons': 'No addons available',
// 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.subtitle': 'Plan and manage vacation days',
'vacay.settings': 'Settings',
'vacay.year': 'Year',
'vacay.addYear': 'Add year',
'vacay.removeYear': 'Remove year',
'vacay.removeYearConfirm': 'Remove {year}?',
'vacay.removeYearHint': 'All vacation entries and company holidays for this year will be permanently deleted.',
'vacay.remove': 'Remove',
'vacay.persons': 'Persons',
'vacay.noPersons': 'No persons added',
'vacay.addPerson': 'Add Person',
'vacay.editPerson': 'Edit Person',
'vacay.removePerson': 'Remove Person',
'vacay.removePersonConfirm': 'Remove {name}?',
'vacay.removePersonHint': 'All vacation entries for this person will be permanently deleted.',
'vacay.personName': 'Name',
'vacay.personNamePlaceholder': 'Enter name',
'vacay.color': 'Color',
'vacay.add': 'Add',
'vacay.legend': 'Legend',
'vacay.publicHoliday': 'Public Holiday',
'vacay.companyHoliday': 'Company Holiday',
'vacay.weekend': 'Weekend',
'vacay.modeVacation': 'Vacation',
'vacay.modeCompany': 'Company Holiday',
'vacay.entitlement': 'Entitlement',
'vacay.entitlementDays': 'Days',
'vacay.used': 'Used',
'vacay.remaining': 'Left',
'vacay.carriedOver': 'from {year}',
'vacay.blockWeekends': 'Block Weekends',
'vacay.blockWeekendsHint': 'Prevent vacation entries on Saturdays and Sundays',
'vacay.publicHolidays': 'Public Holidays',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.selectCountry': 'Select country',
'vacay.selectRegion': 'Select region (optional)',
'vacay.companyHolidays': 'Company Holidays',
'vacay.companyHolidaysHint': 'Allow marking company-wide holiday days',
'vacay.companyHolidaysNoDeduct': 'Company holidays do not count towards vacation days.',
'vacay.carryOver': 'Carry Over',
'vacay.carryOverHint': 'Automatically carry remaining vacation days into the next year',
'vacay.sharing': 'Sharing',
'vacay.sharingHint': 'Share your vacation plan with other NOMAD users',
'vacay.owner': 'Owner',
'vacay.shareEmailPlaceholder': 'Email of NOMAD user',
'vacay.shareSuccess': 'Plan shared successfully',
'vacay.shareError': 'Could not share plan',
'vacay.dissolve': 'Dissolve Fusion',
'vacay.dissolveHint': 'Separate calendars again. Your entries will be kept.',
'vacay.dissolveAction': 'Dissolve',
'vacay.dissolved': 'Calendar separated',
'vacay.fusedWith': 'Fused with',
'vacay.you': 'you',
'vacay.noData': 'No data',
'vacay.changeColor': 'Change color',
'vacay.inviteUser': 'Invite User',
'vacay.inviteHint': 'Invite another NOMAD user to share a combined vacation calendar.',
'vacay.selectUser': 'Select user',
'vacay.sendInvite': 'Send Invite',
'vacay.inviteSent': 'Invite sent',
'vacay.inviteError': 'Could not send invite',
'vacay.pending': 'pending',
'vacay.noUsersAvailable': 'No users available',
'vacay.accept': 'Accept',
'vacay.decline': 'Decline',
'vacay.acceptFusion': 'Accept & Fuse',
'vacay.inviteTitle': 'Fusion Request',
'vacay.inviteWantsToFuse': 'wants to share a vacation calendar with you.',
'vacay.fuseInfo1': 'Both of you will see all vacation entries in one shared calendar.',
'vacay.fuseInfo2': 'Both parties can create and edit entries for each other.',
'vacay.fuseInfo3': 'Both parties can delete entries and change vacation entitlements.',
'vacay.fuseInfo4': 'Settings like public holidays and company holidays are shared.',
'vacay.fuseInfo5': 'The fusion can be dissolved at any time by either party. Your entries will be preserved.',
'nav.myTrips': 'My Trips',
// Atlas addon
'atlas.subtitle': 'Your travel footprint around the world',
'atlas.countries': 'Countries',
'atlas.trips': 'Trips',
'atlas.places': 'Places',
'atlas.days': 'Days',
'atlas.visitedCountries': 'Visited Countries',
'atlas.cities': 'Cities',
'atlas.noData': 'No travel data yet',
'atlas.noDataHint': 'Create a trip and add places to see your world map',
'atlas.lastTrip': 'Last trip',
'atlas.nextTrip': 'Next trip',
'atlas.daysLeft': 'days left',
'atlas.streak': 'Streak',
'atlas.year': 'year',
'atlas.years': 'years',
'atlas.yearInRow': 'year in a row',
'atlas.yearsInRow': 'years in a row',
'atlas.tripIn': 'trip in',
'atlas.tripsIn': 'trips in',
'atlas.since': 'since',
'atlas.europe': 'Europe',
'atlas.asia': 'Asia',
'atlas.northAmerica': 'N. America',
'atlas.southAmerica': 'S. America',
'atlas.africa': 'Africa',
'atlas.oceania': 'Oceania',
'atlas.other': 'Other',
'atlas.firstVisit': 'First trip',
'atlas.lastVisitLabel': 'Last trip',
'atlas.tripSingular': 'Trip',
'atlas.tripPlural': 'Trips',
'atlas.placeVisited': 'Place visited',
'atlas.placesVisited': 'Places visited',
// Trip Planner
'trip.tabs.plan': 'Plan',
'trip.tabs.reservations': 'Bookings',
'trip.tabs.reservationsShort': 'Book',
'trip.tabs.packing': 'Packing List',
'trip.tabs.packingShort': 'Packing',
'trip.tabs.budget': 'Budget',
@@ -241,9 +484,6 @@ const en = {
'trip.confirm.deletePlace': 'Are you sure you want to delete this place?',
// Day Plan Sidebar
'dayplan.transport.car': 'Car',
'dayplan.transport.walk': 'Walk',
'dayplan.transport.bike': 'Bike',
'dayplan.emptyDay': 'No places planned for this day',
'dayplan.addNote': 'Add Note',
'dayplan.editNote': 'Edit Note',
@@ -259,6 +499,9 @@ const en = {
'dayplan.optimize': 'Optimize',
'dayplan.optimized': 'Route optimized',
'dayplan.routeError': 'Failed to calculate route',
'dayplan.toast.needTwoPlaces': 'At least two places needed for route optimization',
'dayplan.toast.routeOptimized': 'Route optimized',
'dayplan.toast.noGeoPlaces': 'No places with coordinates found for route calculation',
'dayplan.confirmed': 'Confirmed',
'dayplan.pendingRes': 'Pending',
'dayplan.pdf': 'PDF',
@@ -266,7 +509,7 @@ const en = {
'dayplan.pdfError': 'Failed to export PDF',
// Places Sidebar
'places.addPlace': 'Add Place',
'places.addPlace': 'Add Place/Activity',
'places.assignToDay': 'Add to which day?',
'places.all': 'All',
'places.unplanned': 'Unplanned',
@@ -289,20 +532,21 @@ const en = {
'places.noCategory': 'No Category',
'places.categoryNamePlaceholder': 'Category name',
'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.formNotesPlaceholder': 'Personal notes...',
'places.formReservation': 'Reservation',
'places.reservationNotesPlaceholder': 'Reservation notes, confirmation number...',
'places.mapsSearchPlaceholder': 'Search Google Maps...',
'places.mapsSearchError': 'Google Maps search failed. Please add an API key in settings.',
'places.mapsSearchPlaceholder': 'Search places...',
'places.mapsSearchError': 'Place search failed.',
'places.osmHint': 'Using OpenStreetMap search (no photos, opening hours, or ratings). Add a Google API key in settings for full details.',
'places.osmActive': 'Search via OpenStreetMap (no photos, ratings or opening hours). Add a Google API key in Settings for enhanced data.',
'places.categoryCreateError': 'Failed to create category',
'places.nameRequired': 'Please enter a name',
'places.saveError': 'Failed to save',
'places.transport.walking': '🚶 Walking',
'places.transport.driving': '🚗 Driving',
'places.transport.cycling': '🚲 Cycling',
'places.transport.transit': '🚌 Transit',
// Place Inspector
'inspector.opened': 'Open',
'inspector.closed': 'Closed',
@@ -316,6 +560,9 @@ const en = {
'inspector.pendingRes': 'Pending Reservation',
'inspector.google': 'Open in Google Maps',
'inspector.website': 'Open Website',
'inspector.addRes': 'Reservation',
'inspector.editRes': 'Edit Reservation',
'inspector.participants': 'Participants',
// Reservations
'reservations.title': 'Bookings',
@@ -332,6 +579,10 @@ const en = {
'reservations.editTitle': 'Edit Reservation',
'reservations.status': 'Status',
'reservations.datetime': 'Date & Time',
'reservations.startTime': 'Start time',
'reservations.endTime': 'End time',
'reservations.date': 'Date',
'reservations.time': 'Time',
'reservations.timeAlt': 'Time (alternative, e.g. 19:30)',
'reservations.notes': 'Notes',
'reservations.notesPlaceholder': 'Additional notes...',
@@ -355,7 +606,7 @@ const en = {
'reservations.titlePlaceholder': 'e.g. Lufthansa LH123, Hotel Adlon, ...',
'reservations.locationAddress': 'Location / Address',
'reservations.locationPlaceholder': 'Address, Airport, Hotel...',
'reservations.confirmationCode': 'Confirmation Number / Booking Code',
'reservations.confirmationCode': 'Booking Code',
'reservations.confirmationPlaceholder': 'e.g. ABC12345',
'reservations.day': 'Day',
'reservations.noDay': 'No Day',
@@ -368,6 +619,9 @@ const en = {
'reservations.toast.updateError': 'Failed to update',
'reservations.toast.deleteError': 'Failed to delete',
'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.title': 'Budget',
@@ -383,7 +637,7 @@ const en = {
'budget.table.days': 'Days',
'budget.table.perPerson': 'Per Person',
'budget.table.perDay': 'Per Day',
'budget.table.perPersonDay': 'Per Person/Day',
'budget.table.perPersonDay': 'P. p / Day',
'budget.table.note': 'Note',
'budget.newEntry': 'New Entry',
'budget.defaultEntry': 'New Entry',
@@ -394,6 +648,10 @@ const en = {
'budget.editTooltip': 'Click to edit',
'budget.confirm.deleteCategory': 'Are you sure you want to delete the category "{name}" with {count} entries?',
'budget.deleteCategory': 'Delete Category',
'budget.perPerson': 'Per Person',
'budget.paid': 'Paid',
'budget.open': 'Open',
'budget.noMembers': 'No members assigned',
// Files
'files.title': 'Files',
@@ -403,11 +661,14 @@ const en = {
'files.uploadError': 'Upload failed',
'files.dropzone': 'Drop files here',
'files.dropzoneHint': 'or click to browse',
'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
'files.uploading': 'Uploading...',
'files.filterAll': 'All',
'files.filterPdf': 'PDFs',
'files.filterImages': 'Images',
'files.filterDocs': 'Documents',
'files.filterCollab': 'Collab Notes',
'files.sourceCollab': 'From Collab Notes',
'files.empty': 'No files yet',
'files.emptyHint': 'Upload files to attach them to your trip',
'files.openTab': 'Open in new tab',
@@ -416,6 +677,8 @@ const en = {
'files.toast.deleteError': 'Failed to delete file',
'files.sourcePlan': 'Day Plan',
'files.sourceBooking': 'Booking',
'files.attach': 'Attach',
'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)',
// Packing
'packing.title': 'Packing List',
@@ -568,6 +831,21 @@ const en = {
'backup.keep.30days': '30 days',
'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.travelPlan': 'Travel Plan',
'pdf.planned': 'Planned',
@@ -575,6 +853,68 @@ const en = {
'pdf.preview': 'PDF Preview',
'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
'stats.countries': 'Countries',
'stats.cities': 'Cities',
@@ -584,6 +924,97 @@ const en = {
'stats.visited': 'visited',
'stats.remaining': 'remaining',
'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.emptyDesc': 'Share ideas, plans, and updates with your travel group',
'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
+115
View File
@@ -2,6 +2,100 @@
@tailwind components;
@tailwind utilities;
html { height: 100%; overflow: hidden; background-color: var(--bg-primary); }
body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow-scrolling: touch; }
.atlas-tooltip {
background: rgba(10, 10, 20, 0.6) !important;
backdrop-filter: blur(20px) saturate(180%) !important;
-webkit-backdrop-filter: blur(20px) saturate(180%) !important;
color: #f1f5f9 !important;
border: 1px solid rgba(255,255,255,0.1) !important;
border-radius: 14px !important;
padding: 10px 14px !important;
font-size: 12px !important;
font-family: inherit !important;
box-shadow: 0 8px 32px rgba(0,0,0,0.25) !important;
transition: none !important;
}
.atlas-tooltip::before { border-top-color: rgba(10, 10, 20, 0.6) !important; }
html:not(.dark) .atlas-tooltip {
background: rgba(255, 255, 255, 0.75) !important;
color: #0f172a !important;
border: 1px solid rgba(0,0,0,0.08) !important;
box-shadow: 0 8px 32px rgba(0,0,0,0.1) !important;
}
html:not(.dark) .atlas-tooltip::before { border-top-color: rgba(255, 255, 255, 0.75) !important; }
.leaflet-tooltip.atlas-tooltip { opacity: 1 !important; }
.leaflet-tooltip-pane { transition: none !important; }
.leaflet-fade-anim .leaflet-tooltip { transition: none !important; opacity: 1 !important; }
.dark .leaflet-control-zoom a {
background: rgba(10, 10, 20, 0.7) !important;
color: #e2e8f0 !important;
border-color: rgba(255, 255, 255, 0.1) !important;
backdrop-filter: blur(12px);
}
.dark .leaflet-control-zoom a:hover {
background: rgba(30, 30, 40, 0.8) !important;
}
@media (max-width: 767px) {
.leaflet-control-zoom { display: none !important; }
}
/* Dark mode overrides for pages using hardcoded slate-* Tailwind classes */
html.dark .bg-slate-50 { background-color: var(--bg-secondary) !important; }
html.dark .bg-white { background-color: var(--bg-card) !important; }
html.dark .bg-slate-100 { background-color: var(--bg-secondary) !important; }
html.dark .bg-slate-900.text-white { background-color: #e2e8f0 !important; color: #0f172a !important; }
html.dark .border-slate-200, html.dark .border-slate-300 { border-color: var(--border-primary) !important; }
html.dark .border-slate-100, html.dark .border-b-slate-100 { border-color: var(--border-secondary) !important; }
html.dark .text-slate-900 { color: var(--text-primary) !important; }
html.dark .text-slate-700 { color: var(--text-secondary) !important; }
html.dark .text-slate-600 { color: var(--text-muted) !important; }
html.dark .text-slate-500 { color: var(--text-muted) !important; }
html.dark .text-slate-400 { color: var(--text-faint) !important; }
html.dark .hover\:bg-slate-50:hover, html.dark .hover\:bg-slate-100:hover { background-color: var(--bg-hover) !important; }
html.dark .hover\:text-slate-900:hover { color: var(--text-primary) !important; }
html.dark .hover\:bg-slate-700:hover { background-color: var(--bg-hover) !important; }
html.dark .divide-slate-100 > :not([hidden]) ~ :not([hidden]) { border-color: var(--border-secondary) !important; }
html.dark .focus\:ring-slate-400:focus { --tw-ring-color: var(--text-faint) !important; }
html.dark input[class*="border-slate"], html.dark input[class*="text-slate"] { background: var(--bg-secondary) !important; color: var(--text-primary) !important; border-color: var(--border-primary) !important; }
html.dark .text-amber-900 { color: #fbbf24 !important; }
html.dark .text-amber-700 { color: #f59e0b !important; }
html.dark .bg-amber-50 { background-color: rgba(245,158,11,0.1) !important; }
html.dark .border-amber-200 { border-color: rgba(245,158,11,0.2) !important; }
html.dark .disabled\:bg-slate-400:disabled { background-color: var(--text-faint) !important; }
html.dark button.bg-slate-900 { background-color: #e2e8f0 !important; color: #0f172a !important; opacity: 1 !important; }
html.dark button.bg-slate-900:hover { background-color: #cbd5e1 !important; }
html.dark button.bg-slate-900:disabled { background-color: #ffffff !important; color: #000000 !important; opacity: 0.4 !important; }
html.dark span.bg-slate-900 { background-color: #e2e8f0 !important; color: #0f172a !important; }
html.dark span.bg-slate-100 { background-color: var(--bg-secondary) !important; color: var(--text-muted) !important; }
html.dark .border-b { border-bottom-color: var(--border-secondary) !important; }
/* Gray variants (CategoryManager, BackupPanel) */
html.dark .bg-gray-50 { background-color: var(--bg-secondary) !important; }
html.dark .bg-gray-100 { background-color: var(--bg-secondary) !important; }
html.dark .border-gray-200, html.dark .border-gray-300 { border-color: var(--border-primary) !important; }
html.dark .border-gray-100 { border-color: var(--border-secondary) !important; }
html.dark .text-gray-900 { color: var(--text-primary) !important; }
html.dark .text-gray-700 { color: var(--text-secondary) !important; }
html.dark .text-gray-600 { color: var(--text-muted) !important; }
html.dark .text-gray-500 { color: var(--text-muted) !important; }
html.dark .text-gray-400 { color: var(--text-faint) !important; }
html.dark .text-gray-300 { color: var(--text-faint) !important; }
html.dark .hover\:bg-gray-50:hover, html.dark .hover\:bg-gray-200:hover { background-color: var(--bg-hover) !important; }
html.dark .hover\:border-gray-200:hover, html.dark .hover\:border-gray-400:hover { border-color: var(--border-primary) !important; }
html.dark input.bg-white, html.dark input[class*="bg-white"] { background: var(--bg-secondary) !important; color: var(--text-primary) !important; }
html.dark .bg-gray-200 { background-color: var(--border-primary) !important; }
html.dark .border-gray-300.border-t-slate-600 { border-color: var(--border-primary) !important; border-top-color: var(--text-primary) !important; }
/* Modal buttons */
html.dark button[class*="text-slate-600"][class*="border-slate-200"] { color: var(--text-muted) !important; border-color: var(--border-primary) !important; }
html.dark button[class*="text-slate-600"][class*="border-slate-200"]:hover { background: var(--bg-hover) !important; }
/* Dashed borders */
html.dark .border-dashed.border-gray-300 { border-color: var(--border-primary) !important; }
html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color: transparent !important; }
/* Reorder buttons: desktop = original style; mobile = always visible, larger touch targets */
.reorder-buttons {
flex-direction: column;
@@ -45,6 +139,8 @@
/* ── Design tokens ─────────────────────────────── */
:root {
--safe-top: env(safe-area-inset-top, 0px);
--nav-h: calc(56px + var(--safe-top));
--font-system: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
--sp-1: 4px;
--sp-2: 8px;
@@ -194,6 +290,11 @@ body {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-button {
display: none;
height: 0;
width: 0;
}
::-webkit-scrollbar-track {
background: var(--scrollbar-track);
@@ -209,6 +310,11 @@ body {
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 */
.form-input {
width: 100%;
@@ -230,6 +336,15 @@ body {
color: var(--text-faint);
}
/* Brand images: no save/copy/drag */
img[alt="NOMAD"] {
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-webkit-user-drag: none;
-webkit-touch-callout: none;
}
/* Weiche Übergänge */
.transition-smooth {
transition: all 0.2s ease;
+2 -2
View File
@@ -1,10 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
-630
View File
@@ -1,630 +0,0 @@
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { adminApi, authApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
import { useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar'
import Modal from '../components/shared/Modal'
import { useToast } from '../components/shared/Toast'
import CategoryManager from '../components/Admin/CategoryManager'
import BackupPanel from '../components/Admin/BackupPanel'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus } from 'lucide-react'
import CustomSelect from '../components/shared/CustomSelect'
export default function AdminPage() {
const { t } = useTranslation()
const TABS = [
{ id: 'users', label: t('admin.tabs.users') },
{ id: 'categories', label: t('admin.tabs.categories') },
{ id: 'settings', label: t('admin.tabs.settings') },
{ id: 'backup', label: t('admin.tabs.backup') },
]
const [activeTab, setActiveTab] = useState('users')
const [users, setUsers] = useState([])
const [stats, setStats] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const [editingUser, setEditingUser] = useState(null)
const [editForm, setEditForm] = useState({ username: '', email: '', role: 'user', password: '' })
const [showCreateUser, setShowCreateUser] = useState(false)
const [createForm, setCreateForm] = useState({ username: '', email: '', password: '', role: 'user' })
// Registration toggle
const [allowRegistration, setAllowRegistration] = useState(true)
// API Keys
const [mapsKey, setMapsKey] = useState('')
const [weatherKey, setWeatherKey] = useState('')
const [showKeys, setShowKeys] = useState({})
const [savingKeys, setSavingKeys] = useState(false)
const [validating, setValidating] = useState({})
const [validation, setValidation] = useState({})
const { user: currentUser, updateApiKeys } = useAuthStore()
const navigate = useNavigate()
const toast = useToast()
useEffect(() => {
loadData()
loadAppConfig()
loadApiKeys()
}, [])
const loadData = async () => {
setIsLoading(true)
try {
const [usersData, statsData] = await Promise.all([
adminApi.users(),
adminApi.stats(),
])
setUsers(usersData.users)
setStats(statsData)
} catch (err) {
toast.error(t('admin.toast.loadError'))
} finally {
setIsLoading(false)
}
}
const loadAppConfig = async () => {
try {
const config = await authApi.getAppConfig()
setAllowRegistration(config.allow_registration)
} catch (err) {
// ignore
}
}
const loadApiKeys = async () => {
try {
const data = await authApi.me()
setMapsKey(data.user?.maps_api_key || '')
setWeatherKey(data.user?.openweather_api_key || '')
} catch (err) {
// ignore
}
}
const handleToggleRegistration = async (value) => {
setAllowRegistration(value)
try {
await authApi.updateAppSettings({ allow_registration: value })
} catch (err) {
setAllowRegistration(!value)
toast.error(err.response?.data?.error || t('common.error'))
}
}
const toggleKey = (key) => {
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
}
const handleSaveApiKeys = async () => {
setSavingKeys(true)
try {
await updateApiKeys({
maps_api_key: mapsKey,
openweather_api_key: weatherKey,
})
toast.success(t('admin.keySaved'))
} catch (err) {
toast.error(err.message)
} finally {
setSavingKeys(false)
}
}
const handleValidateKeys = async () => {
setValidating({ maps: true, weather: true })
try {
const result = await authApi.validateKeys()
setValidation(result)
} catch (err) {
toast.error(t('common.error'))
} finally {
setValidating({})
}
}
const handleValidateKey = async (keyType) => {
setValidating(prev => ({ ...prev, [keyType]: true }))
try {
const result = await authApi.validateKeys()
setValidation(prev => ({ ...prev, [keyType]: result[keyType] }))
} catch (err) {
toast.error(t('common.error'))
} finally {
setValidating(prev => ({ ...prev, [keyType]: false }))
}
}
const handleCreateUser = async () => {
if (!createForm.username.trim() || !createForm.email.trim() || !createForm.password.trim()) {
toast.error(t('admin.toast.fieldsRequired'))
return
}
try {
const data = await adminApi.createUser(createForm)
setUsers(prev => [data.user, ...prev])
setShowCreateUser(false)
setCreateForm({ username: '', email: '', password: '', role: 'user' })
toast.success(t('admin.toast.userCreated'))
} catch (err) {
toast.error(err.response?.data?.error || t('admin.toast.createError'))
}
}
const handleEditUser = (user) => {
setEditingUser(user)
setEditForm({ username: user.username, email: user.email, role: user.role, password: '' })
}
const handleSaveUser = async () => {
try {
const payload = {
username: editForm.username.trim() || undefined,
email: editForm.email.trim() || undefined,
role: editForm.role,
}
if (editForm.password.trim()) payload.password = editForm.password.trim()
const data = await adminApi.updateUser(editingUser.id, payload)
setUsers(prev => prev.map(u => u.id === editingUser.id ? data.user : u))
setEditingUser(null)
toast.success(t('admin.toast.userUpdated'))
} catch (err) {
toast.error(err.response?.data?.error || t('admin.toast.updateError'))
}
}
const handleDeleteUser = async (user) => {
if (user.id === currentUser?.id) {
toast.error(t('admin.toast.cannotDeleteSelf'))
return
}
if (!confirm(t('admin.deleteUser', { name: user.username }))) return
try {
await adminApi.deleteUser(user.id)
setUsers(prev => prev.filter(u => u.id !== user.id))
toast.success(t('admin.toast.userDeleted'))
} catch (err) {
toast.error(err.response?.data?.error || t('admin.toast.deleteError'))
}
}
return (
<div className="min-h-screen bg-slate-50">
<Navbar />
<div className="pt-14">
<div className="max-w-6xl mx-auto px-4 py-8">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center">
<Shield className="w-5 h-5 text-slate-700" />
</div>
<div>
<h1 className="text-2xl font-bold text-slate-900">Administration</h1>
<p className="text-slate-500 text-sm">{t('admin.subtitle')}</p>
</div>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4 mb-6">
{[
{ label: t('admin.stats.users'), value: stats.totalUsers, icon: Users },
{ label: t('admin.stats.trips'), value: stats.totalTrips, icon: Briefcase },
{ label: t('admin.stats.places'), value: stats.totalPlaces, icon: Map },
{ label: t('admin.stats.photos'), value: stats.totalPhotos || 0, icon: Camera },
{ label: t('admin.stats.files'), value: stats.totalFiles || 0, icon: FileText },
].map(({ label, value, icon: Icon }) => (
<div key={label} className="rounded-xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center gap-4">
<Icon className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
<div>
<p className="text-xl font-bold" style={{ color: 'var(--text-primary)' }}>{value}</p>
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{label}</p>
</div>
</div>
</div>
))}
</div>
)}
{/* Tabs */}
<div className="flex gap-1 mb-6 bg-white border border-slate-200 rounded-xl p-1 w-fit">
{TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
activeTab === tab.id
? 'bg-slate-900 text-white'
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab content */}
{activeTab === 'users' && (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
<h2 className="font-semibold text-slate-900">{t('admin.tabs.users')} ({users.length})</h2>
<button
onClick={() => setShowCreateUser(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors"
>
<UserPlus className="w-4 h-4" />
{t('admin.createUser')}
</button>
</div>
{isLoading ? (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto"></div>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left text-xs font-medium text-slate-500 uppercase tracking-wider border-b border-slate-100 bg-slate-50">
<th className="px-5 py-3">{t('admin.table.user')}</th>
<th className="px-5 py-3">{t('admin.table.email')}</th>
<th className="px-5 py-3">{t('admin.table.role')}</th>
<th className="px-5 py-3">{t('admin.table.created')}</th>
<th className="px-5 py-3 text-right">{t('admin.table.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{users.map(u => (
<tr key={u.id} className={`hover:bg-slate-50 transition-colors ${u.id === currentUser?.id ? 'bg-slate-50/60' : ''}`}>
<td className="px-5 py-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-sm font-medium text-slate-700">
{u.username.charAt(0).toUpperCase()}
</div>
<div>
<p className="text-sm font-medium text-slate-900">{u.username}</p>
{u.id === currentUser?.id && (
<span className="text-xs text-slate-500">{t('admin.you')}</span>
)}
</div>
</div>
</td>
<td className="px-5 py-3 text-sm text-slate-600">{u.email}</td>
<td className="px-5 py-3">
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full ${
u.role === 'admin'
? 'bg-slate-900 text-white'
: 'bg-slate-100 text-slate-600'
}`}>
{u.role === 'admin' && <Shield className="w-3 h-3" />}
{u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleUser')}
</span>
</td>
<td className="px-5 py-3 text-sm text-slate-500">
{new Date(u.created_at).toLocaleDateString('de-DE')}
</td>
<td className="px-5 py-3">
<div className="flex items-center gap-2 justify-end">
<button
onClick={() => handleEditUser(u)}
className="p-1.5 text-slate-400 hover:text-slate-900 hover:bg-slate-100 rounded-lg transition-colors"
title={t('admin.editUser')}
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteUser(u)}
disabled={u.id === currentUser?.id}
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
title={t('admin.deleteUserTitle')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{activeTab === 'categories' && <CategoryManager />}
{activeTab === 'settings' && (
<div className="space-y-6">
{/* Registration Toggle */}
<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.allowRegistration')}</h2>
</div>
<div className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.allowRegistration')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.allowRegistrationHint')}</p>
</div>
<button
onClick={() => handleToggleRegistration(!allowRegistration)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
allowRegistration ? 'bg-slate-900' : 'bg-slate-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
allowRegistration ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</div>
{/* API Keys */}
<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.apiKeys')}</h2>
</div>
<div className="p-6 space-y-4">
{/* Google Maps Key */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.mapsKey')}</label>
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showKeys.maps ? 'text' : 'password'}
value={mapsKey}
onChange={e => setMapsKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
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"
/>
<button
type="button"
onClick={() => toggleKey('maps')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showKeys.maps ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<button
onClick={() => handleValidateKey('maps')}
disabled={!mapsKey || validating.maps}
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.maps ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : validation.maps === true ? (
<CheckCircle className="w-4 h-4 text-emerald-500" />
) : validation.maps === false ? (
<XCircle className="w-4 h-4 text-red-500" />
) : null}
{t('admin.validateKey')}
</button>
</div>
<p className="text-xs text-slate-400 mt-1">{t('admin.mapsKeyHint')}</p>
{validation.maps === 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.maps === 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>
{/* OpenWeatherMap Key */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.weatherKey')}</label>
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showKeys.weather ? 'text' : 'password'}
value={weatherKey}
onChange={e => setWeatherKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
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"
/>
<button
type="button"
onClick={() => toggleKey('weather')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showKeys.weather ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</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>
<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>
<button
onClick={handleSaveApiKeys}
disabled={savingKeys}
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"
>
{savingKeys ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
{t('common.save')}
</button>
</div>
</div>
</div>
)}
{activeTab === 'backup' && <BackupPanel />}
</div>
</div>
{/* Create user modal */}
<Modal
isOpen={showCreateUser}
onClose={() => setShowCreateUser(false)}
title={t('admin.createUser')}
size="sm"
footer={
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowCreateUser(false)}
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50"
>
{t('common.cancel')}
</button>
<button
onClick={handleCreateUser}
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 text-white rounded-lg"
>
{t('admin.createUser')}
</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')} *</label>
<input
type="text"
value={createForm.username}
onChange={e => setCreateForm(f => ({ ...f, username: e.target.value }))}
placeholder={t('settings.username')}
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 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.email')} *</label>
<input
type="email"
value={createForm.email}
onChange={e => setCreateForm(f => ({ ...f, email: e.target.value }))}
placeholder={t('common.email')}
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 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')} *</label>
<input
type="password"
value={createForm.password}
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
placeholder={t('common.password')}
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 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
<CustomSelect
value={createForm.role}
onChange={value => setCreateForm(f => ({ ...f, role: value }))}
options={[
{ value: 'user', label: t('settings.roleUser') },
{ value: 'admin', label: t('settings.roleAdmin') },
]}
/>
</div>
</div>
</Modal>
{/* Edit user modal */}
<Modal
isOpen={!!editingUser}
onClose={() => setEditingUser(null)}
title={t('admin.editUser')}
size="sm"
footer={
<div className="flex gap-3 justify-end">
<button
onClick={() => setEditingUser(null)}
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50"
>
{t('common.cancel')}
</button>
<button
onClick={handleSaveUser}
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 text-white rounded-lg"
>
{t('common.save')}
</button>
</div>
}
>
{editingUser && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
<input
type="text"
value={editForm.username}
onChange={e => setEditForm(f => ({ ...f, username: 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 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.email')}</label>
<input
type="email"
value={editForm.email}
onChange={e => setEditForm(f => ({ ...f, email: 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 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.newPassword')} <span className="text-slate-400 font-normal">({t('admin.newPasswordHint')})</span></label>
<input
type="password"
value={editForm.password}
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
placeholder={t('admin.newPasswordPlaceholder')}
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 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
<CustomSelect
value={editForm.role}
onChange={value => setEditForm(f => ({ ...f, role: value }))}
options={[
{ value: 'user', label: t('settings.roleUser') },
{ value: 'admin', label: t('settings.roleAdmin') },
]}
/>
</div>
</div>
)}
</Modal>
</div>
)
}
File diff suppressed because it is too large Load Diff
+518
View File
@@ -0,0 +1,518 @@
import React, { useEffect, useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from '../i18n'
import { useSettingsStore } from '../store/settingsStore'
import Navbar from '../components/Layout/Navbar'
import apiClient from '../api/client'
import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X } from 'lucide-react'
import L from 'leaflet'
import type { AtlasPlace, GeoJsonFeatureCollection, TranslationFn } from '../types'
// Convert country code to flag emoji
interface AtlasCountry {
code: string
tripCount: number
placeCount: number
firstVisit?: string | null
lastVisit?: string | null
}
interface AtlasStats {
totalTrips: number
totalPlaces: number
totalCountries: number
totalDays: number
totalCities?: number
}
interface AtlasData {
countries: AtlasCountry[]
stats: AtlasStats
mostVisited?: AtlasCountry | null
continents?: Record<string, number>
lastTrip?: { id: number; title: string; countryCode?: string } | null
nextTrip?: { id: number; title: string; countryCode?: string } | null
streak?: number
firstYear?: number
tripsThisYear?: number
}
interface CountryDetail {
places: AtlasPlace[]
trips: { id: number; title: string }[]
}
function MobileStats({ data, stats, countries, resolveName, t, dark }: { data: AtlasData | null; stats: AtlasStats; countries: AtlasCountry[]; resolveName: (code: string) => string; t: TranslationFn; dark: boolean }): React.ReactElement {
const tp = dark ? '#f1f5f9' : '#0f172a'
const tf = dark ? '#475569' : '#94a3b8'
const { continents, lastTrip, nextTrip, streak, firstYear, tripsThisYear } = data || {}
const CL = { 'Europe': t('atlas.europe'), 'Asia': t('atlas.asia'), 'North America': t('atlas.northAmerica'), 'South America': t('atlas.southAmerica'), 'Africa': t('atlas.africa'), 'Oceania': t('atlas.oceania') }
const thisYear = new Date().getFullYear()
return (
<div className="space-y-4">
{/* Stats grid */}
<div className="grid grid-cols-5 gap-2">
{[[stats.totalCountries, t('atlas.countries')], [stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => (
<div key={i} className="text-center py-2">
<p className="text-xl font-black tabular-nums" style={{ color: tp }}>{v}</p>
<p className="text-[9px] font-semibold uppercase tracking-wide" style={{ color: tf }}>{l}</p>
</div>
))}
</div>
{/* Continents */}
<div className="grid grid-cols-6 gap-1">
{['Europe', 'Asia', 'North America', 'South America', 'Africa', 'Oceania'].map(cont => {
const count = continents?.[cont] || 0
return (
<div key={cont} className="text-center py-1">
<p className="text-base font-bold tabular-nums" style={{ color: count > 0 ? tp : (dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.12)') }}>{count}</p>
<p className="text-[8px] font-semibold uppercase" style={{ color: count > 0 ? tf : (dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)') }}>{CL[cont]}</p>
</div>
)
})}
</div>
{/* Highlights */}
<div className="flex gap-3">
{streak > 0 && (
<div className="text-center flex-1 py-2">
<p className="text-xl font-black tabular-nums" style={{ color: tp }}>{streak}</p>
<p className="text-[9px] font-semibold uppercase tracking-wide" style={{ color: tf }}>{streak === 1 ? t('atlas.yearInRow') : t('atlas.yearsInRow')}</p>
</div>
)}
{tripsThisYear > 0 && (
<div className="text-center flex-1 py-2">
<p className="text-xl font-black tabular-nums" style={{ color: tp }}>{tripsThisYear}</p>
<p className="text-[9px] font-semibold uppercase tracking-wide" style={{ color: tf }}>{tripsThisYear === 1 ? t('atlas.tripIn') : t('atlas.tripsIn')} {thisYear}</p>
</div>
)}
</div>
</div>
)
}
function countryCodeToFlag(code: string): string {
if (!code || code.length !== 2) return ''
return String.fromCodePoint(...[...code.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65))
}
function useCountryNames(language: string): (code: string) => string {
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
useEffect(() => {
try {
const dn = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' })
setResolver(() => (code: string) => { try { return dn.of(code) || code } catch { return code } })
} catch { /* */ }
}, [language])
return resolver
}
// Map visited country codes to ISO-3166 alpha3 (GeoJSON uses alpha3)
const A2_TO_A3: Record<string, string> = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BR":"BRA","BE":"BEL","BG":"BGR","CA":"CAN","CL":"CHL","CN":"CHN","CO":"COL","HR":"HRV","CZ":"CZE","DK":"DNK","EG":"EGY","EE":"EST","FI":"FIN","FR":"FRA","DE":"DEU","GR":"GRC","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JP":"JPN","KE":"KEN","KR":"KOR","LV":"LVA","LT":"LTU","LU":"LUX","MY":"MYS","MX":"MEX","MA":"MAR","NL":"NLD","NZ":"NZL","NO":"NOR","PK":"PAK","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","RO":"ROU","RU":"RUS","SA":"SAU","RS":"SRB","SK":"SVK","SI":"SVN","ZA":"ZAF","ES":"ESP","SE":"SWE","CH":"CHE","TH":"THA","TR":"TUR","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","VN":"VNM","NG":"NGA"}
export default function AtlasPage(): React.ReactElement {
const { t, language } = useTranslation()
const { settings } = useSettingsStore()
const navigate = useNavigate()
const resolveName = useCountryNames(language)
const dm = settings.dark_mode
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const mapRef = useRef<HTMLDivElement>(null)
const mapInstance = useRef<L.Map | null>(null)
const geoLayerRef = useRef<L.GeoJSON | null>(null)
const glareRef = useRef<HTMLDivElement>(null)
const borderGlareRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const handlePanelMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
const rect = panelRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
// Subtle inner glow
glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)`
glareRef.current.style.opacity = '1'
// Border glow that follows cursor
borderGlareRef.current.style.opacity = '1'
borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
borderGlareRef.current.style.WebkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
}
const handlePanelMouseLeave = () => {
if (glareRef.current) glareRef.current.style.opacity = '0'
if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0'
}
const [data, setData] = useState<AtlasData | null>(null)
const [loading, setLoading] = useState<boolean>(true)
const [sidebarOpen, setSidebarOpen] = useState<boolean>(true)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<boolean>(false)
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
// Load atlas data
useEffect(() => {
apiClient.get('/addons/atlas/stats').then(r => {
setData(r.data)
setLoading(false)
}).catch(() => setLoading(false))
}, [])
// Load GeoJSON world data (direct GeoJSON, no conversion needed)
useEffect(() => {
fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson')
.then(r => r.json())
.then(geo => setGeoData(geo))
.catch(() => {})
}, [])
// Initialize map — runs after loading is done and mapRef is available
useEffect(() => {
if (loading || !mapRef.current) return
if (mapInstance.current) { mapInstance.current.remove(); mapInstance.current = null }
const map = L.map(mapRef.current, {
center: [25, 0],
zoom: 3,
minZoom: 3,
maxZoom: 7,
zoomControl: false,
attributionControl: false,
maxBounds: [[-90, -220], [90, 220]],
maxBoundsViscosity: 1.0,
fadeAnimation: false,
preferCanvas: true,
})
L.control.zoom({ position: 'bottomright' }).addTo(map)
const tileUrl = dark
? 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png'
L.tileLayer(tileUrl, {
maxZoom: 8,
keepBuffer: 25,
updateWhenZooming: true,
updateWhenIdle: false,
tileSize: 256,
zoomOffset: 0,
crossOrigin: true,
loading: true,
}).addTo(map)
// Preload adjacent zoom level tiles
L.tileLayer(tileUrl, {
maxZoom: 8,
keepBuffer: 10,
opacity: 0,
tileSize: 256,
crossOrigin: true,
}).addTo(map)
mapInstance.current = map
return () => { map.remove(); mapInstance.current = null }
}, [dark, loading])
// Render GeoJSON countries
useEffect(() => {
if (!mapInstance.current || !geoData || !data) return
const visitedA3 = new Set(data.countries.map(c => A2_TO_A3[c.code]).filter(Boolean))
const countryMap = {}
data.countries.forEach(c => { if (A2_TO_A3[c.code]) countryMap[A2_TO_A3[c.code]] = c })
if (geoLayerRef.current) {
mapInstance.current.removeLayer(geoLayerRef.current)
}
// Generate deterministic color per country code
const VISITED_COLORS = ['#6366f1','#ec4899','#14b8a6','#f97316','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#e11d48','#0d9488','#7c3aed','#2563eb','#dc2626','#059669','#d946ef']
// Assign colors in order of visit (by index in countries array) so no two neighbors share a color easily
const visitedA3List = [...visitedA3]
const colorMap = {}
visitedA3List.forEach((a3, i) => { colorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] })
const colorForCode = (a3) => colorMap[a3] || VISITED_COLORS[0]
const canvasRenderer = L.canvas({ padding: 0.5, tolerance: 5 })
geoLayerRef.current = L.geoJSON(geoData, {
renderer: canvasRenderer,
interactive: true,
bubblingMouseEvents: false,
style: (feature) => {
const a3 = feature.properties?.ISO_A3 || feature.properties?.ADM0_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
const visited = visitedA3.has(a3)
return {
fillColor: visited ? colorForCode(a3) : (dark ? '#1e1e2e' : '#e2e8f0'),
fillOpacity: visited ? 0.7 : 0.3,
color: dark ? '#333' : '#cbd5e1',
weight: 0.5,
}
},
onEachFeature: (feature, layer) => {
const a3 = feature.properties?.ISO_A3 || feature.properties?.ADM0_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
const c = countryMap[a3]
if (c) {
const name = resolveName(c.code)
const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { month: 'short', year: 'numeric' }) }
const tooltipHtml = `
<div style="display:flex;flex-direction:column;gap:8px;min-width:160px">
<div style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;padding-bottom:6px;border-bottom:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}">${name}</div>
<div style="display:flex;gap:14px">
<div><span style="font-size:16px;font-weight:800">${c.tripCount}</span> <span style="font-size:10px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">${c.tripCount === 1 ? t('atlas.tripSingular') : t('atlas.tripPlural')}</span></div>
<div><span style="font-size:16px;font-weight:800">${c.placeCount}</span> <span style="font-size:10px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">${c.placeCount === 1 ? t('atlas.placeVisited') : t('atlas.placesVisited')}</span></div>
</div>
<div style="display:flex;gap:2px;border-top:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'};padding-top:8px">
<div style="flex:1;display:flex;flex-direction:column;gap:2px">
<span style="font-size:9px;text-transform:uppercase;letter-spacing:0.08em;opacity:0.4">${t('atlas.firstVisit')}</span>
<span style="font-size:12px;font-weight:700">${formatDate(c.firstVisit)}</span>
</div>
<div style="flex:1;display:flex;flex-direction:column;gap:2px">
<span style="font-size:9px;text-transform:uppercase;letter-spacing:0.08em;opacity:0.4">${t('atlas.lastVisitLabel')}</span>
<span style="font-size:12px;font-weight:700">${formatDate(c.lastVisit)}</span>
</div>
</div>
</div>
</div>`
layer.bindTooltip(tooltipHtml, {
sticky: false, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
})
layer.on('click', () => loadCountryDetail(c.code))
layer.on('mouseover', (e) => {
e.target.setStyle({ fillOpacity: 0.9, weight: 2, color: dark ? '#818cf8' : '#4f46e5' })
})
layer.on('mouseout', (e) => {
geoLayerRef.current.resetStyle(e.target)
})
}
}
}).addTo(mapInstance.current)
}, [geoData, data, dark])
const loadCountryDetail = async (code: string): Promise<void> => {
setSelectedCountry(code)
try {
const r = await apiClient.get(`/addons/atlas/country/${code}`)
setCountryDetail(r.data)
} catch { /* */ }
}
const stats = data?.stats || { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 }
const countries = data?.countries || []
if (loading) {
return (
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
<Navbar />
<div className="flex items-center justify-center" style={{ paddingTop: 'var(--nav-h)', minHeight: 'calc(100vh - var(--nav-h))' }}>
<div className="w-8 h-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
</div>
</div>
)
}
return (
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
<Navbar />
<div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0 }}>
{/* Map */}
<div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} />
{/* Mobile: Bottom bar */}
<div className="md:hidden absolute bottom-3 left-0 right-0 z-10 flex justify-center" style={{ touchAction: 'manipulation' }}>
<div className="flex items-center gap-4 px-5 py-4 rounded-2xl"
style={{ background: dark ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.5)', backdropFilter: 'blur(16px)' }}>
{/* Countries highlighted */}
<div className="text-center px-3 py-1.5 rounded-xl" style={{ background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' }}>
<p className="text-3xl font-black tabular-nums leading-none" style={{ color: 'var(--text-primary)' }}>{stats.totalCountries}</p>
<p className="text-[9px] font-semibold uppercase tracking-wide mt-1" style={{ color: 'var(--text-faint)' }}>{t('atlas.countries')}</p>
</div>
{[[stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => (
<div key={i} className="text-center px-1">
<p className="text-xl font-black tabular-nums leading-none" style={{ color: 'var(--text-primary)' }}>{v}</p>
<p className="text-[9px] font-semibold uppercase tracking-wide mt-1" style={{ color: 'var(--text-faint)' }}>{l}</p>
</div>
))}
</div>
</div>
{/* Desktop Panel — bottom center, glass effect */}
<div
ref={panelRef}
onMouseMove={handlePanelMouseMove}
onMouseLeave={handlePanelMouseLeave}
className="hidden md:flex flex-col absolute z-10 overflow-hidden transition-all duration-300"
style={{
bottom: 16,
left: '50%',
transform: 'translateX(-50%)',
width: 'fit-content',
background: dark ? 'rgba(10,10,15,0.55)' : 'rgba(255,255,255,0.2)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: '1px solid ' + (dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'),
borderRadius: 20,
boxShadow: dark
? '0 8px 32px rgba(0,0,0,0.3)'
: '0 8px 32px rgba(0,0,0,0.08)',
}}
>
{/* Liquid glass glare effect */}
<div ref={glareRef} className="absolute inset-0 pointer-events-none" style={{ opacity: 0, transition: 'opacity 0.3s ease', borderRadius: 20 }} />
{/* Border glow that follows cursor */}
<div ref={borderGlareRef} className="absolute inset-0 pointer-events-none" style={{
opacity: 0, transition: 'opacity 0.3s ease', borderRadius: 20,
border: dark ? '1.5px solid rgba(255,255,255,0.5)' : '2px solid rgba(0,0,0,0.15)',
}} />
<SidebarContent
data={data} stats={stats} countries={countries} selectedCountry={selectedCountry}
countryDetail={countryDetail} resolveName={resolveName}
onCountryClick={loadCountryDetail} onTripClick={(id) => navigate(`/trips/${id}`)}
t={t} dark={dark}
/>
</div>
</div>
</div>
)
}
interface SidebarContentProps {
data: AtlasData | null
stats: AtlasStats
countries: AtlasCountry[]
selectedCountry: string | null
countryDetail: CountryDetail | null
resolveName: (code: string) => string
onCountryClick: (code: string) => void
onTripClick: (id: number) => void
t: TranslationFn
dark: boolean
}
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, t, dark }: SidebarContentProps): React.ReactElement {
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
const tp = dark ? '#f1f5f9' : '#0f172a'
const tm = dark ? '#94a3b8' : '#64748b'
const tf = dark ? '#475569' : '#94a3b8'
const accent = '#818cf8'
const { mostVisited, continents, lastTrip, nextTrip, streak, firstYear, tripsThisYear } = data || {}
const contEntries = continents ? Object.entries(continents).sort((a, b) => b[1] - a[1]) : []
const maxCont = contEntries.length > 0 ? contEntries[0][1] : 1
const CL = { 'Europe': t('atlas.europe'), 'Asia': t('atlas.asia'), 'North America': t('atlas.northAmerica'), 'South America': t('atlas.southAmerica'), 'Africa': t('atlas.africa'), 'Oceania': t('atlas.oceania') }
const contColors = ['#818cf8', '#f472b6', '#34d399', '#fbbf24', '#fb923c', '#22d3ee']
if (countries.length === 0 && !lastTrip) {
return (
<div className="p-8 text-center">
<Globe size={28} className="mx-auto mb-2" style={{ color: tf, opacity: 0.4 }} />
<p className="text-sm font-medium" style={{ color: tm }}>{t('atlas.noData')}</p>
<p className="text-xs mt-1" style={{ color: tf }}>{t('atlas.noDataHint')}</p>
</div>
)
}
const thisYear = new Date().getFullYear()
const divider = `2px solid ${bg(0.08)}`
return (
<div className="flex items-stretch justify-center">
{/* ═══ SECTION 1: Numbers ═══ */}
{/* Countries hero */}
<div className="flex items-baseline gap-1.5 px-5 py-4 mx-2 my-2 rounded-xl" style={{ background: bg(0.08) }}>
<span className="text-5xl font-black tabular-nums leading-none" style={{ color: tp }}>{stats.totalCountries}</span>
<span className="text-sm font-medium" style={{ color: tm }}>{t('atlas.countries')}</span>
</div>
{/* Other stats */}
{[[stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => (
<div key={i} className="flex flex-col items-center justify-center px-3 py-5 shrink-0">
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{v}</span>
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide whitespace-nowrap" style={{ color: tf }}>{l}</span>
</div>
))}
{/* ═══ DIVIDER ═══ */}
<div style={{ width: 2, background: bg(0.08), margin: '12px 14px' }} />
{/* ═══ SECTION 2: Continents ═══ */}
<div className="flex items-center gap-4 px-3 py-4 shrink-0">
{['Europe', 'Asia', 'North America', 'South America', 'Africa', 'Oceania'].map((cont) => {
const count = continents?.[cont] || 0
const active = count > 0
return (
<div key={cont} className="flex flex-col items-center shrink-0">
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: active ? tp : bg(0.15) }}>{count}</span>
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide whitespace-nowrap" style={{ color: active ? tf : bg(0.1) }}>{CL[cont]}</span>
</div>
)
})}
</div>
{/* ═══ DIVIDER ═══ */}
<div style={{ width: 2, background: bg(0.08), margin: '12px 14px' }} />
{/* ═══ SECTION 3: Highlights & Streaks ═══ */}
<div className="flex items-center gap-5 px-3 py-4">
{/* Last trip */}
{lastTrip && (
<button onClick={() => onTripClick(lastTrip.id)} className="flex items-center gap-2.5 text-left transition-opacity hover:opacity-75">
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-lg shrink-0" style={{ background: bg(0.06) }}>
{lastTrip.countryCode ? countryCodeToFlag(lastTrip.countryCode) : <MapPin size={16} style={{ color: tm }} />}
</div>
<div className="min-w-0">
<p className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: tf }}>{t('atlas.lastTrip')}</p>
<p className="text-[13px] font-bold truncate" style={{ color: tp }}>{lastTrip.title}</p>
</div>
</button>
)}
{/* Streak */}
{streak > 0 && (
<div className="flex flex-col items-center justify-center px-3">
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{streak}</span>
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight whitespace-nowrap" style={{ color: tf }}>
{streak === 1 ? t('atlas.yearInRow') : t('atlas.yearsInRow')}
</span>
</div>
)}
{/* This year */}
{tripsThisYear > 0 && (
<div className="flex flex-col items-center justify-center px-3">
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{tripsThisYear}</span>
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight whitespace-nowrap" style={{ color: tf }}>
{tripsThisYear === 1 ? t('atlas.tripIn') : t('atlas.tripsIn')} {thisYear}
</span>
</div>
)}
</div>
{/* ═══ Country detail overlay ═══ */}
{selectedCountry && countryDetail && (
<>
<div style={{ width: 2, background: bg(0.08), margin: '12px 0' }} />
<div className="flex items-center gap-3 px-6 py-4">
<span className="text-3xl">{countryCodeToFlag(selectedCountry)}</span>
<div>
<p className="text-sm font-bold" style={{ color: tp }}>{resolveName(selectedCountry)}</p>
<p className="text-[10px] mb-1" style={{ color: tf }}>{countryDetail.places.length} {t('atlas.places')} · {countryDetail.trips.length} Trips</p>
<div className="flex flex-wrap gap-1">
{countryDetail.trips.slice(0, 3).map(trip => (
<button key={trip.id} onClick={() => onTripClick(trip.id)}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-semibold transition-opacity hover:opacity-75"
style={{ background: bg(0.08), color: tp }}>
<Briefcase size={9} style={{ color: tm }} />
{trip.title}
</button>
))}
</div>
</div>
</div>
</>
)}
</div>
)
}
@@ -2,26 +2,47 @@ import React, { useEffect, useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { tripsApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { useTranslation } from '../i18n'
import { getApiErrorMessage } from '../types'
import Navbar from '../components/Layout/Navbar'
import TravelStats from '../components/Dashboard/TravelStats'
import DemoBanner from '../components/Layout/DemoBanner'
import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
import TripFormModal from '../components/Trips/TripFormModal'
import { useToast } from '../components/shared/Toast'
import {
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
Archive, ArchiveRestore, Clock, MapPin,
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft,
} from 'lucide-react'
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
interface DashboardTrip {
id: number
title: string
description?: string | null
start_date?: string | null
end_date?: string | null
cover_image?: string | null
is_archived?: boolean
is_owner?: boolean
owner_username?: string
day_count?: number
place_count?: number
[key: string]: string | number | boolean | null | undefined
}
function daysUntil(dateStr) {
const font: React.CSSProperties = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
const MS_PER_DAY = 86400000
function daysUntil(dateStr: string | null | undefined): number | null {
if (!dateStr) return null
const today = new Date(); today.setHours(0, 0, 0, 0)
const d = new Date(dateStr + 'T00:00:00'); d.setHours(0, 0, 0, 0)
return Math.round((d - today) / 86400000)
return Math.round((d - today) / MS_PER_DAY)
}
function getTripStatus(trip) {
function getTripStatus(trip: DashboardTrip): string | null {
const today = new Date().toISOString().split('T')[0]
if (trip.start_date && trip.end_date && trip.start_date <= today && trip.end_date >= today) return 'ongoing'
const until = daysUntil(trip.start_date)
@@ -32,17 +53,17 @@ function getTripStatus(trip) {
return 'past'
}
function formatDate(dateStr, locale = 'de-DE') {
function formatDate(dateStr: string | null | undefined, locale: string = 'de-DE'): string | null {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })
}
function formatDateShort(dateStr, locale = 'de-DE') {
function formatDateShort(dateStr: string | null | undefined, locale: string = 'de-DE'): string | null {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
}
function sortTrips(trips) {
function sortTrips(trips: DashboardTrip[]): DashboardTrip[] {
const today = new Date().toISOString().split('T')[0]
function rank(t) {
if (t.start_date && t.end_date && t.start_date <= today && t.end_date >= today) return 0 // ongoing
@@ -69,10 +90,63 @@ const GRADIENTS = [
'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)',
'linear-gradient(135deg, #96fbc4 0%, #f9f586 100%)',
]
function tripGradient(id) { return GRADIENTS[id % GRADIENTS.length] }
function tripGradient(id: number): string { return GRADIENTS[id % GRADIENTS.length] }
// ── Liquid Glass hover effect ────────────────────────────────────────────────
interface LiquidGlassProps {
children: React.ReactNode
dark: boolean
style?: React.CSSProperties
className?: string
onClick?: () => void
}
function LiquidGlass({ children, dark, style, className = '', onClick }: LiquidGlassProps): React.ReactElement {
const ref = useRef<HTMLDivElement>(null)
const glareRef = useRef<HTMLDivElement>(null)
const borderRef = useRef<HTMLDivElement>(null)
const onMove = (e: React.MouseEvent<HTMLDivElement>): void => {
if (!ref.current || !glareRef.current || !borderRef.current) return
const rect = ref.current.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
glareRef.current.style.background = `radial-gradient(circle 250px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'} 0%, transparent 70%)`
glareRef.current.style.opacity = '1'
borderRef.current.style.opacity = '1'
borderRef.current.style.maskImage = `radial-gradient(circle 120px at ${x}px ${y}px, black 0%, transparent 100%)`
borderRef.current.style.WebkitMaskImage = `radial-gradient(circle 120px at ${x}px ${y}px, black 0%, transparent 100%)`
}
const onLeave = () => {
if (glareRef.current) glareRef.current.style.opacity = '0'
if (borderRef.current) borderRef.current.style.opacity = '0'
}
return (
<div ref={ref} onMouseMove={onMove} onMouseLeave={onLeave} onClick={onClick} className={className}
style={{ position: 'relative', overflow: 'hidden', ...style }}>
<div ref={glareRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.3s', borderRadius: 'inherit', zIndex: 1 }} />
<div ref={borderRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.3s', borderRadius: 'inherit', zIndex: 1,
border: dark ? '1.5px solid rgba(255,255,255,0.4)' : '1.5px solid rgba(0,0,0,0.12)',
}} />
{children}
</div>
)
}
// ── Spotlight Card (next upcoming trip) ─────────────────────────────────────
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) {
interface TripCardProps {
trip: DashboardTrip
onEdit: (trip: DashboardTrip) => void
onDelete: (trip: DashboardTrip) => void
onArchive: (id: number) => void
onClick: (trip: DashboardTrip) => void
t: (key: string, params?: Record<string, string | number | null>) => string
locale: string
dark?: boolean
}
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement {
const status = getTripStatus(trip)
const coverBg = trip.cover_image
@@ -80,7 +154,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }
: tripGradient(trip.id)
return (
<div style={{ marginBottom: 32, borderRadius: 20, overflow: 'hidden', boxShadow: '0 8px 40px rgba(0,0,0,0.13)', position: 'relative', cursor: 'pointer' }}
<LiquidGlass dark={dark} style={{ marginBottom: 32, borderRadius: 20, boxShadow: '0 8px 40px rgba(0,0,0,0.13)', cursor: 'pointer' }}
onClick={() => onClick(trip)}>
{/* Cover / Background */}
<div style={{ height: 300, background: coverBg, position: 'relative' }}>
@@ -148,12 +222,12 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }
</div>
</div>
</div>
</div>
</LiquidGlass>
)
}
// ── Regular Trip Card ────────────────────────────────────────────────────────
function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) {
function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
const status = getTripStatus(trip)
const [hovered, setHovered] = useState(false)
@@ -167,9 +241,9 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) {
onMouseLeave={() => setHovered(false)}
onClick={() => onClick(trip)}
style={{
background: 'var(--bg-card)', borderRadius: 16, overflow: 'hidden', cursor: 'pointer',
border: '1px solid var(--border-primary)', transition: 'all 0.18s',
boxShadow: hovered ? '0 8px 28px rgba(0,0,0,0.10)' : '0 1px 4px rgba(0,0,0,0.04)',
background: hovered ? 'var(--bg-tertiary)' : 'var(--bg-card)', borderRadius: 16, overflow: 'hidden', cursor: 'pointer',
border: `1px solid ${hovered ? 'var(--text-faint)' : 'var(--border-primary)'}`, transition: 'all 0.18s',
boxShadow: hovered ? '0 8px 28px rgba(0,0,0,0.15)' : '0 1px 4px rgba(0,0,0,0.04)',
transform: hovered ? 'translateY(-2px)' : 'none',
}}
>
@@ -242,7 +316,17 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) {
}
// ── Archived Trip Row ────────────────────────────────────────────────────────
function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }) {
interface ArchivedRowProps {
trip: DashboardTrip
onEdit: (trip: DashboardTrip) => void
onUnarchive: (id: number) => void
onDelete: (trip: DashboardTrip) => void
onClick: (trip: DashboardTrip) => void
t: (key: string, params?: Record<string, string | number | null>) => string
locale: string
}
function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }: ArchivedRowProps): React.ReactElement {
return (
<div onClick={() => onClick(trip)} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px',
@@ -285,7 +369,7 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function Stat({ value, label }) {
function Stat({ value, label }: { value: number | string; label: string }): React.ReactElement {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>{value}</span>
@@ -294,7 +378,7 @@ function Stat({ value, label }) {
)
}
function CardAction({ onClick, icon, label, danger }) {
function CardAction({ onClick, icon, label, danger }: { onClick: () => void; icon: React.ReactNode; label: string; danger?: boolean }): React.ReactElement {
return (
<button onClick={onClick} style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 8,
@@ -308,7 +392,7 @@ function CardAction({ onClick, icon, label, danger }) {
)
}
function IconBtn({ onClick, title, danger, loading, children }) {
function IconBtn({ onClick, title, danger, loading, children }: { onClick: () => void; title: string; danger?: boolean; loading?: boolean; children: React.ReactNode }): React.ReactElement {
return (
<button onClick={onClick} title={title} disabled={loading} style={{
width: 32, height: 32, borderRadius: 99, border: '1px solid rgba(255,255,255,0.25)',
@@ -324,7 +408,7 @@ function IconBtn({ onClick, title, danger, loading, children }) {
}
// ── Skeleton ─────────────────────────────────────────────────────────────────
function SkeletonCard() {
function SkeletonCard(): React.ReactElement {
return (
<div style={{ background: 'white', borderRadius: 16, overflow: 'hidden', border: '1px solid #f3f4f6' }}>
<div style={{ height: 120, background: '#f3f4f6', animation: 'pulse 1.5s ease-in-out infinite' }} />
@@ -337,17 +421,34 @@ function SkeletonCard() {
}
// ── Main Page ────────────────────────────────────────────────────────────────
export default function DashboardPage() {
const [trips, setTrips] = useState([])
const [archivedTrips, setArchivedTrips] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editingTrip, setEditingTrip] = useState(null)
const [showArchived, setShowArchived] = useState(false)
export default function DashboardPage(): React.ReactElement {
const [trips, setTrips] = useState<DashboardTrip[]>([])
const [archivedTrips, setArchivedTrips] = useState<DashboardTrip[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true)
const [showForm, setShowForm] = useState<boolean>(false)
const [editingTrip, setEditingTrip] = useState<DashboardTrip | null>(null)
const [showArchived, setShowArchived] = useState<boolean>(false)
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile'>(false)
const navigate = useNavigate()
const toast = useToast()
const { t, locale } = useTranslation()
const { demoMode } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const dm = settings.dark_mode
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const showCurrency = settings.dashboard_currency !== 'off'
const showTimezone = settings.dashboard_timezone !== 'off'
const showSidebar = showCurrency || showTimezone
useEffect(() => {
if (showWidgetSettings === 'mobile') {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => { document.body.style.overflow = '' }
}, [showWidgetSettings])
useEffect(() => { loadTrips() }, [])
@@ -372,8 +473,9 @@ export default function DashboardPage() {
const data = await tripsApi.create(tripData)
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.created'))
} catch (err) {
throw new Error(err.response?.data?.error || t('dashboard.toast.createError'))
return data
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, t('dashboard.toast.createError')))
}
}
@@ -382,8 +484,8 @@ export default function DashboardPage() {
const data = await tripsApi.update(editingTrip.id, tripData)
setTrips(prev => sortTrips(prev.map(t => t.id === editingTrip.id ? data.trip : t)))
toast.success(t('dashboard.toast.updated'))
} catch (err) {
throw new Error(err.response?.data?.error || t('dashboard.toast.updateError'))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, t('dashboard.toast.updateError')))
}
}
@@ -421,8 +523,8 @@ export default function DashboardPage() {
}
}
const handleCoverUpdate = (tripId, coverImage) => {
const update = t => t.id === tripId ? { ...t, cover_image: coverImage } : t
const handleCoverUpdate = (tripId: number, coverImage: string | null): void => {
const update = (t: DashboardTrip) => t.id === tripId ? { ...t, cover_image: coverImage } : t
setTrips(prev => prev.map(update))
setArchivedTrips(prev => prev.map(update))
}
@@ -435,9 +537,10 @@ export default function DashboardPage() {
const rest = spotlight ? trips.filter(t => t.id !== spotlight.id) : trips
return (
<div style={{ minHeight: '100vh', background: 'var(--bg-secondary)', ...font }}>
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', background: 'var(--bg-secondary)', ...font }}>
<Navbar />
<div style={{ paddingTop: 56 }}>
{demoMode && <DemoBanner />}
<div style={{ flex: 1, overflow: 'auto', overscrollBehavior: 'contain', marginTop: 'var(--nav-h)' }}>
<div style={{ maxWidth: 1300, margin: '0 auto', padding: '32px 20px 60px' }}>
{/* Header */}
@@ -450,21 +553,75 @@ export default function DashboardPage() {
: t('dashboard.subtitle.empty')}
</p>
</div>
<button
onClick={() => { setEditingTrip(null); setShowForm(true) }}
style={{
display: 'flex', alignItems: 'center', gap: 7, padding: '9px 18px',
background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 12,
fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
{/* Widget settings */}
<button
onClick={() => setShowWidgetSettings(s => s ? false : true)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '0 14px',
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
transition: 'background 0.15s, border-color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.borderColor = 'var(--text-faint)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.borderColor = 'var(--border-primary)' }}
>
<Settings size={15} />
</button>
<button
onClick={() => { setEditingTrip(null); setShowForm(true) }}
style={{
display: 'flex', alignItems: 'center', gap: 7, padding: '9px 18px',
background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 12,
fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
<Plus size={15} /> {t('dashboard.newTrip')}
</button>
</button>
</div>
</div>
{/* Widget settings dropdown */}
{showWidgetSettings && (
<div className="rounded-xl border p-3 mb-4 flex items-center gap-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<span className="text-xs font-semibold" style={{ color: 'var(--text-muted)' }}>Widgets:</span>
<label className="flex items-center gap-2 cursor-pointer">
<button onClick={() => updateSetting('dashboard_currency', showCurrency ? 'off' : 'on')}
className="relative inline-flex h-5 w-9 items-center rounded-full transition-colors"
style={{ background: showCurrency ? 'var(--text-primary)' : 'var(--border-primary)' }}>
<span className="absolute left-0.5 h-4 w-4 rounded-full transition-transform duration-200"
style={{ background: 'var(--bg-card)', transform: showCurrency ? 'translateX(16px)' : 'translateX(0)' }} />
</button>
<span className="text-xs" style={{ color: 'var(--text-primary)' }}>{t('dashboard.currency')}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<button onClick={() => updateSetting('dashboard_timezone', showTimezone ? 'off' : 'on')}
className="relative inline-flex h-5 w-9 items-center rounded-full transition-colors"
style={{ background: showTimezone ? 'var(--text-primary)' : 'var(--border-primary)' }}>
<span className="absolute left-0.5 h-4 w-4 rounded-full transition-transform duration-200"
style={{ background: 'var(--bg-card)', transform: showTimezone ? 'translateX(16px)' : 'translateX(0)' }} />
</button>
<span className="text-xs" style={{ color: 'var(--text-primary)' }}>{t('dashboard.timezone')}</span>
</label>
</div>
)}
{/* Mobile widgets button */}
{showSidebar && (
<button
onClick={() => setShowWidgetSettings('mobile')}
className="lg:hidden flex items-center justify-center gap-2 w-full py-2.5 rounded-xl text-xs font-semibold mb-4"
style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)' }}
>
<ArrowRightLeft size={13} style={{ color: 'var(--text-faint)' }} />
{showCurrency && showTimezone ? `${t('dashboard.currency')} & ${t('dashboard.timezone')}` : showCurrency ? t('dashboard.currency') : t('dashboard.timezone')}
</button>
)}
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
{/* Main content */}
<div style={{ flex: 1, minWidth: 0 }}>
@@ -502,7 +659,7 @@ export default function DashboardPage() {
{!isLoading && spotlight && (
<SpotlightCard
trip={spotlight}
t={t} locale={locale}
t={t} locale={locale} dark={dark}
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
onDelete={handleDelete}
onArchive={handleArchive}
@@ -559,14 +716,37 @@ export default function DashboardPage() {
)}
</div>
{/* Stats sidebar */}
<div className="hidden lg:block" style={{ position: 'sticky', top: 80, flexShrink: 0 }}>
<TravelStats />
</div>
{/* Widgets sidebar */}
{showSidebar && (
<div className="hidden lg:flex flex-col gap-4" style={{ position: 'sticky', top: 80, flexShrink: 0, width: 280 }}>
{showCurrency && <LiquidGlass dark={dark} style={{ borderRadius: 16 }}><CurrencyWidget /></LiquidGlass>}
{showTimezone && <LiquidGlass dark={dark} style={{ borderRadius: 16 }}><TimezoneWidget /></LiquidGlass>}
</div>
)}
</div>
</div>
</div>
{/* Mobile widgets bottom sheet */}
{showWidgetSettings === 'mobile' && (
<div className="lg:hidden fixed inset-0 z-50" style={{ background: 'rgba(0,0,0,0.3)', touchAction: 'none' }} onClick={() => setShowWidgetSettings(false)}>
<div className="absolute bottom-0 left-0 right-0 flex flex-col overflow-hidden"
style={{ maxHeight: '80vh', background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', overscrollBehavior: 'contain' }}
onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Widgets</span>
<button onClick={() => setShowWidgetSettings(false)} className="w-7 h-7 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
<X size={14} style={{ color: 'var(--text-primary)' }} />
</button>
</div>
<div className="flex-1 overflow-auto p-4 space-y-4">
{showCurrency && <CurrencyWidget />}
{showTimezone && <TimezoneWidget />}
</div>
</div>
</div>
)}
<TripFormModal
isOpen={showForm}
onClose={() => { setShowForm(false); setEditingTrip(null) }}

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