Compare commits

...

151 Commits

Author SHA1 Message Date
Maurice 7272e0bbfd chore: bump version to 2.7.1 2026-03-30 21:25:35 +02:00
Maurice c7eaf3aa79 feat: add Italian, Czech, Hungarian + sync all 12 languages
New languages: Italian (it), Czech (cs), Hungarian (hu)
Merged PRs #158, #130, #119 with conflict resolution.

All 12 language files synced to ~1238 keys each:
ar, br, cs, de, en, es, fr, hu, it, nl, ru, zh

Thanks @entropyst72 (Italian), @Numira-code (Czech),
@slashwarm (Hungarian) for the translations!
2026-03-30 21:22:53 +02:00
Maurice deef5e6b81 Merge branch 'pr-130' into dev 2026-03-30 21:02:32 +02:00
Maurice 6d72006b28 Merge branch 'pr-158' into dev 2026-03-30 21:02:18 +02:00
Maurice 26c1676cdd revert: remove auth from file uploads — breaks img/pdf rendering in browser 2026-03-30 20:56:56 +02:00
Maurice 4ddfa92c14 security: require auth for file and photo uploads
/uploads/files/ and /uploads/photos/ now require a valid Bearer token.
Covers and avatars remain public (needed for shared pages and profiles).
Prevents unauthenticated access to uploaded documents and trip photos.
2026-03-30 20:51:38 +02:00
Maurice 19c9e17884 Merge branch 'pr-120' into dev 2026-03-30 20:09:16 +02:00
Maurice 14ef2d4a4a Merge branch 'pr-117' into dev 2026-03-30 20:07:12 +02:00
Maurice de859318fa feat: admin audit log — merged PR #118
Audit logging for admin actions, backups, auth events.
New AuditLogPanel in Admin tab with pagination.
Dockerfile security: run as non-root user.
i18n keys for all 9 languages.

Thanks @fgbona for the implementation!
2026-03-30 20:05:32 +02:00
Maurice bcbb516448 refactor: replace hardcoded Vacay month/weekday arrays with Intl + i18n — based on PR #122
Remove 12 hardcoded arrays for weekdays/months across 6 languages.
Use Intl.DateTimeFormat for month names and i18n keys for weekdays.
Works for all locales automatically.

Thanks @slashwarm for the original PR!
2026-03-30 19:59:47 +02:00
Maurice 71870e4567 Merge branch 'pr-149' into dev 2026-03-30 19:53:08 +02:00
entropyst72 9819473157 added italian language 2026-03-30 19:43:46 +02:00
Maurice eb7984f40d fix: CustomSelect for backup schedule dropdowns, increase PWA cache limit
- Replace native <select> with CustomSelect for hour and day-of-month
  pickers in backup schedule settings (consistent UI)
- Increase PWA workbox cache size limit to 5MB
2026-03-30 19:39:54 +02:00
Maurice 9caa0acc24 fix: language dropdown not clipped by header overflow 2026-03-30 18:25:40 +02:00
Maurice 8ddfa8fde0 i18n: translate all shared trip page strings to 9 languages 2026-03-30 18:24:22 +02:00
Maurice 41d4b2a8be i18n: sync all 9 language files to match en.ts (1210+ keys each) 2026-03-30 18:19:22 +02:00
fgbona 10ebf46a98 harden runtime config and automate first-run permissions
Run the container as a non-root user in production to fail fast on insecure deployments. Add DEBUG env-based request/response logging for container diagnostics, and introduce a one-shot init-permissions service in docker-compose so fresh installs automatically fix data/uploads ownership for SQLite write access.
2026-03-30 13:19:01 -03:00
Maurice 70809d6c27 fix: TimezoneWidget respects 12h/24h setting, addon notification toggles, cover image path — closes #147 2026-03-30 18:08:22 +02:00
Maurice a314ba2b80 feat: public read-only share links with permissions — closes #79
Share links:
- Generate a public link in the trip share modal
- Choose what to share: Map & Plan, Bookings, Packing, Budget, Chat
- Permissions enforced server-side
- Delete link to revoke access instantly

Shared trip page (/shared/:token):
- Read-only view with TREK logo, cover image, trip details
- Tabbed navigation with Lucide icons (responsive on mobile)
- Interactive map with auto-fit bounds per day
- Day plan, Bookings, Packing, Budget, Chat views
- Language picker, TREK branding footer

Technical:
- share_tokens DB table with per-field permissions
- Public GET /shared/:token endpoint (no auth)
- Two-column share modal (max-w-5xl)
2026-03-30 18:02:53 +02:00
Xre0uS d8f03f6bea fix: prevent OIDC redirect loop in oidc-only mode 2026-03-30 23:57:23 +08:00
Maurice 533d6f84d8 fix: use user locale instead of hardcoded de-DE for number/date formatting — closes #144
- CurrencyWidget: format numbers with user's locale
- ReservationModal: date formatting uses locale
- TripPDF: locale fallback to browser default instead of de-DE
- holidays.ts: formatDate accepts optional locale parameter
2026-03-30 17:28:14 +02:00
Maurice 095cb1b9d1 fix: transport bookings in PDF export with proper Lucide icons 2026-03-30 17:22:06 +02:00
Maurice 0a0205fcf9 fix: ICS export — add DTSTAMP, fix time-only DTEND formatting 2026-03-30 17:14:06 +02:00
Maurice 9aed5ff2ed fix: ICS export auth token key (auth_token not token) 2026-03-30 17:09:44 +02:00
Maurice d189d6d776 feat: email notifications, webhook support, ICS export — closes #110
Email Notifications:
- SMTP configuration in Admin > Settings (host, port, user, pass, from)
- App URL setting for email CTA links
- Webhook URL support (Discord, Slack, custom)
- Test email button with SMTP validation
- Beautiful HTML email template with TREK logo, slogan, red heart footer
- All notification texts translated in 8 languages (en/de/fr/es/nl/ru/zh/ar)
- Emails sent in each user's language preference

Notification Events:
- Trip invitation (member added)
- Booking created (new reservation)
- Vacay fusion invite
- Photos shared (Immich)
- Collab chat message
- Packing list category assignment

User Notification Preferences:
- Per-user toggle for each event type in Settings
- Addon-aware: Vacay/Collab/Photos toggles hidden when addon disabled
- Webhook opt-in per user

ICS Calendar Export:
- Download button next to PDF in day plan header
- Exports trip dates + all reservations with details
- Compatible with Google Calendar, Apple Calendar, Outlook

Technical:
- Nodemailer for SMTP
- notification_preferences DB table with per-event columns
- GET/PUT /auth/app-settings for admin config persistence
- POST /notifications/test-smtp for validation
- Dynamic imports for non-blocking notification sends
2026-03-30 17:07:33 +02:00
Maurice 262905e357 feat: import places from Google Maps URLs — closes #141
Paste a Google Maps URL into the place search bar to automatically
import name, coordinates, and address. No API key required.

Supported URL formats:
- Short URLs: maps.app.goo.gl/..., goo.gl/maps/...
- Full URLs: google.com/maps/place/.../@lat,lng
- Data params: !3dlat!4dlng embedded coordinates

Server resolves short URL redirects and extracts coordinates.
Reverse geocoding via Nominatim provides name and address.
2026-03-30 15:18:22 +02:00
Maurice 4a4643f33f feat: OIDC claim-based admin role assignment — closes #93
New environment variables:
- OIDC_ADMIN_CLAIM (default: "groups") — which claim to check
- OIDC_ADMIN_VALUE (e.g. "app-trek-admins") — value that grants admin

Admin role is resolved on every OIDC login:
- New users get admin if their claim matches
- Existing users have their role updated dynamically
- Removing a user from the group revokes admin on next login
- First user is always admin regardless of claims
- No config = previous behavior (first user admin, rest user)

Supports array claims (groups: ["a", "b"]) and string claims.
2026-03-30 15:12:27 +02:00
Maurice a6a7edf0b2 feat: bucket list POIs with auto-search + optional dates — closes #105
- Bucket list now supports POIs (not just countries): add any place
  with auto-search via Google Places / Nominatim
- Optional target date (month/year) via CustomSelect dropdowns
- New target_date field on bucket_list table (DB migration)
- Server PUT route supports updating all fields
- Country bucket modal: date dropdowns default to empty
- CustomSelect: auto-opens upward when near bottom of viewport
- Search results open upward in the bucket add form
- i18n keys for DE and EN
2026-03-30 14:57:31 +02:00
Maurice 949d0967d2 feat: timezone support + granular backup schedule — closes #131
Based on PR #135 by @andreibrebene with adjustments:
- TZ environment variable for Docker timezone support
- Granular auto-backup schedule (hour, day of week, day of month)
- UTC timestamp fix for admin panel
- Server timezone exposed in app-config API
- Replaced native selects with CustomSelect for consistent UI
- Backup schedule UI with 12h/24h time format support

Thanks @andreibrebene for the implementation!
2026-03-30 14:02:27 +02:00
Maurice cd634093af feat: multi-select category filter, performance fixes, check-in/out order
- Category filter is now a multi-select dropdown with checkboxes
- PlaceAvatar: replace 200ms polling intervals with event-based
  notification + React.memo for major performance improvement
- Map photo fetches: concurrency limited to 3 + lazy loading on images
- PlacesSidebar: content-visibility + useMemo for smooth scrolling
- Accommodation labels: check-out now appears before check-in on same day
- Timed places auto-sort chronologically when time is added
2026-03-30 13:52:35 +02:00
Maurice 7201380504 fix: paginate Immich photo search — no longer limited to 200 — closes #137
The Immich metadata search was hardcoded to size: 200. Now paginates
through all results (1000 per page, up to 20k photos max).
2026-03-30 13:36:04 +02:00
Maurice 1166a09835 feat: live GPS location on map + auto-sort timed places — closes #136
Live location:
- Crosshair button on the map toggles GPS tracking
- Blue dot shows live position with accuracy circle (<500m)
- Uses watchPosition for continuous updates
- Button turns blue when active, click again to stop

Auto-sort:
- Places with a time now auto-sort chronologically among other
  timed items (transports, other timed places)
- Adding a time to a place immediately moves it to the correct
  position in the timeline
- Untimed places keep their manual order_index
2026-03-30 13:30:41 +02:00
Andrei Brebene 6f2d7c8f5e Merge branch 'dev' into feat/auto-backup-schedule-and-timezone 2026-03-30 13:23:19 +03:00
Maurice e6c4c22a1d feat: bulk import for packing lists + complete i18n sync — closes #133
Packing list bulk import:
- Import button in packing list header opens a modal
- Paste items or load CSV/TXT file
- Format: Category, Name, Weight (g), Bag, checked/unchecked
- Bags are auto-created if they don't exist
- Server endpoint POST /packing/import with transaction

i18n sync:
- Added all missing translation keys to fr, es, nl, ru, zh, ar
- All 8 language files now have matching key sets
- Includes memories, vacay weekdays, packing import, settlement,
  GPX import, blur booking codes, transport timeline keys
2026-03-30 12:16:00 +02:00
Maurice 9a044ada28 feat: blur booking codes setting + two-column settings page — closes #114
- New display setting "Blur Booking Codes" (off by default)
- When enabled, confirmation codes are blurred across all views
  (ReservationsPanel, DayDetailPanel, Transport detail modal)
- Hover or click reveals the code (click toggles on mobile)
- Settings page uses masonry two-column layout on desktop, single
  column on mobile (<900px)
- Fix hardcoded admin page title to use i18n key
2026-03-30 11:47:05 +02:00
Maurice da5e77f78d feat: GPX file import for places — closes #98
Upload a GPX file to automatically create places from waypoints.
Supports <wpt>, <rtept>, and <trkpt> elements with CDATA handling.
Handles lat/lon in any attribute order. Track-only files import
start and end points with the track name.

- New server endpoint POST /places/import/gpx
- Import GPX button in PlacesSidebar below Add Place
- i18n keys for DE and EN
2026-03-30 11:35:28 +02:00
Andrei Brebene cc8be328f9 feat: add granular auto-backup scheduling and timezone support
Add UI controls for configuring auto-backup schedule with hour, day of
week, and day of month pickers. The hour picker respects the user's
12h/24h time format preference from settings.

Add TZ environment variable support via docker-compose so the container
runs in the configured timezone. The timezone is passed to node-cron for
accurate scheduling and exposed via the API so the UI displays it.

Fix SQLite UTC timestamp handling by appending Z suffix to all timestamps
sent to the client, ensuring proper timezone conversion in the browser.

Made-with: Cursor
2026-03-30 12:27:52 +03:00
Maurice f1c4155d81 feat: add Brazilian Portuguese (pt-BR) language support — thanks @fgbona 2026-03-30 12:27:21 +03:00
Fabian Sievert d4899a8dee feat: add Helm chart for Kubernetes deployment — thanks @another-novelty
* feat: Add basic helm chart

* Delete chart/my-values.yaml
2026-03-30 12:27:21 +03:00
AxelFl a973a1b4f8 docs: fix docker image name in SECURITY.md — thanks @AxelFl 2026-03-30 12:27:21 +03:00
Maurice 73b0534053 feat: add missing French translation keys for memories and weekend days 2026-03-30 12:27:21 +03:00
quentinClaudel 931c5bd990 feat: improve French translations — thanks @quentinClaudel 2026-03-30 12:27:21 +03:00
Maurice ee54308819 feat: expand budget currencies from 14 to 46 — closes #96
Add BDT, INR, BRL, MXN, KRW, CNY, SGD, PHP, VND, ZAR, AED, SAR, ILS,
EGP, MAD, HUF, RON, BGN, HRK, ISK, RUB, UAH, LKR, CLP, COP, PEN, ARS,
NZD, IDR, MYR, HKD, TWD with correct currency symbols.
2026-03-30 11:16:23 +02:00
Gérnyi Márk 66b00c24e2 add leftWidth/rightWidth centering to PlaceInspector 2026-03-30 11:15:57 +02:00
Maurice f6d08582ec feat: expense settlement — track who paid, show who owes whom — closes #41
- Click member avatars on budget items to mark who paid (green = paid)
- Multiple green chips = those people split the payment equally
- Settlement dropdown in the total budget card shows optimized payment
  flows (who owes whom how much) and net balances per person
- Info tooltip explains how the feature works
- New server endpoint GET /budget/settlement calculates net balances
  and minimized payment flows using a greedy algorithm
- Merged category legend: amount + percentage in one row
- i18n keys added for DE and EN
2026-03-30 11:12:22 +02:00
Maurice 8d9a511edf fix: auto-invalidate cache on version update — closes #121
- Add version check on app startup: compare server version with stored
  client version, clear all SW caches and reload on mismatch
- Set Cache-Control: no-cache on index.html so browsers always fetch
  the latest version instead of serving stale cached HTML
2026-03-30 10:26:23 +02:00
Maurice 3059d53d11 fix: use 50m resolution GeoJSON for Atlas — show smaller countries — closes #115
Switch from ne_110m to ne_50m Natural Earth dataset so small countries
like Seychelles, Maldives, Monaco etc. are visible in the Atlas view
and visited countries status.
2026-03-30 10:19:17 +02:00
Maurice 3074724f2f feat: show transport bookings in day plan timeline — closes #37
Transport reservations (flights, trains, buses, cars, cruises) now appear
directly in the day plan timeline based on their reservation date/time.

- Transport cards display inline with places and notes, sorted by time
- Click to open detail modal with all booking data and linked files
- Persistent positioning via new day_plan_position field on reservations
- Free drag & drop: places can be moved between/around transport entries
- Arrow reorder works on the full visual list including transports
- Timed places show confirmation popup when reorder breaks chronology
- Custom delete confirmation popup for reservations
- DB migration adds day_plan_position column to reservations table
- New batch endpoint PUT /reservations/positions for position updates
- i18n keys added for DE and EN
2026-03-30 10:15:27 +02:00
Numira 21ed7ea4a2 Change GeoJSON fetch URL to 110m resolution
Updated GeoJSON data source to use 110m resolution.
2026-03-30 10:03:11 +02:00
Numira 267271d97a Change GeoJSON fetch URL to 50m resolution
Updated GeoJSON data source for country boundaries.
2026-03-30 09:40:11 +02:00
Numira 874c1292c7 Add Czech language support to translation context 2026-03-30 09:32:34 +02:00
Numira a9948499e4 Add files via upload
Added support for Czech language (complete translation of all strings)
2026-03-30 09:24:52 +02:00
Gérnyi Márk 90301e62ce fix type signature, sync keys with upstream, fix atlas.tripIn translation 2026-03-30 01:07:11 +02:00
Gérnyi Márk 377422a9d5 add race condition detection for invite token usage 2026-03-30 00:59:02 +02:00
Gérnyi Márk d90a059dfa pass leftWidth/rightWidth from TripPlannerPage to DayDetailPanel 2026-03-30 00:52:41 +02:00
Gérnyi Márk 1e20f024d5 use leftWidth/rightWidth to center panel between sidebars 2026-03-30 00:46:06 +02:00
Gérnyi Márk 9a81baa809 feat: add leftWidth/rightWidth layout props to DayDetailPanel 2026-03-30 00:44:28 +02:00
Gérnyi Márk 11b85a2d70 feat: add Hungarian language support 2026-03-30 00:43:42 +02:00
fgbona d04629605e feat(audit): admin audit log
Audit log
- Add audit_log table (migration + schema) with index on created_at.
- Add auditLog service (writeAudit, getClientIp) and record events for backups
  (create, restore, upload-restore, delete, auto-settings), admin actions
  (users, OIDC, invites, system update, demo baseline, bag tracking, packing
  template delete, addons), and auth (app settings, MFA enable/disable).
- Add GET /api/admin/audit-log with pagination; fix invite insert row id lookup.
- Add AuditLogPanel and Admin tab; adminApi.auditLog.
- Add admin.tabs.audit and admin.audit.* strings in all locale files.
Note: Rebase feature branches so new DB migrations stay after existing ones
  (e.g. file_links) when merging upstream.
2026-03-29 19:39:05 -03:00
Gérnyi Márk 187989cc1d feat: pass invite token through OIDC flow to allow invited registration
When registration is disabled, users with a valid invite link can now
register via OIDC/SSO. The invite token is passed from the login page
through the OIDC state, validated on callback, and used to bypass the
allow_registration check. Invite usage count is incremented after
successful registration.
2026-03-30 00:35:53 +02:00
Maurice 6444b2b4ce feat: add Brazilian Portuguese (pt-BR) language support — thanks @fgbona 2026-03-29 23:55:46 +02:00
Fabian Sievert 42ebc7c298 feat: add Helm chart for Kubernetes deployment — thanks @another-novelty
* feat: Add basic helm chart

* Delete chart/my-values.yaml
2026-03-29 23:44:20 +02:00
AxelFl 8bca921b30 docs: fix docker image name in SECURITY.md — thanks @AxelFl 2026-03-29 23:42:11 +02:00
Maurice 12f8b6eb55 feat: add missing French translation keys for memories and weekend days 2026-03-29 23:38:51 +02:00
quentinClaudel 202cfb6a63 feat: improve French translations — thanks @quentinClaudel 2026-03-29 23:36:56 +02:00
Maurice b6f9664ec2 feat: multi-link files to multiple bookings and places — closes #23
Files can now be linked to multiple bookings and places simultaneously
via a new file_links junction table. Booking modal includes a file picker
to link existing uploads. Unlinking removes the association without
deleting the file.
2026-03-29 23:32:04 +02:00
Maurice 9f8075171d feat: Immich photo integration — Photos addon with sharing, filters, lightbox
- Immich connection per user (Settings → Immich URL + API Key)
- Photos addon (admin-toggleable, trip tab)
- Manual photo selection from Immich library (date filter + all photos)
- Photo sharing with consent popup, per-photo privacy toggle
- Lightbox with liquid glass EXIF info panel (camera, lens, location, settings)
- Location filter + date sort in gallery
- WebSocket live sync when photos are added/removed/shared
- Proxy endpoints for thumbnails and originals with token auth
2026-03-29 22:41:39 +02:00
Maurice 02b907e764 fix: manually marked Atlas countries not saved when no trips exist — closes #95 2026-03-29 22:37:21 +02:00
Maurice e05e021f41 fix: prevent duplicate packing category names from merging — auto-append number — closes #100 2026-03-29 22:37:21 +02:00
Maurice 615c6bae58 fix: Bangladesh pins incorrectly shown as India in Atlas — add BD bounding box — closes #106 2026-03-29 22:37:21 +02:00
Maurice 62fbc26811 fix: GitHub panel blank screen — add missing releases endpoint, fix NOMAD→TREK URL — closes #107 2026-03-29 22:37:21 +02:00
Maurice 2171203a4c feat: configurable weekend days in Vacay — closes #97
Users can now select which days are weekends (default: Sat+Sun).
Useful for countries like Bangladesh (Fri+Sat) or others with
different work weeks. Settings appear under "Block weekends" toggle.
2026-03-29 19:46:24 +02:00
Maurice b28b483b90 fix: unlimited invite links (max_uses=0) no longer blocked as fully used 2026-03-29 19:30:21 +02:00
Maurice 020cafade1 feat: auto-redirect to OIDC when password auth is disabled — closes #94 2026-03-29 18:25:51 +02:00
Maurice e4b2262d4d docs: update README for v2.7.0 — new features, env vars table, fix nomad references 2026-03-29 17:51:03 +02:00
Maurice d2efd960b5 v2.7.0 2026-03-29 17:42:11 +02:00
Maurice c51a27371b chore: sync server package-lock.json for clean npm ci builds 2026-03-29 17:35:55 +02:00
Maurice 252d2d22a8 i18n: sync all 8 languages to 1086 keys — remove ES extras, complete AR 2026-03-29 17:23:19 +02:00
Maurice 80c2486570 i18n: add missing translation keys for all features across ES, FR, RU, ZH, NL, AR 2026-03-29 17:09:33 +02:00
Maurice 7dcd89fb71 fix: pan to clicked marker without zoom reset — closes #86 2026-03-29 16:55:27 +02:00
Maurice 8458481950 feat: atlas country marking, bucket list, trip creation UX — closes #49
Atlas:
- Click any country to mark as visited or add to bucket list
- Bucket list with country flags, planned month/year, horizontal layout
- Confirm popup with two options (mark visited / bucket list)
- Full A2/A3 country code mapping for all countries

Trip creation:
- Drag & drop cover image support
- Add travel buddies via CustomSelect dropdown when creating a trip
- Manual date entry via double-click on date picker (supports DD.MM.YYYY, ISO, etc.)
2026-03-29 16:51:35 +02:00
Maurice 808b7f7a72 fix: map pins update immediately when category filter is cleared 2026-03-29 15:27:56 +02:00
Maurice f4ee7b868d feat: sync category filter to map pins — closes #81 2026-03-29 15:26:47 +02:00
Maurice e99960c3b6 feat: support OIDC_ONLY environment variable to disable password auth — closes #48 2026-03-29 15:14:41 +02:00
Maurice c39d242cfb feat: bag tracking with weight distribution, packing UX overhaul — closes #13
- Bag tracking: optional admin setting under Packing addon, weight per item,
  bag assignment with inline creation, iOS-style weight sidebar + mobile modal
- Admin: merged Categories + Packing Templates into "Configuration" tab
- Packing UI: category-first workflow, mobile-friendly action buttons,
  stable category ordering, responsive button labels
2026-03-29 15:08:56 +02:00
Maurice 2f8a189319 feat: packing templates with category-based workflow — closes #14
- Admin: create/edit/delete packing templates with categories and items
- Trip packing: category-first workflow (add category → add items inside)
- Apply template button adds items additively (preserves existing)
- Replaces old item+category freetext input
2026-03-29 14:19:06 +02:00
Maurice 44138af11a feat: assign trip members to packing list categories — closes #71 2026-03-29 13:37:48 +02:00
mauriceboe bc6c59f358 Merge pull request #72 from Summerfeeling/main
fix: prioritize ADM0_A3 over ISO_A3 in atlas area resolution to support France, Norway and Israel
2026-03-29 13:23:15 +02:00
Maurice 54804d0e5f style: unify language button size with other settings buttons 2026-03-29 13:21:24 +02:00
Maurice 631e47944b style: increase spacing between password/MFA sections in settings 2026-03-29 13:20:08 +02:00
Maurice 3abcc0ec76 feat: fix MFA integration — migration, otplib compat, branding, and add MFA translations for all languages 2026-03-29 13:18:53 +02:00
Maurice 530f233b7d Merge PR #76: feat/mfa — multifactor authentication (closes #46) 2026-03-29 13:01:05 +02:00
Maurice fbb3bb862c i18n: add missing Arabic translations for grid/list toggle, accommodation rename, and invite links 2026-03-29 12:58:18 +02:00
mauriceboe 3c3b7b9136 Merge pull request #76 from mansourSaleh/add-arabic-language-support
feat(client): add Arabic language support
2026-03-29 12:56:45 +02:00
Maurice 99514ddce1 feat: add invite registration links with configurable usage limits
Admins can create one-time registration links (1–5× or unlimited uses)
with optional expiry (1d–14d or never). Recipients can register even
when public registration is disabled. Atomic usage counting prevents
race conditions, all endpoints are rate-limited.
2026-03-29 12:49:15 +02:00
Mansour Almohsen b0ffb63d67 feat(client): add Arabic language support
Add Arabic to the client i18n system, expose it in the language selectors, and enable RTL document handling. Also localize the remaining language-specific UI bits used by the login, demo, Vacay, and GitHub panels.
2026-03-29 12:47:45 +03:00
Maurice d909aac751 i18n: rename "Hotel" booking type to "Accommodation" — closes #75 2026-03-29 11:14:33 +02:00
Maurice e91b79ebfc feat: add list/grid view toggle on dashboard — closes #73 2026-03-29 11:10:33 +02:00
Summerfeeling | Timo 2d7babcba3 fix: prioritize ADM0_A3 over ISO_A3 in atlas area resolution to support France, Norway and Israel 2026-03-29 03:40:57 +02:00
Fernando Bona e56ea068ef Merge branch 'main' into feat/mfa 2026-03-28 22:12:26 -03:00
fgbona a091051387 feat/mfa: Removed install-server-deps.sh, .npmrc and .nvmrc 2026-03-28 22:10:49 -03:00
mauriceboe df3e62af5c Merge pull request #70 from Summerfeeling/main
fix: use correct uploads path as src for avatars in day plan sidebar
2026-03-29 01:50:58 +01:00
mauriceboe 399e4acf03 Merge pull request #69 from saswatds/helmet-fix
fix: resolve static asset SSL errors from helmet's upgrade-insecure-requests
2026-03-29 01:48:08 +01:00
Maurice e0fd9830d9 Merge branch 'dev' 2026-03-29 01:43:07 +01:00
Maurice 7a445583d7 style: replace native color picker and text input with TREK-style components in holiday calendars 2026-03-29 01:41:57 +01:00
Summerfeeling | Timo 1d9d628e2d fix: use correct uploads path for avatars in day plan sidebar 2026-03-29 01:39:15 +01:00
Maurice 005c08dcea Merge PR #68: multiple holiday calendars per vacay plan (closes #36) 2026-03-29 01:33:06 +01:00
Saswat e25fec4e4a fix: resolve static asset SSL errors from helmet's upgrade-insecure-requests
Helmet merges default CSP directives (including `upgrade-insecure-requests`)
into custom directives when `useDefaults` is true (the default). This caused
browsers to upgrade all HTTP sub-resource requests to HTTPS, breaking static
assets when the server runs over plain HTTP.

This commit conditionally sets `upgrade-insecure-requests` based on
FORCE_HTTPS: enabled in production (where HTTPS is available), explicitly
disabled (null) otherwise to prevent browser SSL errors on home servers
and development environments.

Also extracts `shouldForceHttps` to avoid repeated env lookups.
2026-03-28 17:30:51 -07:00
mauriceboe 85e69b8a3d Update multilingual support in README 2026-03-29 01:09:27 +01:00
Maurice 1d57eacfa4 fix: wrap language buttons in settings to prevent overflow 2026-03-29 01:05:40 +01:00
Maurice ecf7433980 i18n: add French, Russian, Chinese Simplified, and Dutch translations 2026-03-29 01:02:41 +01:00
Maurice 433d780f74 security: upgrade multer 1.4.5 → 2.1.1 — fixes CVE-2025-47944, CVE-2025-47935, CVE-2025-48997, CVE-2025-7338 2026-03-29 00:35:16 +01:00
Maurice 27f8856e9b i18n: add addon catalog translations for EN and DE — fixes missing collab tab name 2026-03-28 23:46:15 +01:00
Maurice f2c90ee0f4 Merge branch 'main' into dev 2026-03-28 23:29:00 +01:00
Maurice 83d256ebac feat: custom timezones in timezone widget — closes #21 2026-03-28 23:23:52 +01:00
Stephen Wheet 3c4f5f7193 feat: multiple holiday calendars per vacay plan
- Add vacay_holiday_calendars table (region, label, color, sort_order)
- Lazy migration of existing holidays_region to first calendar row
- Extract applyHolidayCalendars() helper; replace inline holiday logic
- GET /vacay/plan now includes holiday_calendars array
- Add POST/PUT/DELETE /vacay/plan/holiday-calendars/:id endpoints
- Client VacayPlan/VacayEntry/HolidayInfo types updated
- loadHolidays() loops over all calendars; per-calendar color on HolidayInfo
- VacayMonthCard uses holiday.color instead of hardcoded red
- VacaySettings replaced single country picker with calendar list UI
- VacayPage legend renders one item per calendar
- i18n: addCalendar, calendarLabel, calendarColor, noCalendars (en + de)
- Fix pre-existing TS errors: VacayPlan/VacayEntry missing fields,
  SettingToggleProps icon/onChange types, packing.suggestions.items array type

Closes #36
2026-03-28 22:16:12 +00:00
Maurice 31124a604a feat: auto-split pasted lat,lng coordinates in place form — closes #22 2026-03-28 23:11:47 +01:00
Maurice 0d9dbb6286 i18n: consolidate es.js into es.ts, add missing 2.6.2 Spanish translations 2026-03-28 23:00:53 +01:00
Fernando Bona 66ae577b7b Merge branch 'main' into feat/mfa 2026-03-28 18:59:06 -03:00
Joaquin 706548c45d feat: add full Spanish translation (#57)
* feat(i18n): add spanish translation support

* refactor(i18n): refine spanish copy for es-es

* refactor(i18n): translate addon titles to spanish
2026-03-28 22:56:17 +01:00
Maurice aa32df5ee1 Merge branch 'main' into dev 2026-03-28 22:29:34 +01:00
Maurice 1f9ae8e4b5 feat: add Unraid Community App template — fixes #56 2026-03-28 22:25:14 +01:00
Maurice d69585a820 feat: add Unraid Community App template — fixes #56 2026-03-28 22:23:34 +01:00
mauriceboe 723f8a1c3d Merge pull request #66 from wheetazlab/feature-oidc-only-mode
feat: add OIDC-only mode to disable password authentication
2026-03-28 21:51:14 +01:00
Maurice 678fe2d12c docs: update README Docker/GitHub refs to TREK, push to both Docker Hub repos (trek + nomad) 2026-03-28 21:41:03 +01:00
mauriceboe e97ecd558f Merge pull request #63 from wheetazlab/feature-update-build-for-new-branding
chore: rename Docker image references from nomad to trek
2026-03-28 21:40:00 +01:00
Stephen Wheet 3d33191925 fix: align @types/express to v4 to match express runtime
The project uses express@^4.18.3 at runtime but had @types/express@^5.0.6
as type definitions. The v5 types widened ParamsDictionary from
string to string | string[], causing 115 type errors across all route
handlers.

Fix: downgrade @types/express to ^4.17.25 (latest v4), which correctly
types req.params as string — matching Express 4 runtime behaviour.

Removes the StringParams = Record<string, string> workaround from
types.ts and the Request<StringParams> annotations from all 15 route
files that were introduced as a workaround for the type mismatch.
2026-03-28 20:36:09 +00:00
Maurice 48e1b732d8 fix: disable Helmet HSTS when FORCE_HTTPS is not set — fixes #58 #59 2026-03-28 21:35:23 +01:00
Stephen Wheet d50c84b755 fix: resolve all TypeScript errors via proper Express 5 typed route params
- Add StringParams = Record<string, string> to types.ts
- Use Request<StringParams> in all route handlers across 14 files
- Clean up earlier as-cast workarounds in places.ts and admin.ts
- tsconfig.json: keep original (removed bad 'types:node' addition)
- package.json: restore @types/express back to ^5.0.6
2026-03-28 20:13:24 +00:00
Stephen Wheet fcbfeb6793 fix: resolve all TypeScript errors - node types, Express v4 types, places/scheduler fixes 2026-03-28 19:45:01 +00:00
Stephen Wheet 77f2c616de fix: type error in AdminPage handleSaveUser payload, install deps 2026-03-28 19:41:06 +00:00
Stephen Wheet 9f8d3f8d99 feat: add OIDC-only mode to disable password authentication
When OIDC is configured, admins can now enable 'Disable password
authentication' in Admin → Settings → SSO. This blocks all password-
based login and registration, forcing users through the SSO identity
provider instead.

Backend:
- routes/admin.ts: expose oidc_only flag on GET /admin/oidc and accept
  it on PUT /admin/oidc (persisted to app_settings)
- routes/auth.ts: add isOidcOnlyMode() helper; block POST /auth/login,
  POST /auth/register (for non-first-user), and PUT /auth/me/password
  with HTTP 403 when OIDC-only mode is active
- routes/auth.ts: expose oidc_only_mode boolean in GET /auth/app-config

Frontend:
- AdminPage: toggle in OIDC/SSO settings section (oidc_only saved with
  rest of OIDC config on same Save button)
- LoginPage: when oidc_only_mode is active, replace form with a
  single-button OIDC redirect; hide register toggle
- SettingsPage: hide password change section when oidc_only_mode is on
- i18n (en/de): admin.oidcOnlyMode, admin.oidcOnlyModeHint,
  login.oidcOnly
2026-03-28 19:33:18 +00:00
Stephen Wheet 3f26a68f64 chore: rename image references from nomad to trek
Reflects upstream rebrand from NOMAD to TREK.
- .github/workflows/docker.yml: mauriceboe/nomad → mauriceboe/trek
- docker-compose.yml: mauriceboe/nomad → mauriceboe/trek
2026-03-28 19:23:13 +00:00
Maurice a3b6a89471 ci: tag Docker images with version from package.json (latest + v2.6.2) 2026-03-28 16:43:41 +01:00
Maurice ee54d89144 docs: rebrand README, SECURITY.md, docker-compose.yml to TREK 2026-03-28 16:41:06 +01:00
Maurice e78c2a97bd v2.6.2 — TREK Rebrand, OSM Enrichment, File Management, Hotel Bookings & Bug Fixes
Rebrand:
- NOMAD → TREK branding across all UI, translations, server, PWA manifest
- New TREK logos (dark/light, with/without icon)
- Liquid glass toast notifications

Bugs Fixed:
- HTTPS redirect now opt-in only (FORCE_HTTPS=true), fixes #33 #43 #52 #54 #55
- PDF export "Tag" fallback uses i18n, fixes #15
- Vacay sharing color collision detection, fixes #25
- Backup settings import fix (PR #47)
- Atlas country detection uses smallest bounding box, fixes #31
- JPY and zero-decimal currencies formatted correctly, fixes #32
- HTML lang="en" instead of hardcoded "de", fixes #34
- Duplicate translation keys removed
- setSelectedAssignmentId crash fixed

New Features:
- OSM enrichment: Overpass API for opening hours, Wikimedia Commons for photos
- Reverse geocoding on map right-click to add places
- OIDC config via environment variables (OIDC_ISSUER, OIDC_CLIENT_ID, etc.), fixes #48
- Multi-arch Docker build (ARM64 + AMD64), fixes #11
- File management: star, trash/restore, upload owner, assign to places/bookings, notes
- Markdown rendering in Collab Notes with expand modal, fixes #17
- Type-specific booking fields (flight: airline/number/airports, hotel: check-in/out/days, train: number/platform/seat), fixes #35
- Hotel bookings auto-create accommodations, bidirectional sync
- Multiple hotels per day with check-in/check-out color coding
- Ko-fi and Buy Me a Coffee support cards
- GitHub releases proxy with server-side caching
2026-03-28 16:38:08 +01:00
mauriceboe 5940b7f24e Merge pull request #47 from fgbona/fix/auto-backup
Fix/auto backup - save button
2026-03-28 13:25:10 +01:00
fgbona 1c3a1ba8da fix/autobackup: Fixed autobackup feature. 2026-03-27 23:53:39 -03:00
fgbona b6d927a3d6 feat/mfa: Added multifactor authentication. 2026-03-27 23:29:37 -03:00
fgbona c5e41f2228 fix: Fixed autobackup feature. 2026-03-27 22:51:35 -03:00
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
217 changed files with 39321 additions and 13310 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.
+70 -4
View File
@@ -7,8 +7,19 @@ on:
jobs:
build:
runs-on: ubuntu-latest
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Prepare platform tag-safe name
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
@@ -18,8 +29,63 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v6
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: mauriceboe/nomad:latest
platforms: ${{ matrix.platform }}
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
no-cache: true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- name: Get version from package.json
id: version
run: echo "VERSION=$(node -p "require('./server/package.json').version")" >> $GITHUB_OUTPUT
- name: Download build digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
docker buildx imagetools create \
-t mauriceboe/trek:latest \
-t mauriceboe/trek:${{ steps.version.outputs.VERSION }} \
-t mauriceboe/nomad:latest \
-t mauriceboe/nomad:${{ steps.version.outputs.VERSION }} \
"${digests[@]}"
- name: Inspect manifest
run: docker buildx imagetools inspect mauriceboe/trek:latest
+9 -5
View File
@@ -11,9 +11,9 @@ FROM node:22-alpine
WORKDIR /app
# Server-Dependencies installieren (better-sqlite3 braucht Build-Tools)
# Timezone support + Server-Dependencies (better-sqlite3 braucht Build-Tools)
COPY server/package*.json ./
RUN apk add --no-cache python3 make g++ && \
RUN apk add --no-cache tzdata python3 make g++ && \
npm ci --production && \
apk del python3 make g++
@@ -26,8 +26,12 @@ 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
RUN chown -R node:node /app
USER node
# Umgebung setzen
ENV NODE_ENV=production
@@ -35,4 +39,4 @@ ENV PORT=3000
EXPOSE 3000
CMD ["node", "src/index.js"]
CMD ["node", "--import", "tsx", "src/index.ts"]
+63 -34
View File
@@ -2,27 +2,27 @@
<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" />
<img src="client/public/logo-light.svg" alt="TREK" height="60" />
</picture>
<br />
<em>Navigation Organizer for Maps, Activities & Destinations</em>
<em>Your Trips. Your Plan.</em>
</p>
<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>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
<a href="https://github.com/mauriceboe/TREK/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/TREK" alt="Last Commit" /></a>
</p>
<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.
<strong><a href="https://demo-nomad.pakulat.org">Live Demo</a></strong> — Try TREK without installing. Resets hourly.
</p>
![NOMAD Screenshot](docs/screenshot.png)
![NOMAD Screenshot 2](docs/screenshot-2.png)
![TREK Screenshot](docs/screenshot.png)
![TREK Screenshot 2](docs/screenshot-2.png)
<details>
<summary>More Screenshots</summary>
@@ -44,13 +44,16 @@
- **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
- **Map Category Filter** — Filter places by category and see only matching pins on the map
### Travel Management
- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments
- **Reservations & Bookings** — Track flights, accommodations, 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
- **Packing Lists** — Category-based checklists with user assignment, packing templates, and progress tracking
- **Packing Templates** — Create reusable packing templates in the admin panel with categories and items, apply to any trip
- **Bag Tracking** — Optional weight tracking and bag assignment for packing items with iOS-style weight distribution (admin-toggleable)
- **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 cover page, images, notes, and NOMAD branding
- **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and TREK branding
### Mobile & PWA
- **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed
@@ -61,17 +64,22 @@
### 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
- **Invite Links** — Create one-time registration links with configurable max uses and expiry for easy onboarding
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
- **Two-Factor Authentication (MFA)** — TOTP-based 2FA with QR code setup, works with Google Authenticator, Authy, etc.
- **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
- **Atlas** — Interactive world map with visited countries, bucket list with planned travel dates, 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
- **Dashboard Views** — Toggle between card grid and compact list view on the My Trips page
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
- **Multilingual** — English and German (i18n)
- **Admin Panel** — User management, global categories, addon management, API keys, backups, and GitHub release history
- **Multilingual** — English, German, Spanish, French, Russian, Chinese (Simplified), Dutch, Arabic (with RTL support)
- **Admin Panel** — User management, invite links, packing templates, 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
@@ -82,7 +90,7 @@
- **PWA**: vite-plugin-pwa + Workbox
- **Real-Time**: WebSocket (`ws`)
- **State**: Zustand
- **Auth**: JWT + OIDC
- **Auth**: JWT + OIDC + TOTP (MFA)
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
- **Weather**: Open-Meteo API (free, no key required)
- **Icons**: lucide-react
@@ -90,19 +98,19 @@
## Quick Start
```bash
docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/nomad
docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
```
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:
TREK works as a Progressive Web App — no App Store needed:
1. Open your NOMAD instance in the browser (HTTPS required)
1. Open your TREK 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
4. TREK launches fullscreen with its own icon, just like a native app
<details>
<summary>Docker Compose (recommended for production)</summary>
@@ -110,13 +118,18 @@ NOMAD works as a Progressive Web App — no App Store needed:
```yaml
services:
app:
image: mauriceboe/nomad:latest
container_name: nomad
image: mauriceboe/trek:latest
container_name: trek
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
# - OIDC_ISSUER=https://auth.example.com
# - OIDC_CLIENT_ID=trek
# - OIDC_CLIENT_SECRET=supersecret
# - OIDC_DISPLAY_NAME="SSO"
# - OIDC_ONLY=true # disable password auth entirely
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
@@ -140,20 +153,20 @@ 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 ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/nomad
docker pull mauriceboe/trek
docker rm -f trek
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
```
> **Tip:** Not sure which paths you used? Run `docker inspect nomad --format '{{json .Mounts}}'` before removing the container.
> **Tip:** Not sure which paths you used? Run `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
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).
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
> **Important:** NOMAD uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path.
> **Important:** TREK uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path.
<details>
<summary>Nginx</summary>
@@ -161,13 +174,13 @@ For production, put NOMAD behind a reverse proxy with HTTPS (e.g. Nginx, Caddy,
```nginx
server {
listen 80;
server_name nomad.yourdomain.com;
server_name trek.yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name nomad.yourdomain.com;
server_name trek.yourdomain.com;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
@@ -202,13 +215,29 @@ server {
Caddy handles WebSocket upgrades automatically:
```
nomad.yourdomain.com {
trek.yourdomain.com {
reverse_proxy localhost:3000
}
```
</details>
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | Server port | `3000` |
| `NODE_ENV` | Environment | `production` |
| `JWT_SECRET` | JWT signing secret | Auto-generated |
| `FORCE_HTTPS` | Redirect HTTP to HTTPS | `false` |
| `OIDC_ISSUER` | OIDC provider URL | — |
| `OIDC_CLIENT_ID` | OIDC client ID | — |
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
| `OIDC_DISPLAY_NAME` | SSO button label | `SSO` |
| `OIDC_ONLY` | Disable password auth | `false` |
| `TRUST_PROXY` | Trust proxy headers | `1` |
| `DEMO_MODE` | Enable demo mode | `false` |
## 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.
@@ -218,14 +247,14 @@ API keys are configured in the **Admin Panel** after login. Keys set by the admi
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a project and enable the **Places API (New)**
3. Create an API key under Credentials
4. In NOMAD: Admin Panel → Settings → Google Maps
4. In TREK: Admin Panel → Settings → Google Maps
## Building from Source
```bash
git clone https://github.com/mauriceboe/NOMAD.git
cd NOMAD
docker build -t nomad .
git clone https://github.com/mauriceboe/TREK.git
cd TREK
docker build -t trek .
```
## Data & Backups
+1 -1
View File
@@ -21,6 +21,6 @@ You will receive a response within 48 hours. Once confirmed, a fix will be relea
## Scope
This policy covers the NOMAD application and its Docker image (`mauriceboe/nomad`).
This policy covers the TREK application and its Docker image (`mauriceboe/trek`).
Third-party dependencies are monitored via GitHub Dependabot.
+5
View File
@@ -0,0 +1,5 @@
apiVersion: v2
name: trek
version: 0.1.0
description: Minimal Helm chart for TREK app
appVersion: "latest"
+33
View File
@@ -0,0 +1,33 @@
# TREK Helm Chart
This is a minimal Helm chart for deploying the TREK app.
## Features
- Deploys the TREK container
- Exposes port 3000 via Service
- Optional persistent storage for `/app/data` and `/app/uploads`
- Configurable environment variables and secrets
- Optional generic Ingress support
- Health checks on `/api/health`
## Usage
```sh
helm install trek ./chart \
--set secretEnv.JWT_SECRET=your_jwt_secret \
--set ingress.enabled=true \
--set ingress.hosts[0].host=yourdomain.com
```
See `values.yaml` for more options.
## Files
- `Chart.yaml` — chart metadata
- `values.yaml` — configuration values
- `templates/` — Kubernetes manifests
## Notes
- Ingress is off by default. Enable and configure hosts for your domain.
- PVCs require a default StorageClass or specify one as needed.
- JWT_SECRET must be set for production use.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
+13
View File
@@ -0,0 +1,13 @@
1. JWT_SECRET handling:
- By default, the chart creates a secret with the value from `values.yaml: secretEnv.JWT_SECRET`.
- To generate a random JWT_SECRET at install, set `generateJwtSecret: true`.
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must have a key matching `existingSecretKey` (defaults to `JWT_SECRET`).
2. Example usage:
- Set a custom secret: `--set secretEnv.JWT_SECRET=your_secret`
- Generate a random secret: `--set generateJwtSecret=true`
- Use an existing secret: `--set existingSecret=my-k8s-secret`
- Use a custom key in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_KEY`
3. Only one method should be used at a time. If both `generateJwtSecret` and `existingSecret` are set, `existingSecret` takes precedence.
If using `existingSecret`, ensure the referenced secret and key exist in the target namespace.
+18
View File
@@ -0,0 +1,18 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "trek.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "trek.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- printf "%s" $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "trek.fullname" . }}-config
labels:
app: {{ include "trek.name" . }}
data:
NODE_ENV: {{ .Values.env.NODE_ENV | quote }}
PORT: {{ .Values.env.PORT | quote }}
{{- if .Values.env.ALLOWED_ORIGINS }}
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
{{- end }}
+61
View File
@@ -0,0 +1,61 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "trek.fullname" . }}
labels:
app: {{ include "trek.name" . }}
spec:
replicas: 1
selector:
matchLabels:
app: {{ include "trek.name" . }}
template:
metadata:
labels:
app: {{ include "trek.name" . }}
spec:
{{- if .Values.imagePullSecrets }}
imagePullSecrets:
{{- range .Values.imagePullSecrets }}
- name: {{ .name }}
{{- end }}
{{- end }}
containers:
- name: trek
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: 3000
envFrom:
- configMapRef:
name: {{ include "trek.fullname" . }}-config
env:
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: {{ .Values.existingSecretKey | default "JWT_SECRET" }}
volumeMounts:
- name: data
mountPath: /app/data
- name: uploads
mountPath: /app/uploads
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 15
periodSeconds: 30
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
volumes:
- name: data
persistentVolumeClaim:
claimName: {{ include "trek.fullname" . }}-data
- name: uploads
persistentVolumeClaim:
claimName: {{ include "trek.fullname" . }}-uploads
+32
View File
@@ -0,0 +1,32 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "trek.fullname" . }}
labels:
app: {{ include "trek.name" . }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.tls }}
tls:
{{- toYaml .Values.ingress.tls | nindent 4 }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host }}
http:
paths:
{{- range .paths }}
- path: {{ . }}
pathType: Prefix
backend:
service:
name: {{ include "trek.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}
+25
View File
@@ -0,0 +1,25 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "trek.fullname" . }}-data
labels:
app: {{ include "trek.name" . }}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.persistence.data.size }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "trek.fullname" . }}-uploads
labels:
app: {{ include "trek.name" . }}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.persistence.uploads.size }}
+23
View File
@@ -0,0 +1,23 @@
{{- if and (not .Values.existingSecret) (not .Values.generateJwtSecret) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "trek.fullname" . }}-secret
labels:
app: {{ include "trek.name" . }}
type: Opaque
data:
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ .Values.secretEnv.JWT_SECRET | b64enc | quote }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateJwtSecret) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "trek.fullname" . }}-secret
labels:
app: {{ include "trek.name" . }}
type: Opaque
stringData:
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ randAlphaNum 32 }}
{{- end }}
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "trek.fullname" . }}
labels:
app: {{ include "trek.name" . }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: 3000
protocol: TCP
name: http
selector:
app: {{ include "trek.name" . }}
+53
View File
@@ -0,0 +1,53 @@
image:
repository: mauriceboe/trek
tag: latest
pullPolicy: IfNotPresent
# Optional image pull secrets for private registries
imagePullSecrets: []
# - name: my-registry-secret
service:
type: ClusterIP
port: 3000
env:
NODE_ENV: production
PORT: 3000
# ALLOWED_ORIGINS: ""
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
# JWT secret configuration
secretEnv:
# If set, use this value for JWT_SECRET (base64-encoded in secret.yaml)
JWT_SECRET: ""
# If true, a random JWT_SECRET will be generated during install (overrides secretEnv.JWT_SECRET)
generateJwtSecret: false
# If set, use an existing Kubernetes secret for JWT_SECRET
existingSecret: ""
existingSecretKey: JWT_SECRET
persistence:
enabled: true
data:
size: 1Gi
uploads:
size: 1Gi
resources: {}
ingress:
enabled: false
annotations: {}
hosts:
- host: chart-example.local
paths:
- /
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
+4 -4
View File
@@ -1,15 +1,15 @@
<!DOCTYPE html>
<html lang="de">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>NOMAD</title>
<title>TREK</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" />
<meta name="apple-mobile-web-app-title" content="TREK" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
<!-- Favicon -->
@@ -25,6 +25,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1499 -10
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "nomad-client",
"version": "2.6.0",
"name": "trek-client",
"version": "2.7.1",
"private": true,
"type": "module",
"scripts": {
@@ -19,8 +19,10 @@
"react-dropzone": "^14.4.1",
"react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.2",
"react-window": "^2.2.7",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
"zustand": "^4.5.2"
},
@@ -28,11 +30,13 @@
"@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",
"typescript": "^6.0.2",
"vite": "^5.1.4",
"vite-plugin-pwa": "^0.21.0"
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

+36 -11
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,18 +6,23 @@ 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 SharedTripPage from './pages/SharedTripPage'
import { ToastContainer } from './components/shared/Toast'
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()
@@ -40,7 +45,7 @@ function ProtectedRoute({ children, adminRequired = false }) {
return <Navigate to="/dashboard" replace />
}
return children
return <>{children}</>
}
function RootRedirect() {
@@ -58,16 +63,37 @@ function RootRedirect() {
}
export default function App() {
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey } = useAuthStore()
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone } = useAuthStore()
const { loadSettings } = useSettingsStore()
useEffect(() => {
if (token) {
loadUser()
}
authApi.getAppConfig().then(config => {
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string }) => {
if (config?.demo_mode) setDemoMode(true)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.timezone) setServerTimezone(config.timezone)
if (config?.version) {
const storedVersion = localStorage.getItem('trek_app_version')
if (storedVersion && storedVersion !== config.version) {
try {
if ('caches' in window) {
const names = await caches.keys()
await Promise.all(names.map(n => caches.delete(n)))
}
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations()
await Promise.all(regs.map(r => r.unregister()))
}
} catch {}
localStorage.setItem('trek_app_version', config.version)
window.location.reload()
return
}
localStorage.setItem('trek_app_version', config.version)
}
}).catch(() => {})
}, [])
@@ -79,10 +105,9 @@ export default function App() {
}
}, [isAuthenticated])
// Apply dark mode class to <html> + update PWA theme-color
useEffect(() => {
const mode = settings.dark_mode
const applyDark = (isDark) => {
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')
@@ -91,11 +116,10 @@ export default function App() {
if (mode === 'auto') {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
applyDark(mq.matches)
const handler = (e) => applyDark(e.matches)
const handler = (e: MediaQueryListEvent) => applyDark(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}
// Support legacy boolean + new string values
applyDark(mode === true || mode === 'dark')
}, [settings.dark_mode])
@@ -105,7 +129,8 @@ export default function App() {
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<Navigate to="/login" replace />} />
<Route path="/shared/:token" element={<SharedTripPage />} />
<Route path="/register" element={<LoginPage />} />
<Route
path="/dashboard"
element={
-251
View File
@@ -1,251 +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),
changePassword: (data) => apiClient.put('/auth/me/password', data).then(r => r.data),
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
}
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),
update: (tripId, dayId, id, data) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data),
getParticipants: (tripId, id) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
setParticipants: (tripId, id, userIds) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds }).then(r => r.data),
updateTime: (tripId, id, times) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
}
export const packingApi = {
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),
saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data),
getOidc: () => apiClient.get('/admin/oidc').then(r => r.data),
updateOidc: (data) => apiClient.put('/admin/oidc', data).then(r => r.data),
addons: () => apiClient.get('/admin/addons').then(r => r.data),
updateAddon: (id, data) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
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, 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),
setMembers: (tripId, id, userIds) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
togglePaid: (tripId, id, userId, paid) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
perPersonSummary: (tripId) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
}
export const filesApi = {
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 } }).then(r => r.data),
getDetailed: (lat, lng, date, lang) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
}
export const settingsApi = {
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 accommodationsApi = {
list: (tripId) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
create: (tripId, data) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
}
export const dayNotesApi = {
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 collabApi = {
// Notes
getNotes: (tripId) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data),
createNote: (tripId, data) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
updateNote: (tripId, id, data) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
deleteNote: (tripId, id) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
uploadNoteFile: (tripId, noteId, formData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
deleteNoteFile: (tripId, noteId, fileId) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
// Polls
getPolls: (tripId) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
createPoll: (tripId, data) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
votePoll: (tripId, id, optionIndex) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex }).then(r => r.data),
closePoll: (tripId, id) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
deletePoll: (tripId, id) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
// Chat
getMessages: (tripId, before) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
sendMessage: (tripId, data) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
deleteMessage: (tripId, id) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
reactMessage: (tripId, id, emoji) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji }).then(r => r.data),
linkPreview: (tripId, url) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
}
export const backupApi = {
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 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) => 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
+307
View File
@@ -0,0 +1,307 @@
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; invite_token?: string }) => apiClient.post('/auth/register', data).then(r => r.data),
validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data),
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data),
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data),
mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', 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),
importGpx: (tripId: number | string, file: File) => {
const fd = new FormData(); fd.append('file', file)
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).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),
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).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),
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
updateBag: (tripId: number | string, bagId: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).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),
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
getPackingTemplate: (id: number) => apiClient.get(`/admin/packing-templates/${id}`).then(r => r.data),
createPackingTemplate: (data: { name: string }) => apiClient.post('/admin/packing-templates', data).then(r => r.data),
updatePackingTemplate: (id: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${id}`, data).then(r => r.data),
deletePackingTemplate: (id: number) => apiClient.delete(`/admin/packing-templates/${id}`).then(r => r.data),
addTemplateCategory: (templateId: number, data: { name: string }) => apiClient.post(`/admin/packing-templates/${templateId}/categories`, data).then(r => r.data),
updateTemplateCategory: (templateId: number, catId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/categories/${catId}`, data).then(r => r.data),
deleteTemplateCategory: (templateId: number, catId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/categories/${catId}`).then(r => r.data),
addTemplateItem: (templateId: number, catId: number, data: { name: string }) => apiClient.post(`/admin/packing-templates/${templateId}/categories/${catId}/items`, data).then(r => r.data),
updateTemplateItem: (templateId: number, itemId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/items/${itemId}`, data).then(r => r.data),
deleteTemplateItem: (templateId: number, itemId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/items/${itemId}`).then(r => r.data),
listInvites: () => apiClient.get('/admin/invites').then(r => r.data),
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
auditLog: (params?: { limit?: number; offset?: number }) =>
apiClient.get('/admin/audit-log', { params }).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/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).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),
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
}
export const filesApi = {
list: (tripId: number | string, trash?: boolean) => apiClient.get(`/trips/${tripId}/files`, { params: trash ? { trash: 'true' } : {} }).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),
toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data),
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data),
permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data),
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data),
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).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),
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[]) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions }).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 const shareApi = {
getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/share-link`).then(r => r.data),
createLink: (tripId: number | string, perms?: Record<string, boolean>) => apiClient.post(`/trips/${tripId}/share-link`, perms || {}).then(r => r.data),
deleteLink: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/share-link`).then(r => r.data),
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
}
export const notificationsApi = {
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
updatePreferences: (prefs: Record<string, boolean>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
}
export default apiClient
@@ -1,45 +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
return
}
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
@@ -50,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
}
@@ -59,20 +61,16 @@ function connectInternal(token, isReconnect = false) {
socket = new WebSocket(url)
socket.onopen = () => {
// connection established
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 }))
// joined trip room
}
})
// 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)
}
})
@@ -94,7 +92,7 @@ function connectInternal(token, isReconnect = false) {
}
}
export function connect(token) {
export function connect(token: string): void {
currentToken = token
reconnectDelay = 1000
if (reconnectTimer) {
@@ -104,7 +102,7 @@ export function connect(token) {
connectInternal(token, false)
}
export function disconnect() {
export function disconnect(): void {
currentToken = null
if (reconnectTimer) {
clearTimeout(reconnectTimer)
@@ -112,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)
}
@@ -1,20 +1,33 @@
import React, { useEffect, useState } from 'react'
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'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image } from 'lucide-react'
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase,
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image,
}
function AddonIcon({ name, size = 20 }) {
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() {
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void }) {
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)
@@ -31,7 +44,7 @@ export default function AddonManager() {
try {
const data = await adminApi.addons()
setAddons(data.addons)
} catch (err) {
} catch (err: unknown) {
toast.error(t('admin.addons.toast.error'))
} finally {
setLoading(false)
@@ -46,7 +59,7 @@ export default function AddonManager() {
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
window.dispatchEvent(new Event('addons-changed'))
toast.success(t('admin.addons.toast.updated'))
} catch (err) {
} catch (err: unknown) {
// Rollback
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a))
toast.error(t('admin.addons.toast.error'))
@@ -71,7 +84,7 @@ export default function AddonManager() {
<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')}
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
</p>
</div>
@@ -91,7 +104,28 @@ export default function AddonManager() {
</span>
</div>
{tripAddons.map(addon => (
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('admin.bagTracking.title')}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.bagTracking.subtitle')}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button onClick={onToggleBagTracking}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: bagTrackingEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
@@ -117,8 +151,27 @@ export default function AddonManager() {
)
}
function AddonRow({ addon, onToggle, t }) {
interface AddonRowProps {
addon: Addon
onToggle: (addonId: string) => void
t: (key: string) => string
}
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
const nameKey = `admin.addons.catalog.${addon.id}.name`
const descKey = `admin.addons.catalog.${addon.id}.description`
const translatedName = t(nameKey)
const translatedDescription = t(descKey)
return {
name: translatedName !== nameKey ? translatedName : addon.name,
description: translatedDescription !== descKey ? translatedDescription : addon.description,
}
}
function AddonRow({ addon, onToggle, t }: AddonRowProps) {
const isComingSoon = false
const label = getAddonLabel(t, addon)
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 */}
@@ -129,7 +182,7 @@ function AddonRow({ addon, onToggle, t }) {
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{addon.name}</span>
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{label.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
@@ -142,12 +195,12 @@ function AddonRow({ addon, onToggle, t }) {
{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>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{label.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)' }}>
<span className="hidden sm:inline 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
@@ -0,0 +1,166 @@
import React, { useCallback, useEffect, useState } from 'react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { RefreshCw, ClipboardList } from 'lucide-react'
interface AuditEntry {
id: number
created_at: string
user_id: number | null
username: string | null
user_email: string | null
action: string
resource: string | null
details: Record<string, unknown> | null
ip: string | null
}
export default function AuditLogPanel(): React.ReactElement {
const { t, locale } = useTranslation()
const [entries, setEntries] = useState<AuditEntry[]>([])
const [total, setTotal] = useState(0)
const [offset, setOffset] = useState(0)
const [loading, setLoading] = useState(true)
const limit = 100
const loadFirstPage = useCallback(async () => {
setLoading(true)
try {
const data = await adminApi.auditLog({ limit, offset: 0 }) as {
entries: AuditEntry[]
total: number
}
setEntries(data.entries || [])
setTotal(data.total ?? 0)
setOffset(0)
} catch {
setEntries([])
setTotal(0)
setOffset(0)
} finally {
setLoading(false)
}
}, [])
const loadMore = useCallback(async () => {
const nextOffset = offset + limit
setLoading(true)
try {
const data = await adminApi.auditLog({ limit, offset: nextOffset }) as {
entries: AuditEntry[]
total: number
}
setEntries((prev) => [...prev, ...(data.entries || [])])
setTotal(data.total ?? 0)
setOffset(nextOffset)
} catch {
/* keep existing */
} finally {
setLoading(false)
}
}, [offset])
useEffect(() => {
loadFirstPage()
}, [loadFirstPage])
const fmtTime = (iso: string) => {
try {
return new Date(iso).toLocaleString(locale, {
dateStyle: 'short',
timeStyle: 'medium',
})
} catch {
return iso
}
}
const fmtDetails = (d: Record<string, unknown> | null) => {
if (!d || Object.keys(d).length === 0) return '—'
try {
return JSON.stringify(d)
} catch {
return '—'
}
}
const userLabel = (e: AuditEntry) => {
if (e.username) return e.username
if (e.user_email) return e.user_email
if (e.user_id != null) return `#${e.user_id}`
return '—'
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-lg m-0 flex items-center gap-2" style={{ color: 'var(--text-primary)' }}>
<ClipboardList size={20} />
{t('admin.tabs.audit')}
</h2>
<p className="text-sm m-0 mt-1" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.subtitle')}</p>
</div>
<button
type="button"
disabled={loading}
onClick={() => loadFirstPage()}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-primary)', background: 'var(--bg-card)' }}
>
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
{t('admin.audit.refresh')}
</button>
</div>
<p className="text-xs m-0" style={{ color: 'var(--text-faint)' }}>
{t('admin.audit.showing', { count: entries.length, total })}
</p>
{loading && entries.length === 0 ? (
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('common.loading')}</div>
) : entries.length === 0 ? (
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.empty')}</div>
) : (
<div className="rounded-xl border overflow-x-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
<table className="w-full text-sm border-collapse min-w-[720px]">
<thead>
<tr className="border-b text-left" style={{ borderColor: 'var(--border-secondary)' }}>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.time')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.user')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.action')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.resource')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.ip')}</th>
<th className="p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.details')}</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.id} className="border-b align-top" style={{ borderColor: 'var(--border-secondary)' }}>
<td className="p-3 whitespace-nowrap font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{fmtTime(e.created_at)}</td>
<td className="p-3" style={{ color: 'var(--text-primary)' }}>{userLabel(e)}</td>
<td className="p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{e.action}</td>
<td className="p-3 font-mono text-xs break-all max-w-[140px]" style={{ color: 'var(--text-muted)' }}>{e.resource || '—'}</td>
<td className="p-3 font-mono text-xs whitespace-nowrap" style={{ color: 'var(--text-muted)' }}>{e.ip || '—'}</td>
<td className="p-3 font-mono text-xs break-all max-w-[280px]" style={{ color: 'var(--text-faint)' }}>{fmtDetails(e.details)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{entries.length < total && (
<button
type="button"
disabled={loading}
onClick={() => loadMore()}
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50"
style={{ color: 'var(--text-secondary)' }}
>
{t('admin.audit.loadMore')}
</button>
)}
</div>
)
}
@@ -1,8 +1,11 @@
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, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import CustomSelect from '../shared/CustomSelect'
import { getApiErrorMessage } from '../../types'
const INTERVAL_OPTIONS = [
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
@@ -20,19 +23,35 @@ const KEEP_OPTIONS = [
{ value: 0, labelKey: 'backup.keep.forever' },
]
const DAYS_OF_WEEK = [
{ value: 0, labelKey: 'backup.dow.sunday' },
{ value: 1, labelKey: 'backup.dow.monday' },
{ value: 2, labelKey: 'backup.dow.tuesday' },
{ value: 3, labelKey: 'backup.dow.wednesday' },
{ value: 4, labelKey: 'backup.dow.thursday' },
{ value: 5, labelKey: 'backup.dow.friday' },
{ value: 6, labelKey: 'backup.dow.saturday' },
]
const HOURS = Array.from({ length: 24 }, (_, i) => i)
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
export default function BackupPanel() {
const [backups, setBackups] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [restoringFile, setRestoringFile] = useState(null)
const [isUploading, setIsUploading] = useState(false)
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
const [serverTimezone, setServerTimezone] = useState('')
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
const fileInputRef = useRef(null)
const toast = useToast()
const { t, language, locale } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const loadBackups = async () => {
setIsLoading(true)
@@ -50,6 +69,7 @@ export default function BackupPanel() {
try {
const data = await backupApi.getAutoSettings()
setAutoSettings(data.settings)
if (data.timezone) setServerTimezone(data.timezone)
} catch {}
}
@@ -73,7 +93,7 @@ export default function BackupPanel() {
}
const handleUploadRestore = (e) => {
const file = e.target.files?.[0]
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
e.target.value = ''
setRestoreConfirm({ type: 'upload', filename: file.name, file })
@@ -90,8 +110,8 @@ export default function BackupPanel() {
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'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('backup.toast.restoreError')))
setRestoringFile(null)
}
} else {
@@ -100,8 +120,8 @@ export default function BackupPanel() {
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'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('backup.toast.uploadError')))
setIsUploading(false)
}
}
@@ -146,10 +166,12 @@ export default function BackupPanel() {
const formatDate = (dateStr) => {
if (!dateStr) return '-'
try {
return new Date(dateStr).toLocaleString(locale, {
const opts: Intl.DateTimeFormatOptions = {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
if (serverTimezone) opts.timeZone = serverTimezone
return new Date(dateStr).toLocaleString(locale, opts)
} catch { return dateStr }
}
@@ -330,6 +352,68 @@ export default function BackupPanel() {
</div>
</div>
{/* Hour picker (for daily, weekly, monthly) */}
{autoSettings.interval !== 'hourly' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
<CustomSelect
value={String(autoSettings.hour)}
onChange={v => handleAutoSettingsChange('hour', parseInt(v, 10))}
size="sm"
options={HOURS.map(h => {
let label: string
if (is12h) {
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
label = `${h12}:00 ${period}`
} else {
label = `${String(h).padStart(2, '0')}:00`
}
return { value: String(h), label }
})}
/>
<p className="text-xs text-gray-400 mt-1">
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
</p>
</div>
)}
{/* Day of week (for weekly) */}
{autoSettings.interval === 'weekly' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
<div className="flex flex-wrap gap-2">
{DAYS_OF_WEEK.map(opt => (
<button
key={opt.value}
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
autoSettings.day_of_week === opt.value
? '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'
}`}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
)}
{/* Day of month (for monthly) */}
{autoSettings.interval === 'monthly' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
<CustomSelect
value={String(autoSettings.day_of_month)}
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(v, 10))}
size="sm"
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
/>
<p className="text-xs text-gray-400 mt-1">{t('backup.auto.dayOfMonthHint')}</p>
</div>
)}
{/* Keep duration */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
@@ -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')))
}
}
@@ -197,7 +198,6 @@ export default function CategoryManager() {
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" />
<span className="hidden sm:inline">{t('categories.new')}</span>
<span className="sm:hidden">Add</span>
</button>
</div>
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useState, useEffect } from 'react'
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee } from 'lucide-react'
import { getLocaleForLanguage, useTranslation } from '../../i18n'
import apiClient from '../../api/client'
const REPO = 'mauriceboe/NOMAD'
const PER_PAGE = 10
@@ -17,13 +18,12 @@ export default function GitHubPanel() {
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()
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
const data = Array.isArray(res.data) ? res.data : []
setReleases(prev => append ? [...prev, ...data] : data)
setHasMore(data.length === PER_PAGE)
} catch (err) {
setError(err.message)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Unknown error')
}
}
@@ -46,7 +46,7 @@ export default function GitHubPanel() {
const formatDate = (dateStr) => {
const d = new Date(dateStr)
return d.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric' })
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' })
}
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
@@ -112,30 +112,63 @@ export default function GitHubPanel() {
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 */}
{/* Support cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<a
href="https://ko-fi.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ff5e5b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Coffee size={20} style={{ color: '#ff5e5b' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Ko-fi</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://buymeacoffee.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ffdd0015', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Heart size={20} style={{ color: '#ffdd00' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Buy Me a Coffee</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
</div>
{/* Loading / Error / Releases */}
{loading ? (
<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>
) : error ? (
<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>
) : (
<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>
@@ -258,6 +291,7 @@ export default function GitHubPanel() {
)}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,306 @@
import { useState, useEffect, useRef } from 'react'
import { adminApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { Plus, Trash2, Edit2, Package, X, Check, ChevronDown, ChevronRight, FolderPlus } from 'lucide-react'
interface TemplateCategory { id: number; template_id: number; name: string; sort_order: number }
interface TemplateItem { id: number; category_id: number; name: string; sort_order: number }
interface Template { id: number; name: string; item_count: number; category_count: number; created_by_name: string }
export default function PackingTemplateManager() {
const [templates, setTemplates] = useState<Template[]>([])
const [isLoading, setIsLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [createName, setCreateName] = useState('')
// Expanded template state
const [expandedId, setExpandedId] = useState<number | null>(null)
const [categories, setCategories] = useState<TemplateCategory[]>([])
const [items, setItems] = useState<TemplateItem[]>([])
// Editing states
const [editingTemplate, setEditingTemplate] = useState<number | null>(null)
const [editTemplateName, setEditTemplateName] = useState('')
const [editingCatId, setEditingCatId] = useState<number | null>(null)
const [editCatName, setEditCatName] = useState('')
const [editingItemId, setEditingItemId] = useState<number | null>(null)
const [editItemName, setEditItemName] = useState('')
// Adding states
const [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('')
const [addingItemToCatId, setAddingItemToCatId] = useState<number | null>(null)
const [newItemName, setNewItemName] = useState('')
const addItemRef = useRef<HTMLInputElement>(null)
const toast = useToast()
const { t } = useTranslation()
useEffect(() => { loadTemplates() }, [])
const loadTemplates = async () => {
setIsLoading(true)
try {
const data = await adminApi.packingTemplates()
setTemplates(data.templates || [])
} catch { toast.error(t('admin.packingTemplates.loadError')) }
finally { setIsLoading(false) }
}
const toggleExpand = async (id: number) => {
if (expandedId === id) { setExpandedId(null); return }
setExpandedId(id)
setAddingCategory(false)
setAddingItemToCatId(null)
try {
const data = await adminApi.getPackingTemplate(id)
setCategories(data.categories || [])
setItems(data.items || [])
} catch { toast.error(t('admin.packingTemplates.loadError')) }
}
// Template CRUD
const handleCreateTemplate = async () => {
if (!createName.trim()) return
try {
const data = await adminApi.createPackingTemplate({ name: createName.trim() })
setTemplates(prev => [{ ...data.template, item_count: 0, category_count: 0 }, ...prev])
setCreateName(''); setShowCreate(false)
setExpandedId(data.template.id); setCategories([]); setItems([])
toast.success(t('admin.packingTemplates.created'))
} catch { toast.error(t('admin.packingTemplates.createError')) }
}
const handleDeleteTemplate = async (id: number) => {
try {
await adminApi.deletePackingTemplate(id)
setTemplates(prev => prev.filter(t => t.id !== id))
if (expandedId === id) setExpandedId(null)
toast.success(t('admin.packingTemplates.deleted'))
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
}
const handleRenameTemplate = async (id: number) => {
if (!editTemplateName.trim()) { setEditingTemplate(null); return }
try {
await adminApi.updatePackingTemplate(id, { name: editTemplateName.trim() })
setTemplates(prev => prev.map(t => t.id === id ? { ...t, name: editTemplateName.trim() } : t))
setEditingTemplate(null)
} catch { toast.error(t('admin.packingTemplates.saveError')) }
}
// Category CRUD
const handleAddCategory = async () => {
if (!newCatName.trim() || !expandedId) return
try {
const data = await adminApi.addTemplateCategory(expandedId, { name: newCatName.trim() })
setCategories(prev => [...prev, data.category])
setNewCatName(''); setAddingCategory(false)
} catch { toast.error(t('admin.packingTemplates.saveError')) }
}
const handleRenameCategory = async (catId: number) => {
if (!editCatName.trim() || !expandedId) { setEditingCatId(null); return }
try {
await adminApi.updateTemplateCategory(expandedId, catId, { name: editCatName.trim() })
setCategories(prev => prev.map(c => c.id === catId ? { ...c, name: editCatName.trim() } : c))
setEditingCatId(null)
} catch { toast.error(t('admin.packingTemplates.saveError')) }
}
const handleDeleteCategory = async (catId: number) => {
if (!expandedId) return
try {
await adminApi.deleteTemplateCategory(expandedId, catId)
setCategories(prev => prev.filter(c => c.id !== catId))
setItems(prev => prev.filter(i => i.category_id !== catId))
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
}
// Item CRUD
const handleAddItem = async (catId: number) => {
if (!newItemName.trim() || !expandedId) return
try {
const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() })
setItems(prev => [...prev, data.item])
setNewItemName('')
setTimeout(() => addItemRef.current?.focus(), 30)
} catch { toast.error(t('admin.packingTemplates.saveError')) }
}
const handleRenameItem = async (itemId: number) => {
if (!editItemName.trim() || !expandedId) { setEditingItemId(null); return }
try {
await adminApi.updateTemplateItem(expandedId, itemId, { name: editItemName.trim() })
setItems(prev => prev.map(i => i.id === itemId ? { ...i, name: editItemName.trim() } : i))
setEditingItemId(null)
} catch { toast.error(t('admin.packingTemplates.saveError')) }
}
const handleDeleteItem = async (itemId: number) => {
if (!expandedId) return
try {
await adminApi.deleteTemplateItem(expandedId, itemId)
setItems(prev => prev.filter(i => i.id !== itemId))
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
}
const inputStyle = 'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none'
const btnIcon = 'p-1.5 rounded-lg transition-colors'
return (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
{/* Header */}
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
<div>
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.packingTemplates.subtitle')}</p>
</div>
<button onClick={() => setShowCreate(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">
<Plus className="w-4 h-4" /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
</button>
</div>
{/* Create template */}
{showCreate && (
<div className="px-5 py-3 border-b border-slate-100 flex items-center gap-3">
<Package size={16} className="text-slate-400 flex-shrink-0" />
<input autoFocus value={createName} onChange={e => setCreateName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }}
placeholder={t('admin.packingTemplates.namePlaceholder')} className={inputStyle} />
<button onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
<button onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
</div>
)}
{/* Template list */}
{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>
) : templates.length === 0 ? (
<div className="p-8 text-center text-sm text-slate-400">{t('admin.packingTemplates.empty')}</div>
) : (
<div className="divide-y divide-slate-100">
{templates.map(tmpl => (
<div key={tmpl.id}>
{/* Template row */}
<div className="px-5 py-3 flex items-center gap-3 hover:bg-slate-50 transition-colors">
<button onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
{expandedId === tmpl.id ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
</button>
<Package size={16} className="text-slate-400 flex-shrink-0" />
{editingTemplate === tmpl.id ? (
<input autoFocus value={editTemplateName} onChange={e => setEditTemplateName(e.target.value)}
onBlur={() => handleRenameTemplate(tmpl.id)}
onKeyDown={e => { if (e.key === 'Enter') handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" />
) : (
<span onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</span>
)}
<span className="text-xs text-slate-400 px-2 py-0.5 bg-slate-100 rounded-full">
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}
</span>
<button onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
className={`${btnIcon} hover:bg-slate-100 text-slate-400 hover:text-slate-700`}><Edit2 size={14} /></button>
<button onClick={() => handleDeleteTemplate(tmpl.id)}
className={`${btnIcon} hover:bg-red-50 text-slate-400 hover:text-red-500`}><Trash2 size={14} /></button>
</div>
{/* Expanded content */}
{expandedId === tmpl.id && (
<div className="px-5 pb-4 ml-8 space-y-3">
{categories.map(cat => {
const catItems = items.filter(i => i.category_id === cat.id)
return (
<div key={cat.id} className="border border-slate-200 rounded-lg overflow-hidden">
{/* Category header */}
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50">
{editingCatId === cat.id ? (
<>
<input autoFocus value={editCatName} onChange={e => setEditCatName(e.target.value)}
onBlur={() => handleRenameCategory(cat.id)}
onKeyDown={e => { if (e.key === 'Enter') handleRenameCategory(cat.id); if (e.key === 'Escape') setEditingCatId(null) }}
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm font-semibold" />
</>
) : (
<span className="flex-1 text-xs font-bold text-slate-500 uppercase tracking-wider">{cat.name}</span>
)}
<span className="text-xs text-slate-400">{catItems.length}</span>
<button onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Plus size={13} /></button>
<button onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Edit2 size={13} /></button>
<button onClick={() => handleDeleteCategory(cat.id)}
className={`${btnIcon} text-slate-400 hover:text-red-500`}><Trash2 size={13} /></button>
</div>
{/* Items */}
{(catItems.length > 0 || addingItemToCatId === cat.id) && (
<div className="divide-y divide-slate-50">
{catItems.map(item => (
<div key={item.id} className="flex items-center gap-3 px-4 py-2 group">
{editingItemId === item.id ? (
<>
<input autoFocus value={editItemName} onChange={e => setEditItemName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }}
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
<button onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
<button onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
</>
) : (
<>
<span className="flex-1 text-sm text-slate-700">{item.name}</span>
<button onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-700 transition-all"><Edit2 size={12} /></button>
<button onClick={() => handleDeleteItem(item.id)}
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all"><Trash2 size={12} /></button>
</>
)}
</div>
))}
{/* Add item inline */}
{addingItemToCatId === cat.id && (
<div className="flex items-center gap-2 px-4 py-2">
<input ref={addItemRef} value={newItemName} onChange={e => setNewItemName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }}
placeholder={t('admin.packingTemplates.itemName')}
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
<button onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
className="p-1.5 rounded-lg bg-slate-900 text-white disabled:bg-slate-300 hover:bg-slate-700 transition-colors"><Plus size={13} /></button>
<button onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
className="p-1 text-slate-400 hover:text-slate-600"><X size={13} /></button>
</div>
)}
</div>
)}
</div>
)
})}
{/* Add category button */}
{addingCategory ? (
<div className="flex items-center gap-2">
<input autoFocus value={newCatName} onChange={e => setNewCatName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
placeholder={t('admin.packingTemplates.categoryName')}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm" />
<button onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
<button onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
</div>
) : (
<button onClick={() => setAddingCategory(true)}
className="flex items-center gap-2 px-3 py-2.5 w-full text-sm text-slate-400 hover:text-slate-600 border border-dashed border-slate-200 rounded-lg hover:border-slate-400 transition-colors">
<FolderPlus size={14} /> {t('admin.packingTemplates.addCategory')}
</button>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
)
}
@@ -1,19 +1,57 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } 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, Pencil, Users, Check } from 'lucide-react'
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { budgetApi } from '../../api/client'
import type { BudgetItem, BudgetMember } from '../../types'
import { currencyDecimals } from '../../utils/formatters'
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']
const SYMBOLS = { EUR: '€', USD: '$', GBP: '£', JPY: ', CHF: 'CHF', CZK: 'Kč', PLN: 'zł', SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$' }
const CURRENCIES = [
'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
]
const SYMBOLS = {
EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł',
SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$',
NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM',
PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$',
ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD',
HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽',
UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$',
PEN: 'S/.', ARS: 'AR$',
}
const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
const fmtNum = (v, locale, cur) => {
if (v == null || isNaN(v)) return '-'
return Number(v).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ' + (SYMBOLS[cur] || cur)
const d = currencyDecimals(cur)
return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur)
}
const calcPP = (p, n) => (n > 0 ? p / n : null)
@@ -60,7 +98,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('')
@@ -113,7 +156,15 @@ function AddItemRow({ onAdd, t }) {
}
// ── Chip with custom tooltip ─────────────────────────────────────────────────
function ChipWithTooltip({ label, avatarUrl, size = 20 }) {
interface ChipWithTooltipProps {
label: string
avatarUrl: string | null
size?: number
paid?: boolean
onClick?: () => void
}
function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) {
const [hover, setHover] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const ref = useRef(null)
@@ -126,13 +177,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }) {
setHover(true)
}
const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
return (
<>
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
onClick={onClick}
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,
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
transition: 'border-color 0.15s, background 0.15s',
}}>
{avatarUrl
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
@@ -143,11 +200,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }) {
<div style={{
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
display: 'flex', alignItems: 'center', gap: 5,
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}
{paid && (
<span style={{
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
background: 'rgba(34,197,94,0.15)', color: '#16a34a',
textTransform: 'uppercase', letterSpacing: '0.03em',
}}>Paid</span>
)}
</div>,
document.body
)}
@@ -156,7 +221,15 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }) {
}
// ── Budget Member Chips (for Persons column) ────────────────────────────────
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compact = true }) {
interface BudgetMemberChipsProps {
members?: BudgetMember[]
tripMembers?: TripMember[]
onSetMembers: (memberIds: number[]) => void
onTogglePaid?: (userId: number, paid: boolean) => void
compact?: boolean
}
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, 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)
@@ -196,7 +269,10 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compa
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} />
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize}
paid={!!m.paid}
onClick={onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
/>
))}
<button ref={btnRef} onClick={openDropdown}
style={{
@@ -246,7 +322,14 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compa
}
// ── Per-Person Inline (inside total card) ────────────────────────────────────
function PerPersonInline({ tripId, budgetItems, currency, locale }) {
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)
@@ -279,7 +362,13 @@ function PerPersonInline({ tripId, budgetItems, currency, locale }) {
}
// ── 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)
@@ -316,16 +405,29 @@ function PieChart({ segments, size = 200, totalLabel }) {
}
// ── Main Component ───────────────────────────────────────────────────────────
export default function BudgetPanel({ tripId, tripMembers = [] }) {
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers } = useTripStore()
interface BudgetPanelProps {
tripId: number
tripMembers?: TripMember[]
}
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore()
const { t, locale } = useTranslation()
const [newCategoryName, setNewCategoryName] = useState('')
const [editingCat, setEditingCat] = useState(null) // { name, value }
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
const [settlementOpen, setSettlementOpen] = useState(false)
const currency = trip?.currency || 'EUR'
const fmt = (v, cur) => fmtNum(v, locale, cur)
const hasMultipleMembers = tripMembers.length > 1
// Load settlement data whenever budget items change
useEffect(() => {
if (!hasMultipleMembers) return
budgetApi.settlement(tripId).then(setSettlement).catch(() => {})
}, [tripId, budgetItems, hasMultipleMembers])
const setCurrency = (cur) => {
if (tripId) updateTrip(tripId, { currency: cur })
}
@@ -355,12 +457,12 @@ export default function BudgetPanel({ tripId, tripMembers = [] }) {
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 items) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
}
const handleAddCategory = () => {
if (!newCategoryName.trim()) return
@@ -480,13 +582,14 @@ export default function BudgetPanel({ tripId, tripMembers = [] }) {
members={item.members || []}
tripMembers={tripMembers}
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
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')} />
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} />
</td>
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
{hasMultipleMembers ? (
@@ -494,6 +597,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }) {
members={item.members || []}
tripMembers={tripMembers}
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
/>
) : (
<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')} />
@@ -563,12 +667,97 @@ export default function BudgetPanel({ tripId, tripMembers = [] }) {
</div>
</div>
<div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
</div>
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} />
)}
{/* Settlement dropdown inside the total card */}
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 12 }}>
<button onClick={() => setSettlementOpen(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
color: 'rgba(255,255,255,0.6)', fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
}}>
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{t('budget.settlement')}
<span style={{ position: 'relative', display: 'inline-flex', marginLeft: 2 }}>
<span style={{ display: 'flex', cursor: 'help' }}
onMouseEnter={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'block' }}
onMouseLeave={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'none' }}
onClick={e => e.stopPropagation()}
>
<Info size={11} strokeWidth={2} />
</span>
<div style={{
display: 'none', position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
marginTop: 6, width: 220, padding: '10px 12px', borderRadius: 10, zIndex: 100,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
fontSize: 11, fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
}}>
{t('budget.settlementInfo')}
</div>
</span>
</button>
{settlementOpen && (
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
{settlement.flows.map((flow, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
padding: '8px 10px', borderRadius: 10,
background: 'rgba(255,255,255,0.06)',
}}>
<ChipWithTooltip label={flow.from.username} avatarUrl={flow.from.avatar_url} size={28} />
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}></span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#f87171', whiteSpace: 'nowrap' }}>
{fmt(flow.amount, currency)}
</span>
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}></span>
</div>
<ChipWithTooltip label={flow.to.username} avatarUrl={flow.to.avatar_url} size={28} />
</div>
))}
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
<div style={{ marginTop: 4, borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 8 }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'rgba(255,255,255,0.35)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
{t('budget.netBalances')}
</div>
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => (
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '2px 0' }}>
<div style={{
width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 8, fontWeight: 700, color: 'rgba(255,255,255,0.6)', overflow: 'hidden',
}}>
{b.avatar_url
? <img src={b.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: b.username?.[0]?.toUpperCase()
}
</div>
<span style={{ flex: 1, fontSize: 11, color: 'rgba(255,255,255,0.6)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{b.username}
</span>
<span style={{
fontSize: 11, fontWeight: 600, flexShrink: 0,
color: b.balance > 0 ? '#4ade80' : '#f87171',
}}>
{b.balance > 0 ? '+' : ''}{fmt(b.balance, currency)}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
{pieSegments.length > 0 && (
@@ -582,27 +771,19 @@ export default function BudgetPanel({ tripId, tripMembers = [] }) {
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 6 }}>
{pieSegments.map(seg => {
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
return (
<div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{seg.name}</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap' }}>{pct}%</span>
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(seg.value, currency)}</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap', minWidth: 38, textAlign: 'right' }}>{pct}%</span>
</div>
)
})}
</div>
<div style={{ marginTop: 12, borderTop: '1px solid var(--border-secondary)', paddingTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
{pieSegments.map(seg => (
<div key={seg.name} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{seg.name}</span>
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 600 }}>{fmt(seg.value, currency)}</span>
</div>
))}
</div>
</div>
)}
@@ -5,6 +5,25 @@ 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) {
@@ -75,7 +94,14 @@ function shouldShowDateSeparator(msg, prevMsg) {
}
/* ── Emoji Picker ── */
function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }) {
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)
@@ -142,7 +168,14 @@ function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }) {
/* ── Reaction Quick Menu (right-click) ── */
const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
function ReactionMenu({ x, y, onReact, onClose }) {
interface ReactionMenuProps {
x: number
y: number
onReact: (emoji: string) => void
onClose: () => void
}
function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) {
const ref = useRef(null)
useEffect(() => {
@@ -179,7 +212,11 @@ function ReactionMenu({ x, y, onReact, onClose }) {
}
/* ── Message Text with clickable URLs ── */
function MessageText({ text }) {
interface MessageTextProps {
text: string
}
function MessageText({ text }: MessageTextProps) {
const parts = text.split(URL_REGEX)
const urls = text.match(URL_REGEX) || []
const result = []
@@ -198,7 +235,14 @@ function MessageText({ text }) {
const URL_REGEX = /https?:\/\/[^\s<>"']+/g
const previewCache = {}
function LinkPreview({ url, tripId, own, onLoad }) {
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])
@@ -252,7 +296,13 @@ function LinkPreview({ url, tripId, own, onLoad }) {
}
/* ── Reaction Badge with NOMAD tooltip ── */
function ReactionBadge({ reaction, currentUserId, onReact }) {
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)
@@ -295,7 +345,12 @@ function ReactionBadge({ reaction, currentUserId, onReact }) {
}
/* ── Main Component ── */
export default function CollabChat({ tripId, currentUser }) {
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'
@@ -1,16 +1,58 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink } from 'lucide-react'
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import DOM from 'react-dom'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react'
import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import type { User } from '../../types'
interface NoteFile {
id: number
filename: string
original_name: string
mime_type: string
url?: string
}
interface CollabNote {
id: number
trip_id: number
title: string
content: string
category: string
website: string | null
pinned: boolean
color: string | null
username: string
avatar_url: string | null
avatar: string | null
user_id: number
created_at: string
author?: { username: string; avatar: string | null }
user?: { username: string; avatar: string | null }
files?: NoteFile[]
}
interface NoteAuthor {
username: string
avatar?: string | null
}
const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
// ── Website Thumbnail (fetches OG image) ────────────────────────────────────
const ogCache = {}
function WebsiteThumbnail({ url, tripId, color }) {
interface WebsiteThumbnailProps {
url: string
tripId: number
color: string
}
function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps) {
const [data, setData] = useState(ogCache[url] || null)
const [failed, setFailed] = useState(false)
@@ -46,7 +88,12 @@ function WebsiteThumbnail({ url, tripId, color }) {
}
// ── File Preview Portal ─────────────────────────────────────────────────────
function FilePreviewPortal({ file, onClose }) {
interface FilePreviewPortalProps {
file: NoteFile | null
onClose: () => void
}
function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
if (!file) return null
const url = file.url || `/uploads/${file.filename}`
const isImage = file.mime_type?.startsWith('image/')
@@ -120,7 +167,12 @@ const formatTimestamp = (ts, t, locale) => {
}
// ── Avatar ──────────────────────────────────────────────────────────────────
function UserAvatar({ user, size = 14 }) {
interface UserAvatarProps {
user: NoteAuthor | null
size?: number
}
function UserAvatar({ user, size = 14 }: UserAvatarProps) {
if (!user) return null
if (user.avatar) {
return (
@@ -161,7 +213,19 @@ function UserAvatar({ user, size = 14 }) {
}
// ── New Note Modal (portal to body) ─────────────────────────────────────────
function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }) {
interface NoteFormModalProps {
onClose: () => void
onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise<void>
onDeleteFile: (noteId: number, fileId: number) => Promise<void>
existingCategories: string[]
categoryColors: Record<string, string>
getCategoryColor: (category: string) => string
note: CollabNote | null
tripId: number
t: (key: string) => string
}
function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) {
const isEdit = !!note
const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
@@ -236,7 +300,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
onPaste={e => {
const items = e.clipboardData?.items
if (!items) return
for (const item of items) {
for (const item of Array.from(items)) {
if (item.type.startsWith('image/') || item.type === 'application/pdf') {
e.preventDefault()
const file = item.getAsFile()
@@ -350,7 +414,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
outline: 'none',
boxSizing: 'border-box',
resize: 'vertical',
minHeight: 90,
minHeight: 180,
lineHeight: 1.5,
}}
/>
@@ -390,7 +454,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
{t('collab.notes.attachFiles')}
</div>
<input id="note-file-input" ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { setPendingFiles(prev => [...prev, ...Array.from(e.target.files)]); e.target.value = '' }} />
<input id="note-file-input" ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { setPendingFiles(prev => [...prev, ...Array.from((e.target as HTMLInputElement).files)]); e.target.value = '' }} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
{/* Existing attachments (edit mode) */}
{existingAttachments.map(a => {
@@ -448,7 +512,12 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
)
}
function EditableCatName({ name, onRename }) {
interface EditableCatNameProps {
name: string
onRename: (newName: string) => void
}
function EditableCatName({ name, onRename }: EditableCatNameProps) {
const [editing, setEditing] = useState(false)
const [value, setValue] = useState(name)
const inputRef = useRef(null)
@@ -477,7 +546,16 @@ function EditableCatName({ name, onRename }) {
}
// ── Category Settings Modal ──────────────────────────────────────────────────
function CategorySettingsModal({ onClose, categories, categoryColors, onSave, onRenameCategory, t }) {
interface CategorySettingsModalProps {
onClose: () => void
categories: string[]
categoryColors: Record<string, string>
onSave: (colors: Record<string, string>) => void
onRenameCategory: (oldName: string, newName: string) => Promise<void>
t: (key: string) => string
}
function CategorySettingsModal({ onClose, categories, categoryColors, onSave, onRenameCategory, t }: CategorySettingsModalProps) {
const [localColors, setLocalColors] = useState({ ...categoryColors })
const [renames, setRenames] = useState({}) // { oldName: newName }
const [newCatName, setNewCatName] = useState('')
@@ -608,7 +686,20 @@ function CategorySettingsModal({ onClose, categories, categoryColors, onSave, on
}
// ── Note Card ───────────────────────────────────────────────────────────────
function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile, getCategoryColor, tripId, t }) {
interface NoteCardProps {
note: CollabNote
currentUser: User
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
onDelete: (noteId: number) => Promise<void>
onEdit: (note: CollabNote) => void
onView: (note: CollabNote) => void
onPreviewFile: (file: NoteFile) => void
getCategoryColor: (category: string) => string
tripId: number
t: (key: string) => string
}
function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
const [hovered, setHovered] = useState(false)
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
@@ -661,6 +752,14 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile
<div style={{
display: 'flex', gap: 2,
}}>
{note.content && (
<button onClick={() => onView?.(note)} title={t('collab.notes.expand') || 'Expand'}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Maximize2 size={10} />
</button>
)}
<button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = color}
@@ -711,13 +810,13 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
{note.content && (
<p style={{
<div className="collab-note-md" style={{
fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, margin: 0,
display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical',
overflow: 'hidden', wordBreak: 'break-word', fontFamily: FONT,
maxHeight: '4.5em', overflow: 'hidden',
wordBreak: 'break-word', fontFamily: FONT,
}}>
{note.content}
</p>
<Markdown remarkPlugins={[remarkGfm]}>{note.content}</Markdown>
</div>
)}
</div>
{/* Right: website + attachment thumbnails */}
@@ -773,12 +872,18 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile
}
// ── Main Component ──────────────────────────────────────────────────────────
export default function CollabNotes({ tripId, currentUser }) {
interface CollabNotesProps {
tripId: number
currentUser: User
}
export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
const { t } = useTranslation()
const [notes, setNotes] = useState([])
const [loading, setLoading] = useState(true)
const [showNewModal, setShowNewModal] = useState(false)
const [editingNote, setEditingNote] = useState(null)
const [viewingNote, setViewingNote] = useState<CollabNote | null>(null)
const [previewFile, setPreviewFile] = useState(null)
const [showSettings, setShowSettings] = useState(false)
const [activeCategory, setActiveCategory] = useState(null)
@@ -1150,6 +1255,7 @@ export default function CollabNotes({ tripId, currentUser }) {
onUpdate={handleUpdateNote}
onDelete={handleDeleteNote}
onEdit={setEditingNote}
onView={setViewingNote}
onPreviewFile={setPreviewFile}
getCategoryColor={getCategoryColor}
tripId={tripId}
@@ -1161,6 +1267,64 @@ export default function CollabNotes({ tripId, currentUser }) {
</div>
{/* ── New Note Modal ── */}
{/* View note modal */}
{viewingNote && ReactDOM.createPortal(
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 10000, padding: 16,
}}
onClick={() => setViewingNote(null)}
>
<div
style={{
background: 'var(--bg-card)', borderRadius: 16,
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
width: 'min(700px, calc(100vw - 32px))', maxHeight: '80vh',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}
onClick={e => e.stopPropagation()}
>
<div style={{
padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 17, fontWeight: 600, color: 'var(--text-primary)' }}>{viewingNote.title}</div>
{viewingNote.category && (
<span style={{
display: 'inline-block', marginTop: 4, fontSize: 10, fontWeight: 600,
color: getCategoryColor(viewingNote.category),
background: `${getCategoryColor(viewingNote.category)}18`,
padding: '2px 8px', borderRadius: 6,
}}>{viewingNote.category}</span>
)}
</div>
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
<button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
style={{ padding: 6, 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)'}>
<Pencil size={16} />
</button>
<button onClick={() => setViewingNote(null)}
style={{ padding: 6, 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)'}>
<X size={18} />
</button>
</div>
</div>
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
<Markdown remarkPlugins={[remarkGfm]}>{viewingNote.content || ''}</Markdown>
</div>
</div>
</div>,
document.body
)}
{showNewModal && (
<NoteFormModal
onClose={() => setShowNewModal(false)}
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import { useState, useEffect } from 'react'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
@@ -23,7 +23,18 @@ const card = {
overflow: 'hidden', minHeight: 0,
}
export default function CollabPanel({ tripId, tripMembers = [] }) {
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')
@@ -4,6 +4,30 @@ 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"
@@ -29,7 +53,13 @@ function totalVotes(poll) {
}
// ── Create Poll Modal ────────────────────────────────────────────────────────
function CreatePollModal({ onClose, onCreate, t }) {
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)
@@ -111,7 +141,12 @@ function CreatePollModal({ onClose, onCreate, t }) {
}
// ── Voter Chip with custom tooltip ────────────────────────────────────────────
function VoterChip({ voter, offset }) {
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 })
@@ -152,7 +187,16 @@ function VoterChip({ voter, offset }) {
}
// ── Poll Card ────────────────────────────────────────────────────────────────
function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }) {
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)
@@ -286,7 +330,12 @@ function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }) {
}
// ── Main Component ───────────────────────────────────────────────────────────
export default function CollabPolls({ tripId, currentUser }) {
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)
@@ -26,7 +26,17 @@ function formatDayLabel(date, t, locale) {
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short' })
}
export default function WhatsNextWidget({ tripMembers = [] }) {
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'
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { ArrowRightLeft, RefreshCw } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
@@ -14,7 +14,7 @@ const CURRENCIES = [
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
export default function CurrencyWidget() {
const { t } = useTranslation()
const { t, locale } = useTranslation()
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
const [amount, setAmount] = useState('100')
@@ -40,7 +40,7 @@ export default function CurrencyWidget() {
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 })
return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const result = rawResult
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'
import { useState, useEffect } from 'react'
import { Clock, Plus, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
const POPULAR_ZONES = [
{ label: 'New York', tz: 'America/New_York' },
@@ -23,9 +24,9 @@ const POPULAR_ZONES = [
{ label: 'Cairo', tz: 'Africa/Cairo' },
]
function getTime(tz) {
function getTime(tz, locale, is12h) {
try {
return new Date().toLocaleTimeString('de-DE', { timeZone: tz, hour: '2-digit', minute: '2-digit' })
return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h })
} catch { return '—' }
}
@@ -41,7 +42,8 @@ function getOffset(tz) {
}
export default function TimezoneWidget() {
const { t } = useTranslation()
const { t, locale } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const [zones, setZones] = useState(() => {
const saved = localStorage.getItem('dashboard_timezones')
return saved ? JSON.parse(saved) : [
@@ -51,6 +53,9 @@ export default function TimezoneWidget() {
})
const [now, setNow] = useState(Date.now())
const [showAdd, setShowAdd] = useState(false)
const [customLabel, setCustomLabel] = useState('')
const [customTz, setCustomTz] = useState('')
const [customError, setCustomError] = useState('')
useEffect(() => {
const i = setInterval(() => setNow(Date.now()), 10000)
@@ -61,6 +66,20 @@ export default function TimezoneWidget() {
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
}, [zones])
const isValidTz = (tz: string) => {
try { Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date()); return true } catch { return false }
}
const addCustomZone = () => {
const tz = customTz.trim()
if (!tz) { setCustomError(t('dashboard.timezoneCustomErrorEmpty')); return }
if (!isValidTz(tz)) { setCustomError(t('dashboard.timezoneCustomErrorInvalid')); return }
if (zones.find(z => z.tz === tz)) { setCustomError(t('dashboard.timezoneCustomErrorDuplicate')); return }
const label = customLabel.trim() || tz.split('/').pop()?.replace(/_/g, ' ') || tz
setZones([...zones, { label, tz }])
setCustomLabel(''); setCustomTz(''); setCustomError(''); setShowAdd(false)
}
const addZone = (zone) => {
if (!zones.find(z => z.tz === zone.tz)) {
setZones([...zones, zone])
@@ -70,7 +89,7 @@ export default function TimezoneWidget() {
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
const localTime = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
// Show abbreviated timezone name (e.g. CET, CEST, EST)
@@ -96,7 +115,7 @@ export default function TimezoneWidget() {
{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-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale, is12h)}</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)' }}>
@@ -108,7 +127,29 @@ export default function TimezoneWidget() {
{/* Add zone dropdown */}
{showAdd && (
<div className="mt-2 rounded-xl p-2 max-h-[200px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
<div className="mt-2 rounded-xl p-2 max-h-[280px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
{/* Custom timezone */}
<div className="px-2 py-2 mb-2 rounded-lg" style={{ background: 'var(--bg-card)' }}>
<p className="text-[10px] font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezoneCustomTitle')}</p>
<div className="space-y-1.5">
<input value={customLabel} onChange={e => setCustomLabel(e.target.value)}
placeholder={t('dashboard.timezoneCustomLabelPlaceholder')}
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-secondary)' }} />
<input value={customTz} onChange={e => { setCustomTz(e.target.value); setCustomError('') }}
placeholder={t('dashboard.timezoneCustomTzPlaceholder')}
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: `1px solid ${customError ? '#ef4444' : 'var(--border-secondary)'}` }}
onKeyDown={e => { if (e.key === 'Enter') addCustomZone() }} />
{customError && <p className="text-[10px]" style={{ color: '#ef4444' }}>{customError}</p>}
<button onClick={addCustomZone}
className="w-full py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
{t('dashboard.timezoneCustomAdd')}
</button>
</div>
</div>
{/* Popular zones */}
{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"
@@ -116,7 +157,7 @@ export default function TimezoneWidget() {
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>
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale, is12h)}</span>
</button>
))}
</div>
@@ -1,194 +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","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 dm = useSettingsStore(s => s.settings.dark_mode)
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const [stats, setStats] = useState(null)
const [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>
)
}
-374
View File
@@ -1,374 +0,0 @@
import React, { useState, useCallback } from 'react'
import ReactDOM from 'react-dom'
import { useDropzone } from 'react-dropzone'
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote } from 'lucide-react'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
function isImage(mimeType) {
if (!mimeType) return false
return mimeType.startsWith('image/') // covers jpg, png, gif, webp, etc.
}
function getFileIcon(mimeType) {
if (!mimeType) return File
if (mimeType === 'application/pdf') return FileText
if (isImage(mimeType)) return FileImage
return File
}
function formatSize(bytes) {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function formatDateWithLocale(dateStr, locale) {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
} catch { return '' }
}
// Image lightbox
function ImageLightbox({ file, onClose }) {
const { t } = useTranslation()
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={onClose}
>
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
<img
src={file.url}
alt={file.original_name}
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
/>
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8 }}>
<a href={file.url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }} title={t('files.openTab')}>
<ExternalLink size={16} />
</a>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
<X size={18} />
</button>
</div>
</div>
</div>
</div>
)
}
// Source badge — unified style for both place and reservation
function SourceBadge({ icon: Icon, label }) {
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, maxWidth: '100%', overflow: 'hidden',
}}>
<Icon size={10} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</span>
)
}
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId, allowedFileTypes }) {
const [uploading, setUploading] = useState(false)
const [filterType, setFilterType] = useState('all')
const [lightboxFile, setLightboxFile] = useState(null)
const toast = useToast()
const { t, locale } = useTranslation()
const onDrop = useCallback(async (acceptedFiles) => {
if (acceptedFiles.length === 0) return
setUploading(true)
try {
for (const file of acceptedFiles) {
const formData = new FormData()
formData.append('file', file)
await onUpload(formData)
}
toast.success(t('files.uploaded', { count: acceptedFiles.length }))
} catch {
toast.error(t('files.uploadError'))
} finally {
setUploading(false)
}
}, [onUpload, toast, t])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxSize: 50 * 1024 * 1024,
noClick: false,
})
// Paste support
const handlePaste = useCallback((e) => {
const items = e.clipboardData?.items
if (!items) return
const files = []
for (const item of items) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) files.push(file)
}
}
if (files.length > 0) {
e.preventDefault()
onDrop(files)
}
}, [onDrop])
const filteredFiles = files.filter(f => {
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
})
const handleDelete = async (id) => {
if (!confirm(t('files.confirm.delete'))) return
try {
await onDelete(id)
toast.success(t('files.toast.deleted'))
} catch {
toast.error(t('files.toast.deleteError'))
}
}
const [previewFile, setPreviewFile] = useState(null)
const openFile = (file) => {
if (isImage(file.mime_type)) {
setLightboxFile(file)
} else {
setPreviewFile(file)
}
}
return (
<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 — portal to body to escape stacking context */}
{previewFile && ReactDOM.createPortal(
<div
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: '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 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noreferrer"
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
<ExternalLink size={13} /> {t('files.openTab')}
</a>
<button onClick={() => setPreviewFile(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<X size={18} />
</button>
</div>
</div>
<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>,
document.body
)}
{/* Header */}
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('files.title')}</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length })}
</p>
</div>
</div>
{/* Upload zone */}
<div
{...getRootProps()}
style={{
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
}}
>
<input {...getInputProps()} />
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} />
{uploading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
<div style={{ width: 14, height: 14, border: '2px solid var(--text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
{t('files.uploading')}
</div>
) : (
<>
<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>
{/* Filter tabs */}
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0 }}>
{[
{ id: 'all', label: t('files.filterAll') },
{ 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,
fontFamily: 'inherit', transition: 'all 0.12s',
background: filterType === tab.id ? 'var(--accent)' : 'transparent',
color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)',
fontWeight: filterType === tab.id ? 600 : 400,
}}>{tab.label}</button>
))}
<span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--text-faint)', alignSelf: 'center' }}>
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
</span>
</div>
{/* File list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
{filteredFiles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
<FileText size={40} 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('files.empty')}</p>
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredFiles.map(file => {
const FileIcon = getFileIcon(file.mime_type)
const linkedPlace = places?.find(p => p.id === file.place_id)
const linkedReservation = file.reservation_id
? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title })
: null
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
return (
<div key={file.id} style={{
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 10,
transition: 'border-color 0.12s',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
className="group"
>
{/* Icon or thumbnail */}
<div
onClick={() => openFile({ ...file, url: fileUrl })}
style={{
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', overflow: 'hidden',
}}
>
{isImage(file.mime_type)
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: (() => {
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>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
onClick={() => openFile({ ...file, url: fileUrl })}
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }}
>
{file.original_name}
</div>
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
{linkedPlace && (
<SourceBadge
icon={MapPin}
label={`${t('files.sourcePlan')} · ${linkedPlace.name}`}
/>
)}
{linkedReservation && (
<SourceBadge
icon={Ticket}
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 && (
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p>
)}
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 2, flexShrink: 0, opacity: 0, transition: 'opacity 0.12s' }} className="file-actions">
<button onClick={() => openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ExternalLink size={14} />
</button>
<button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
<Trash2 size={14} />
</button>
</div>
</div>
)
})}
</div>
)}
</div>
<style>{`
div:hover > .file-actions { opacity: 1 !important; }
`}</style>
</div>
)
}
+782
View File
@@ -0,0 +1,782 @@
import ReactDOM from 'react-dom'
import { useState, useCallback, useRef } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check } from 'lucide-react'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { filesApi } from '../../api/client'
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
function isImage(mimeType) {
if (!mimeType) return false
return mimeType.startsWith('image/')
}
function getFileIcon(mimeType) {
if (!mimeType) return File
if (mimeType === 'application/pdf') return FileText
if (isImage(mimeType)) return FileImage
return File
}
function formatSize(bytes) {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function formatDateWithLocale(dateStr, locale) {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
} catch { return '' }
}
// Image lightbox
interface ImageLightboxProps {
file: TripFile & { url: string }
onClose: () => void
}
function ImageLightbox({ file, onClose }: ImageLightboxProps) {
const { t } = useTranslation()
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={onClose}
>
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
<img
src={file.url}
alt={file.original_name}
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
/>
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8 }}>
<a href={file.url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }} title={t('files.openTab')}>
<ExternalLink size={16} />
</a>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
<X size={18} />
</button>
</div>
</div>
</div>
</div>
)
}
// Source badge
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, maxWidth: '100%', overflow: 'hidden',
}}>
<Icon size={10} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</span>
)
}
function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avatarUrl?: string | null; size?: number }) {
const [hover, setHover] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const ref = useRef<HTMLDivElement>(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,
cursor: 'default',
}}>
{avatarUrl
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: name?.[0]?.toUpperCase()
}
</div>
{hover && ReactDOM.createPortal(
<div style={{
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
background: 'var(--bg-elevated)', color: 'var(--text-primary)',
fontSize: 11, fontWeight: 500, padding: '3px 8px', borderRadius: 6,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', whiteSpace: 'nowrap', zIndex: 9999,
pointerEvents: 'none',
}}>
{name}
</div>,
document.body
)}
</>
)
}
interface FileManagerProps {
files?: TripFile[]
onUpload: (fd: FormData) => Promise<any>
onDelete: (fileId: number) => Promise<void>
onUpdate: (fileId: number, data: Partial<TripFile>) => Promise<void>
places: Place[]
days?: Day[]
assignments?: AssignmentsMap
reservations?: Reservation[]
tripId: number
allowedFileTypes: Record<string, string[]>
}
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
const [uploading, setUploading] = useState(false)
const [filterType, setFilterType] = useState('all')
const [lightboxFile, setLightboxFile] = useState(null)
const [showTrash, setShowTrash] = useState(false)
const [trashFiles, setTrashFiles] = useState<TripFile[]>([])
const [loadingTrash, setLoadingTrash] = useState(false)
const toast = useToast()
const { t, locale } = useTranslation()
const loadTrash = useCallback(async () => {
setLoadingTrash(true)
try {
const data = await filesApi.list(tripId, true)
setTrashFiles(data.files || [])
} catch { /* */ }
setLoadingTrash(false)
}, [tripId])
const toggleTrash = useCallback(() => {
if (!showTrash) loadTrash()
setShowTrash(v => !v)
}, [showTrash, loadTrash])
const refreshFiles = useCallback(async () => {
if (onUpdate) onUpdate(0, {} as any)
}, [onUpdate])
const handleStar = async (fileId: number) => {
try {
await filesApi.toggleStar(tripId, fileId)
refreshFiles()
} catch { /* */ }
}
const handleRestore = async (fileId: number) => {
try {
await filesApi.restore(tripId, fileId)
setTrashFiles(prev => prev.filter(f => f.id !== fileId))
refreshFiles()
toast.success(t('files.toast.restored'))
} catch {
toast.error(t('files.toast.restoreError'))
}
}
const handlePermanentDelete = async (fileId: number) => {
if (!confirm(t('files.confirm.permanentDelete'))) return
try {
await filesApi.permanentDelete(tripId, fileId)
setTrashFiles(prev => prev.filter(f => f.id !== fileId))
toast.success(t('files.toast.deleted'))
} catch {
toast.error(t('files.toast.deleteError'))
}
}
const handleEmptyTrash = async () => {
if (!confirm(t('files.confirm.emptyTrash'))) return
try {
await filesApi.emptyTrash(tripId)
setTrashFiles([])
toast.success(t('files.toast.trashEmptied') || 'Trash emptied')
} catch {
toast.error(t('files.toast.deleteError'))
}
}
const [lastUploadedIds, setLastUploadedIds] = useState<number[]>([])
const onDrop = useCallback(async (acceptedFiles) => {
if (acceptedFiles.length === 0) return
setUploading(true)
const uploadedIds: number[] = []
try {
for (const file of acceptedFiles) {
const formData = new FormData()
formData.append('file', file)
const result = await onUpload(formData)
const fileObj = result?.file || result
if (fileObj?.id) uploadedIds.push(fileObj.id)
}
toast.success(t('files.uploaded', { count: acceptedFiles.length }))
// Open assign modal for the last uploaded file
const lastId = uploadedIds[uploadedIds.length - 1]
if (lastId && (places.length > 0 || reservations.length > 0)) {
setAssignFileId(lastId)
}
} catch {
toast.error(t('files.uploadError'))
} finally {
setUploading(false)
}
}, [onUpload, toast, t, places, reservations])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxSize: 50 * 1024 * 1024,
noClick: false,
})
const handlePaste = useCallback((e) => {
const items = e.clipboardData?.items
if (!items) return
const pastedFiles = []
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) pastedFiles.push(file)
}
}
if (pastedFiles.length > 0) {
e.preventDefault()
onDrop(pastedFiles)
}
}, [onDrop])
const filteredFiles = files.filter(f => {
if (filterType === 'starred') return !!f.starred
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
})
const handleDelete = async (id) => {
try {
await onDelete(id)
toast.success(t('files.toast.trashed') || 'Moved to trash')
} catch {
toast.error(t('files.toast.deleteError'))
}
}
const [previewFile, setPreviewFile] = useState(null)
const [assignFileId, setAssignFileId] = useState<number | null>(null)
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
try {
await filesApi.update(tripId, fileId, data)
refreshFiles()
} catch {
toast.error(t('files.toast.assignError'))
}
}
const openFile = (file) => {
if (isImage(file.mime_type)) {
setLightboxFile(file)
} else {
setPreviewFile(file)
}
}
const renderFileRow = (file: TripFile, isTrash = false) => {
const FileIcon = getFileIcon(file.mime_type)
const allLinkedPlaceIds = new Set<number>()
if (file.place_id) allLinkedPlaceIds.add(file.place_id)
for (const pid of (file.linked_place_ids || [])) allLinkedPlaceIds.add(pid)
const linkedPlaces = [...allLinkedPlaceIds].map(pid => places?.find(p => p.id === pid)).filter(Boolean)
// All linked reservations (primary + file_links)
const allLinkedResIds = new Set<number>()
if (file.reservation_id) allLinkedResIds.add(file.reservation_id)
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid)
const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean)
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
return (
<div key={file.id} style={{
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 10,
transition: 'border-color 0.12s',
opacity: isTrash ? 0.7 : 1,
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
className="group"
>
{/* Icon or thumbnail */}
<div
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
style={{
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: isTrash ? 'default' : 'pointer', overflow: 'hidden',
}}
>
{isImage(file.mime_type)
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: (() => {
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>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
{file.uploaded_by_name && (
<AvatarChip name={file.uploaded_by_name} avatarUrl={file.uploaded_by_avatar} size={20} />
)}
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
<span
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }}
>
{file.original_name}
</span>
</div>
{file.description && (
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '2px 0 0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p>
)}
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
{linkedPlaces.map(p => (
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
))}
{linkedReservations.map(r => (
<SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
))}
{file.note_id && (
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
)}
</div>
</div>
{/* Actions — always visible on mobile, hover on desktop */}
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{isTrash ? (
<>
<button onClick={() => handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#22c55e'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<RotateCcw size={14} />
</button>
<button onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={14} />
</button>
</>
) : (
<>
<button onClick={() => handleStar(file.id)} title={file.starred ? t('files.unstar') || 'Unstar' : t('files.star') || 'Star'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: file.starred ? '#facc15' : 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => { if (!file.starred) e.currentTarget.style.color = '#facc15' }} onMouseLeave={e => { if (!file.starred) e.currentTarget.style.color = 'var(--text-faint)' }}>
<Star size={14} fill={file.starred ? '#facc15' : 'none'} />
</button>
<button onClick={() => setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={14} />
</button>
<button onClick={() => openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ExternalLink size={14} />
</button>
<button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={14} />
</button>
</>
)}
</div>
</div>
)
}
return (
<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)} />}
{/* Assign modal */}
{assignFileId && ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 5000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setAssignFileId(null)}>
<div style={{
background: 'var(--bg-card)', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
width: 'min(600px, calc(100vw - 32px))', maxHeight: '70vh', overflow: 'hidden', display: 'flex', flexDirection: 'column',
}} onClick={e => e.stopPropagation()}>
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('files.assignTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{files.find(f => f.id === assignFileId)?.original_name || ''}
</div>
</div>
<button onClick={() => setAssignFileId(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4, display: 'flex', flexShrink: 0 }}>
<X size={18} />
</button>
</div>
<div style={{ padding: '8px 12px 0' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '0 2px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{t('files.noteLabel') || 'Note'}
</div>
<input
type="text"
placeholder={t('files.notePlaceholder')}
defaultValue={files.find(f => f.id === assignFileId)?.description || ''}
onBlur={e => {
const val = e.target.value.trim()
const file = files.find(f => f.id === assignFileId)
if (file && val !== (file.description || '')) {
handleAssign(file.id, { description: val } as any)
}
}}
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
style={{
width: '100%', padding: '7px 10px', fontSize: 13, borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
}}
/>
</div>
<div style={{ overflowY: 'auto', padding: 8 }}>
{(() => {
const file = files.find(f => f.id === assignFileId)
if (!file) return null
const assignedPlaceIds = new Set<number>()
const dayGroups: { day: Day; dayPlaces: Place[] }[] = []
for (const day of days) {
const da = assignments[String(day.id)] || []
const dayPlaces = da.map(a => places.find(p => p.id === a.place?.id || p.id === a.place_id)).filter(Boolean) as Place[]
if (dayPlaces.length > 0) {
dayGroups.push({ day, dayPlaces })
dayPlaces.forEach(p => assignedPlaceIds.add(p.id))
}
}
const unassigned = places.filter(p => !assignedPlaceIds.has(p.id))
const placeBtn = (p: Place) => {
const isLinked = file.place_id === p.id || (file.linked_place_ids || []).includes(p.id)
return (
<button key={p.id} onClick={async () => {
if (isLinked) {
if (file.place_id === p.id) {
await handleAssign(file.id, { place_id: null })
} else {
try {
const linksRes = await filesApi.getLinks(tripId, file.id)
const link = (linksRes.links || []).find((l: any) => l.place_id === p.id)
if (link) await filesApi.removeLink(tripId, file.id, link.id)
refreshFiles()
} catch {}
}
} else {
if (!file.place_id) {
await handleAssign(file.id, { place_id: p.id })
} else {
try {
await filesApi.addLink(tripId, file.id, { place_id: p.id })
refreshFiles()
} catch {}
}
}
}} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
}
const placesSection = places.length > 0 && (
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{t('files.assignPlace')}
</div>
{dayGroups.map(({ day, dayPlaces }) => (
<div key={day.id}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
{day.title || `${t('dayplan.dayN', { n: day.day_number })}${day.date ? ` · ${day.date}` : ''}`}
</div>
{dayPlaces.map(placeBtn)}
</div>
))}
{unassigned.length > 0 && (
<div>
{dayGroups.length > 0 && <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>{t('files.unassigned')}</div>}
{unassigned.map(placeBtn)}
</div>
)}
</div>
)
const bookingsSection = reservations.length > 0 && (
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{t('files.assignBooking')}
</div>
{reservations.map(r => {
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
return (
<button key={r.id} onClick={async () => {
if (isLinked) {
// Unlink: if primary reservation_id, clear it; if via file_links, remove link
if (file.reservation_id === r.id) {
await handleAssign(file.id, { reservation_id: null })
} else {
try {
const linksRes = await filesApi.getLinks(tripId, file.id)
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
if (link) await filesApi.removeLink(tripId, file.id, link.id)
refreshFiles()
} catch {}
}
} else {
// Link: if no primary, set it; otherwise use file_links
if (!file.reservation_id) {
await handleAssign(file.id, { reservation_id: r.id })
} else {
try {
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
refreshFiles()
} catch {}
}
}
}} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
})}
</div>
)
const hasBoth = placesSection && bookingsSection
return (
<div className={hasBoth ? 'md:flex' : ''}>
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingRight: hasBoth ? 6 : 0 }}>{placesSection}</div>
{hasBoth && <div className="hidden md:block" style={{ width: 1, background: 'var(--border-primary)', flexShrink: 0 }} />}
{hasBoth && <div className="block md:hidden" style={{ height: 1, background: 'var(--border-primary)', margin: '8px 0' }} />}
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingLeft: hasBoth ? 6 : 0 }}>{bookingsSection}</div>
</div>
)
})()}
</div>
</div>
</div>,
document.body
)}
{/* PDF preview modal */}
{previewFile && ReactDOM.createPortal(
<div
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: '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 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noreferrer"
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
<ExternalLink size={13} /> {t('files.openTab')}
</a>
<button onClick={() => setPreviewFile(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<X size={18} />
</button>
</div>
</div>
<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>,
document.body
)}
{/* Header */}
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{showTrash
? `${trashFiles.length} ${trashFiles.length === 1 ? 'file' : 'files'}`
: (files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length }))}
</p>
</div>
<button onClick={toggleTrash} style={{
padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)',
background: showTrash ? 'var(--accent)' : 'var(--bg-card)',
color: showTrash ? 'var(--accent-text)' : 'var(--text-muted)',
fontSize: 12, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 5,
fontFamily: 'inherit',
}}>
<Trash2 size={13} /> {t('files.trash') || 'Trash'}
</button>
</div>
{showTrash ? (
/* Trash view */
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
{trashFiles.length > 0 && (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
<button onClick={handleEmptyTrash} style={{
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
background: '#fef2f2', color: '#dc2626', fontSize: 12, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}>
{t('files.emptyTrash') || 'Empty Trash'}
</button>
</div>
)}
{loadingTrash ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text-faint)' }}>
<div style={{ width: 20, height: 20, border: '2px solid var(--text-faint)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto' }} />
</div>
) : trashFiles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
<Trash2 size={40} 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('files.trashEmpty') || 'Trash is empty'}</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{trashFiles.map(file => renderFileRow(file, true))}
</div>
)}
</div>
) : (
<>
{/* Upload zone */}
<div
{...getRootProps()}
style={{
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
}}
>
<input {...getInputProps()} />
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} />
{uploading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
<div style={{ width: 14, height: 14, border: '2px solid var(--text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
{t('files.uploading')}
</div>
) : (
<>
<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>
{/* Filter tabs */}
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
{[
{ id: 'all', label: t('files.filterAll') },
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []),
{ 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,
fontFamily: 'inherit', transition: 'all 0.12s',
background: filterType === tab.id ? 'var(--accent)' : 'transparent',
color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)',
fontWeight: filterType === tab.id ? 600 : 400,
}}>{tab.icon ? <tab.icon size={13} fill={filterType === tab.id ? '#facc15' : 'none'} color={filterType === tab.id ? '#facc15' : 'currentColor'} /> : tab.label}</button>
))}
<span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--text-faint)', alignSelf: 'center' }}>
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
</span>
</div>
{/* File list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
{filteredFiles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
<FileText size={40} 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('files.empty')}</p>
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredFiles.map(file => renderFileRow(file))}
</div>
)}
</div>
</>
)}
<style>{`
@media (max-width: 767px) {
.file-actions button { padding: 8px !important; }
.file-actions svg { width: 18px !important; height: 18px !important; }
}
`}</style>
</div>
)
}
@@ -2,11 +2,30 @@ 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'
const texts = {
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',
title: 'Willkommen zur TREK Demo',
description: 'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
resetIn: 'Naechster Reset in',
minutes: 'Minuten',
@@ -29,7 +48,7 @@ const texts = {
['Dokumente', 'Dateien an Reisen anhaengen'],
['Widgets', 'Waehrungsrechner & Zeitzonen'],
],
whatIs: 'Was ist NOMAD?',
whatIs: 'Was ist TREK?',
whatIsDesc: 'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
selfHost: 'Open Source — ',
selfHostLink: 'selbst hosten',
@@ -38,7 +57,7 @@ const texts = {
en: {
titleBefore: 'Welcome to ',
titleAfter: '',
title: 'Welcome to the NOMAD Demo',
title: 'Welcome to the TREK Demo',
description: 'You can view, edit and create trips. All changes are automatically reset every hour.',
resetIn: 'Next reset in',
minutes: 'minutes',
@@ -61,20 +80,84 @@ const texts = {
['Documents', 'Attach files to trips'],
['Widgets', 'Currency converter & timezones'],
],
whatIs: 'What is NOMAD?',
whatIs: 'What is TREK?',
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',
},
es: {
titleBefore: 'Bienvenido a ',
titleAfter: '',
title: 'Bienvenido a la demo de TREK',
description: 'Puedes ver, editar y crear viajes. Todos los cambios se restablecen automáticamente cada hora.',
resetIn: 'Próximo reinicio en',
minutes: 'minutos',
uploadNote: 'Las subidas de archivos (fotos, documentos, portadas) están desactivadas en el modo demo.',
fullVersionTitle: 'Además, en la versión completa:',
features: [
'Subida de archivos (fotos, documentos, portadas)',
'Gestión de claves API (Google Maps, tiempo)',
'Gestión de usuarios y permisos',
'Copias de seguridad automáticas',
'Gestión de addons (activar/desactivar)',
'Inicio de sesión único OIDC / SSO',
],
addonsTitle: 'Complementos modulares (se pueden desactivar en la versión completa)',
addons: [
['Vacaciones', 'Planificador de vacaciones con calendario, festivos y fusión de usuarios'],
['Atlas', 'Mapa del mundo con países visitados y estadísticas de viaje'],
['Equipaje', 'Listas de comprobación para cada viaje'],
['Presupuesto', 'Control de gastos con reparto'],
['Documentos', 'Adjunta archivos a los viajes'],
['Widgets', 'Conversor de divisas y zonas horarias'],
],
whatIs: '¿Qué es TREK?',
whatIsDesc: 'Un planificador de viajes autohospedado con colaboración en tiempo real, mapas interactivos, inicio de sesión OIDC y modo oscuro.',
selfHost: 'Código abierto — ',
selfHostLink: 'alójalo tú mismo',
close: 'Entendido',
},
ar: {
titleBefore: 'مرحبًا بك في ',
titleAfter: '',
title: 'مرحبًا بك في النسخة التجريبية من TREK',
description: 'يمكنك عرض الرحلات وتعديلها وإنشاء رحلات جديدة. تتم إعادة ضبط جميع التغييرات تلقائيًا كل ساعة.',
resetIn: 'إعادة الضبط التالية خلال',
minutes: 'دقيقة',
uploadNote: 'رفع الملفات (الصور والمستندات وصور الغلاف) معطّل في وضع العرض التجريبي.',
fullVersionTitle: 'وفي النسخة الكاملة أيضًا:',
features: [
'رفع الملفات (الصور والمستندات وصور الغلاف)',
'إدارة مفاتيح API (خرائط Google والطقس)',
'إدارة المستخدمين والصلاحيات',
'نسخ احتياطية تلقائية',
'إدارة الإضافات (تفعيل/تعطيل)',
'تسجيل دخول موحد OIDC / SSO',
],
addonsTitle: 'إضافات مرنة (يمكن تعطيلها في النسخة الكاملة)',
addons: [
['Vacay', 'مخطط إجازات مع تقويم وعطل ودمج مستخدمين'],
['Atlas', 'خريطة عالمية مع الدول التي تمت زيارتها وإحصاءات السفر'],
['Packing', 'قوائم تجهيز لكل رحلة'],
['Budget', 'تتبع المصروفات مع التقسيم'],
['Documents', 'إرفاق الملفات بالرحلات'],
['Widgets', 'محول عملات ومناطق زمنية'],
],
whatIs: 'ما هو TREK؟',
whatIsDesc: 'مخطط رحلات مستضاف ذاتيًا مع تعاون لحظي وخرائط تفاعلية وتسجيل دخول OIDC ووضع داكن.',
selfHost: 'مفتوح المصدر — ',
selfHostLink: 'استضفه بنفسك',
close: 'فهمت',
},
}
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
const addonIcons = [CalendarDays, Globe, ListChecks, Wallet, FileText, ArrowRightLeft]
export default function DemoBanner() {
const [dismissed, setDismissed] = useState(false)
const [minutesLeft, setMinutesLeft] = useState(59 - new Date().getMinutes())
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
@@ -98,13 +181,13 @@ export default function DemoBanner() {
maxWidth: 480, width: '100%',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
maxHeight: '90vh', overflow: 'auto',
}} onClick={e => e.stopPropagation()}>
}} 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}
{t.titleBefore}<img src="/text-dark.svg" alt="TREK" style={{ height: 18 }} />{t.titleAfter}
</h2>
</div>
@@ -132,7 +215,7 @@ export default function DemoBanner() {
</div>
</div>
{/* What is NOMAD */}
{/* What is TREK */}
<div style={{
background: '#f8fafc', borderRadius: 12, padding: '12px 14px', marginBottom: 16,
border: '1px solid #e2e8f0',
@@ -140,7 +223,7 @@ export default function DemoBanner() {
<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 }} />?
{t.whatIs}
</span>
</div>
<p style={{ fontSize: 12, color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
@@ -194,7 +277,7 @@ export default function DemoBanner() {
<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"
<a href="https://github.com/mauriceboe/TREK" target="_blank" rel="noopener noreferrer"
style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}>
{t.selfHostLink}
</a>
@@ -6,18 +6,34 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
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'
const ADDON_ICONS = { CalendarDays, Briefcase, Globe }
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe }
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }) {
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 location = useLocation()
const [userMenuOpen, setUserMenuOpen] = useState(false)
const [appVersion, setAppVersion] = useState(null)
const [globalAddons, setGlobalAddons] = useState([])
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)
@@ -51,6 +67,12 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
}
const getAddonName = (addon: Addon): string => {
const key = `admin.addons.catalog.${addon.id}.name`
const translated = t(key)
return translated !== key ? translated : addon.name
}
return (
<nav style={{
background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
@@ -75,8 +97,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
)}
<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 }} />
<img src={dark ? '/icons/icon-white.svg' : '/icons/icon-dark.svg'} alt="TREK" className="sm:hidden" style={{ height: 22, width: 22 }} />
<img src={dark ? '/logo-light.svg' : '/logo-dark.svg'} alt="TREK" className="hidden sm:block" style={{ height: 28 }} />
</Link>
{/* Global addon nav items */}
@@ -108,7 +130,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
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>
<span className="hidden md:inline">{getAddonName(addon)}</span>
</Link>
)
})}
@@ -215,7 +237,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
{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 }} />
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
</div>
</div>
@@ -1,12 +1,13 @@
import React, { useEffect, useRef, useState, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
import { useEffect, useRef, useState, useMemo, useCallback } from 'react'
import DOM from 'react-dom'
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import L from 'leaflet'
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
@@ -64,7 +65,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
cursor:pointer;flex-shrink:0;position:relative;
">
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
<img src="${escAttr(place.image_url)}" style="width:100%;height:100%;object-fit:cover;" />
<img src="${escAttr(place.image_url)}" loading="lazy" style="width:100%;height:100%;object-fit:cover;" />
</div>
${badgeHtml}
</div>`,
@@ -93,31 +94,37 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
})
}
function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }) {
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) {
// 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 {}
// Pan to the selected place without changing zoom
const selected = places.find(p => p.id === selectedPlaceId)
if (selected?.lat && selected?.lng) {
map.panTo([selected.lat, selected.lng], { animate: true })
}
}
prev.current = selectedPlaceId
}, [selectedPlaceId, places, dayPlaces, paddingOpts, map])
}, [selectedPlaceId, places, 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)
@@ -132,7 +139,13 @@ function MapController({ center, zoom }) {
}
// Fit bounds when places change (fitKey triggers re-fit)
function BoundsController({ places, fitKey, paddingOpts }) {
interface BoundsControllerProps {
places: Place[]
fitKey: number
paddingOpts: Record<string, number>
}
function BoundsController({ places, fitKey, paddingOpts }: BoundsControllerProps) {
const map = useMap()
const prevFitKey = useRef(-1)
@@ -149,7 +162,11 @@ function BoundsController({ places, fitKey, paddingOpts }) {
return null
}
function MapClickHandler({ onClick }) {
interface MapClickHandlerProps {
onClick: ((e: L.LeafletMouseEvent) => void) | null
}
function MapClickHandler({ onClick }: MapClickHandlerProps) {
const map = useMap()
useEffect(() => {
if (!onClick) return
@@ -159,14 +176,30 @@ function MapClickHandler({ onClick }) {
return null
}
// ── Route travel time label ──
function RouteLabel({ midpoint, walkingText, drivingText }) {
function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) {
const map = useMap()
const [visible, setVisible] = useState(map ? map.getZoom() >= 16 : false)
useEffect(() => {
if (!onContextMenu) return
map.on('contextmenu', onContextMenu)
return () => map.off('contextmenu', onContextMenu)
}, [map, onContextMenu])
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() >= 16)
const check = () => setVisible(map.getZoom() >= 12)
check()
map.on('zoomend', check)
return () => map.off('zoomend', check)
@@ -205,6 +238,97 @@ function RouteLabel({ midpoint, walkingText, drivingText }) {
// Module-level photo cache shared with PlaceAvatar
const mapPhotoCache = new Map()
const mapPhotoInFlight = new Set()
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
function LocationTracker() {
const map = useMap()
const [position, setPosition] = useState<[number, number] | null>(null)
const [accuracy, setAccuracy] = useState(0)
const [tracking, setTracking] = useState(false)
const watchId = useRef<number | null>(null)
const startTracking = useCallback(() => {
if (!('geolocation' in navigator)) return
setTracking(true)
watchId.current = navigator.geolocation.watchPosition(
(pos) => {
const latlng: [number, number] = [pos.coords.latitude, pos.coords.longitude]
setPosition(latlng)
setAccuracy(pos.coords.accuracy)
},
() => setTracking(false),
{ enableHighAccuracy: true, maximumAge: 5000 }
)
}, [])
const stopTracking = useCallback(() => {
if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current)
watchId.current = null
setTracking(false)
setPosition(null)
}, [])
const toggleTracking = useCallback(() => {
if (tracking) { stopTracking() } else { startTracking() }
}, [tracking, startTracking, stopTracking])
// Center map on position when first acquired
const centered = useRef(false)
useEffect(() => {
if (position && !centered.current) {
map.setView(position, 15)
centered.current = true
}
}, [position, map])
// Cleanup on unmount
useEffect(() => () => { if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) }, [])
return (
<>
{/* Location button */}
<div style={{
position: 'absolute', bottom: 20, right: 10, zIndex: 1000,
}}>
<button onClick={toggleTracking} style={{
width: 36, height: 36, borderRadius: '50%',
border: 'none', cursor: 'pointer',
background: tracking ? '#3b82f6' : 'var(--bg-card, white)',
color: tracking ? 'white' : 'var(--text-muted, #6b7280)',
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background 0.2s, color 0.2s',
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
</svg>
</button>
</div>
{/* Blue dot + accuracy circle */}
{position && (
<>
{accuracy < 500 && (
<Circle center={position} radius={accuracy} pathOptions={{ color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.06, weight: 0.5, opacity: 0.3 }} />
)}
<CircleMarker center={position} radius={7} pathOptions={{ color: 'white', fillColor: '#3b82f6', fillOpacity: 1, weight: 2.5 }} />
</>
)}
{/* Pulse animation CSS */}
{position && (
<style>{`
@keyframes location-pulse {
0% { transform: scale(1); opacity: 0.6; }
100% { transform: scale(2.5); opacity: 0; }
}
`}</style>
)}
</>
)
}
export function MapView({
places = [],
@@ -214,6 +338,7 @@ export function MapView({
selectedPlaceId = null,
onMarkerClick,
onMapClick,
onMapContextMenu = null,
center = [48.8566, 2.3522],
zoom = 10,
tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
@@ -235,24 +360,48 @@ export function MapView({
}, [leftWidth, rightWidth, hasInspector])
const [photoUrls, setPhotoUrls] = useState({})
// Fetch Google photos for places that have google_place_id but no image_url
// Fetch photos for places with concurrency limit to avoid blocking map rendering
useEffect(() => {
places.forEach(place => {
if (place.image_url || !place.google_place_id) return
if (mapPhotoCache.has(place.google_place_id)) {
const cached = mapPhotoCache.get(place.google_place_id)
if (cached) setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: cached }))
return
const queue = places.filter(place => {
if (place.image_url) return false
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
if (!cacheKey) return false
if (mapPhotoCache.has(cacheKey)) {
const cached = mapPhotoCache.get(cacheKey)
if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached }))
return false
}
mapsApi.placePhoto(place.google_place_id)
.then(data => {
if (data.photoUrl) {
mapPhotoCache.set(place.google_place_id, data.photoUrl)
setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: data.photoUrl }))
}
})
.catch(() => { mapPhotoCache.set(place.google_place_id, null) })
if (mapPhotoInFlight.has(cacheKey)) return false
const photoId = place.google_place_id || place.osm_id
if (!photoId && !(place.lat && place.lng)) return false
return true
})
let active = 0
const MAX_CONCURRENT = 3
let idx = 0
const fetchNext = () => {
while (active < MAX_CONCURRENT && idx < queue.length) {
const place = queue[idx++]
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
const photoId = place.google_place_id || place.osm_id
mapPhotoInFlight.add(cacheKey)
active++
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
.then(data => {
if (data.photoUrl) {
mapPhotoCache.set(cacheKey, data.photoUrl)
setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl }))
} else {
mapPhotoCache.set(cacheKey, null)
}
})
.catch(() => { mapPhotoCache.set(cacheKey, null) })
.finally(() => { mapPhotoInFlight.delete(cacheKey); active--; fetchNext() })
}
}
fetchNext()
}, [places])
return (
@@ -273,6 +422,8 @@ export function MapView({
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<LocationTracker />
<MarkerClusterGroup
chunkedLoading
@@ -297,7 +448,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 cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
const resolvedPhotoUrl = place.image_url || (cacheKey && photoUrls[cacheKey]) || null
const orderNumbers = dayOrderMap[place.id] ?? null
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected)
@@ -1,117 +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('At least 2 waypoints required')
}
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 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 = 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
}
const walkingDuration = distance / (5000 / 3600) // 5 km/h
const drivingDuration = route.duration // OSRM driving value
return {
coordinates,
distance,
duration,
distanceText: formatDistance(distance),
durationText: formatDuration(duration),
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(drivingDuration),
}
}
/**
* 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(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
}
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} h ${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`
}
@@ -0,0 +1,692 @@
import { useState, useEffect, useCallback } from 'react'
import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter } from 'lucide-react'
import apiClient from '../../api/client'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
// ── Types ───────────────────────────────────────────────────────────────────
interface TripPhoto {
immich_asset_id: string
user_id: number
username: string
shared: number
added_at: string
}
interface ImmichAsset {
id: string
takenAt: string
city: string | null
country: string | null
}
interface MemoriesPanelProps {
tripId: number
startDate: string | null
endDate: string | null
}
// ── Main Component ──────────────────────────────────────────────────────────
export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPanelProps) {
const { t } = useTranslation()
const currentUser = useAuthStore(s => s.user)
const [connected, setConnected] = useState(false)
const [loading, setLoading] = useState(true)
// Trip photos (saved selections)
const [tripPhotos, setTripPhotos] = useState<TripPhoto[]>([])
// Photo picker
const [showPicker, setShowPicker] = useState(false)
const [pickerPhotos, setPickerPhotos] = useState<ImmichAsset[]>([])
const [pickerLoading, setPickerLoading] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
// Confirm share popup
const [showConfirmShare, setShowConfirmShare] = useState(false)
// Filters & sort
const [sortAsc, setSortAsc] = useState(true)
const [locationFilter, setLocationFilter] = useState('')
// Lightbox
const [lightboxId, setLightboxId] = useState<string | null>(null)
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
const [lightboxInfo, setLightboxInfo] = useState<any>(null)
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
// ── Init ──────────────────────────────────────────────────────────────────
useEffect(() => {
loadInitial()
}, [tripId])
// WebSocket: reload photos when another user adds/removes/shares
useEffect(() => {
const handler = () => loadPhotos()
window.addEventListener('memories:updated', handler)
return () => window.removeEventListener('memories:updated', handler)
}, [tripId])
const loadPhotos = async () => {
try {
const photosRes = await apiClient.get(`/integrations/immich/trips/${tripId}/photos`)
setTripPhotos(photosRes.data.photos || [])
} catch {
setTripPhotos([])
}
}
const loadInitial = async () => {
setLoading(true)
try {
const statusRes = await apiClient.get('/integrations/immich/status')
setConnected(statusRes.data.connected)
} catch {
setConnected(false)
}
await loadPhotos()
setLoading(false)
}
// ── Photo Picker ──────────────────────────────────────────────────────────
const [pickerDateFilter, setPickerDateFilter] = useState(true)
const openPicker = async () => {
setShowPicker(true)
setPickerLoading(true)
setSelectedIds(new Set())
setPickerDateFilter(!!(startDate && endDate))
await loadPickerPhotos(!!(startDate && endDate))
}
const loadPickerPhotos = async (useDate: boolean) => {
setPickerLoading(true)
try {
const res = await apiClient.post('/integrations/immich/search', {
from: useDate && startDate ? startDate : undefined,
to: useDate && endDate ? endDate : undefined,
})
setPickerPhotos(res.data.assets || [])
} catch {
setPickerPhotos([])
} finally {
setPickerLoading(false)
}
}
const togglePickerSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const confirmSelection = () => {
if (selectedIds.size === 0) return
setShowConfirmShare(true)
}
const executeAddPhotos = async () => {
setShowConfirmShare(false)
try {
await apiClient.post(`/integrations/immich/trips/${tripId}/photos`, {
asset_ids: [...selectedIds],
shared: true,
})
setShowPicker(false)
loadInitial()
} catch {}
}
// ── Remove photo ──────────────────────────────────────────────────────────
const removePhoto = async (assetId: string) => {
try {
await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`)
setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId))
} catch {}
}
// ── Toggle sharing ────────────────────────────────────────────────────────
const toggleSharing = async (assetId: string, shared: boolean) => {
try {
await apiClient.put(`/integrations/immich/trips/${tripId}/photos/${assetId}/sharing`, { shared })
setTripPhotos(prev => prev.map(p =>
p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p
))
} catch {}
}
// ── Helpers ───────────────────────────────────────────────────────────────
const token = useAuthStore(s => s.token)
const thumbnailUrl = (assetId: string, userId: number) =>
`/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}&token=${token}`
const originalUrl = (assetId: string, userId: number) =>
`/api/integrations/immich/assets/${assetId}/original?userId=${userId}&token=${token}`
const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared)
const allVisibleRaw = [...ownPhotos, ...othersPhotos]
// Unique locations for filter
const locations = [...new Set(allVisibleRaw.map(p => p.city).filter(Boolean) as string[])].sort()
// Apply filter + sort
const allVisible = allVisibleRaw
.filter(p => !locationFilter || p.city === locationFilter)
.sort((a, b) => {
const da = new Date(a.added_at || 0).getTime()
const db = new Date(b.added_at || 0).getTime()
return sortAsc ? da - db : db - da
})
const font: React.CSSProperties = {
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}
// ── Loading ───────────────────────────────────────────────────────────────
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', ...font }}>
<div className="w-8 h-8 border-2 rounded-full animate-spin"
style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
</div>
)
}
// ── Not connected ─────────────────────────────────────────────────────────
if (!connected && allVisible.length === 0) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: 40, textAlign: 'center', ...font }}>
<Camera size={40} style={{ color: 'var(--text-faint)', marginBottom: 12 }} />
<h3 style={{ margin: '0 0 6px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
{t('memories.notConnected')}
</h3>
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)', maxWidth: 300 }}>
{t('memories.notConnectedHint')}
</p>
</div>
)
}
// ── Photo Picker Modal ────────────────────────────────────────────────────
if (showPicker) {
const alreadyAdded = new Set(tripPhotos.filter(p => p.user_id === currentUser?.id).map(p => p.immich_asset_id))
return (
<>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
{/* Picker header */}
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border-secondary)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
{t('memories.selectPhotos')}
</h3>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => setShowPicker(false)}
style={{ padding: '7px 14px', 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 onClick={confirmSelection} disabled={selectedIds.size === 0}
style={{
padding: '7px 14px', borderRadius: 10, border: 'none', fontSize: 12, fontWeight: 600,
cursor: selectedIds.size > 0 ? 'pointer' : 'default', fontFamily: 'inherit',
background: selectedIds.size > 0 ? 'var(--text-primary)' : 'var(--border-primary)',
color: selectedIds.size > 0 ? 'var(--bg-primary)' : 'var(--text-faint)',
}}>
{selectedIds.size > 0 ? t('memories.addSelected', { count: selectedIds.size }) : t('memories.addPhotos')}
</button>
</div>
</div>
{/* Filter tabs */}
<div style={{ display: 'flex', gap: 6 }}>
{startDate && endDate && (
<button onClick={() => { if (!pickerDateFilter) { setPickerDateFilter(true); loadPickerPhotos(true) } }}
style={{
padding: '6px 14px', borderRadius: 99, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
border: '1px solid', transition: 'all 0.15s',
background: pickerDateFilter ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
color: pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
{t('memories.tripDates')} ({startDate ? new Date(startDate + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) : ''} {endDate ? new Date(endDate + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : ''})
</button>
)}
<button onClick={() => { if (pickerDateFilter || !startDate) { setPickerDateFilter(false); loadPickerPhotos(false) } }}
style={{
padding: '6px 14px', borderRadius: 99, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
border: '1px solid', transition: 'all 0.15s',
background: !pickerDateFilter ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: !pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
color: !pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
{t('memories.allPhotos')}
</button>
</div>
{selectedIds.size > 0 && (
<p style={{ margin: '8px 0 0', fontSize: 12, fontWeight: 600, color: 'var(--text-primary)' }}>
{selectedIds.size} {t('memories.selected')}
</p>
)}
</div>
{/* Picker grid */}
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
{pickerLoading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 60 }}>
<div className="w-7 h-7 border-2 rounded-full animate-spin"
style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
</div>
) : pickerPhotos.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<Camera size={36} style={{ color: 'var(--text-faint)', margin: '0 auto 10px', display: 'block' }} />
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>{t('memories.noPhotos')}</p>
</div>
) : (() => {
// Group photos by month
const byMonth: Record<string, ImmichAsset[]> = {}
for (const asset of pickerPhotos) {
const d = asset.takenAt ? new Date(asset.takenAt) : null
const key = d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` : 'unknown'
if (!byMonth[key]) byMonth[key] = []
byMonth[key].push(asset)
}
const sortedMonths = Object.keys(byMonth).sort().reverse()
return sortedMonths.map(month => (
<div key={month} style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-muted)', marginBottom: 6, paddingLeft: 2 }}>
{month !== 'unknown'
? new Date(month + '-15').toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
: '—'}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', gap: 4 }}>
{byMonth[month].map(asset => {
const isSelected = selectedIds.has(asset.id)
const isAlready = alreadyAdded.has(asset.id)
return (
<div key={asset.id}
onClick={() => !isAlready && togglePickerSelect(asset.id)}
style={{
position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden',
cursor: isAlready ? 'default' : 'pointer',
opacity: isAlready ? 0.3 : 1,
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
outlineOffset: -3,
}}>
<img src={thumbnailUrl(asset.id, currentUser!.id)} alt="" loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
{isSelected && (
<div style={{
position: 'absolute', top: 4, right: 4, width: 22, height: 22, borderRadius: '50%',
background: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Check size={13} color="var(--bg-primary)" />
</div>
)}
{isAlready && (
<div style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.3)', fontSize: 10, color: 'white', fontWeight: 600,
}}>
{t('memories.alreadyAdded')}
</div>
)}
</div>
)
})}
</div>
</div>
))
})()}
</div>
</div>
{/* Confirm share popup (inside picker) */}
{showConfirmShare && (
<div onClick={() => setShowConfirmShare(false)}
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
<div onClick={e => e.stopPropagation()}
style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}>
<Share2 size={28} style={{ color: 'var(--text-primary)', marginBottom: 12 }} />
<h3 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
{t('memories.confirmShareTitle')}
</h3>
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.5 }}>
{t('memories.confirmShareHint', { count: selectedIds.size })}
</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button onClick={() => setShowConfirmShare(false)}
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button onClick={executeAddPhotos}
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
{t('memories.confirmShareButton')}
</button>
</div>
</div>
</div>
)}
</>
)
}
// ── Main Gallery ──────────────────────────────────────────────────────────
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
{/* Header */}
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>
{t('memories.title')}
</h2>
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'var(--text-faint)' }}>
{allVisible.length} {t('memories.photosFound')}
{othersPhotos.length > 0 && ` · ${othersPhotos.length} ${t('memories.fromOthers')}`}
</p>
</div>
{connected && (
<button onClick={openPicker}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 10,
border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)',
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}>
<Plus size={14} /> {t('memories.addPhotos')}
</button>
)}
</div>
</div>
{/* Filter & Sort bar */}
{allVisibleRaw.length > 0 && (
<div style={{ display: 'flex', gap: 6, padding: '8px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0, flexWrap: 'wrap' }}>
<button onClick={() => setSortAsc(v => !v)}
style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px', borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'var(--bg-card)',
fontSize: 11, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)',
}}>
<ArrowUpDown size={11} /> {sortAsc ? t('memories.oldest') : t('memories.newest')}
</button>
{locations.length > 1 && (
<select value={locationFilter} onChange={e => setLocationFilter(e.target.value)}
style={{
padding: '4px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
background: 'var(--bg-card)', fontSize: 11, fontFamily: 'inherit', color: 'var(--text-muted)',
cursor: 'pointer', outline: 'none',
}}>
<option value="">{t('memories.allLocations')}</option>
{locations.map(loc => <option key={loc} value={loc}>{loc}</option>)}
</select>
)}
</div>
)}
{/* Gallery */}
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
{allVisible.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<Camera size={40} style={{ color: 'var(--text-faint)', margin: '0 auto 12px', display: 'block' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>
{t('memories.noPhotos')}
</p>
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: '0 0 16px' }}>
{t('memories.noPhotosHint')}
</p>
<button onClick={openPicker}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '9px 18px', borderRadius: 10,
border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)',
fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}>
<Plus size={15} /> {t('memories.addPhotos')}
</button>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: 6 }}>
{allVisible.map(photo => {
const isOwn = photo.user_id === currentUser?.id
return (
<div key={photo.immich_asset_id} className="group"
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
onClick={() => {
setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
setLightboxInfoLoading(true)
apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`)
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
}}>
<img src={thumbnailUrl(photo.immich_asset_id, photo.user_id)} alt="" loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10 }} />
{/* Other user's avatar */}
{!isOwn && (
<div className="memories-avatar" style={{ position: 'absolute', bottom: 6, left: 6, zIndex: 10 }}>
<div style={{
width: 22, height: 22, borderRadius: '50%',
background: `hsl(${photo.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
border: '2px solid white', boxShadow: '0 1px 4px rgba(0,0,0,0.3)',
}}>
{photo.username[0]}
</div>
<div className="memories-avatar-tooltip" style={{
position: 'absolute', bottom: '100%', left: '50%', transform: 'translateX(-50%)',
marginBottom: 6, padding: '3px 8px', borderRadius: 6,
background: 'var(--text-primary)', color: 'var(--bg-primary)',
fontSize: 10, fontWeight: 600, whiteSpace: 'nowrap',
pointerEvents: 'none', opacity: 0, transition: 'opacity 0.15s',
}}>
{photo.username}
</div>
</div>
)}
{/* Own photo actions (hover) */}
{isOwn && (
<div className="opacity-0 group-hover:opacity-100"
style={{ position: 'absolute', top: 4, right: 4, display: 'flex', gap: 3, transition: 'opacity 0.15s' }}>
<button onClick={e => { e.stopPropagation(); toggleSharing(photo.immich_asset_id, !photo.shared) }}
title={photo.shared ? t('memories.stopSharing') : t('memories.sharePhotos')}
style={{
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{photo.shared ? <Eye size={12} color="white" /> : <EyeOff size={12} color="white" />}
</button>
<button onClick={e => { e.stopPropagation(); removePhoto(photo.immich_asset_id) }}
style={{
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<X size={12} color="white" />
</button>
</div>
)}
{/* Not shared indicator */}
{isOwn && !photo.shared && (
<div style={{
position: 'absolute', bottom: 6, right: 6, padding: '2px 6px', borderRadius: 6,
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
fontSize: 9, color: 'rgba(255,255,255,0.7)', fontWeight: 500,
}}>
<EyeOff size={9} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
{t('memories.private')}
</div>
)}
</div>
)
})}
</div>
)}
</div>
<style>{`
.memories-avatar:hover .memories-avatar-tooltip { opacity: 1 !important; }
`}</style>
{/* Confirm share popup */}
{showConfirmShare && (
<div onClick={() => setShowConfirmShare(false)}
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
<div onClick={e => e.stopPropagation()}
style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}>
<Share2 size={28} style={{ color: 'var(--text-primary)', marginBottom: 12 }} />
<h3 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
{t('memories.confirmShareTitle')}
</h3>
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.5 }}>
{t('memories.confirmShareHint', { count: selectedIds.size })}
</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button onClick={() => setShowConfirmShare(false)}
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button onClick={executeAddPhotos}
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
{t('memories.confirmShareButton')}
</button>
</div>
</div>
</div>
)}
{/* Lightbox */}
{lightboxId && lightboxUserId && (
<div onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
style={{
position: 'absolute', inset: 0, zIndex: 100,
background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<button onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
style={{
position: 'absolute', top: 16, right: 16, width: 40, height: 40, borderRadius: '50%',
background: 'rgba(255,255,255,0.1)', border: 'none', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<X size={20} color="white" />
</button>
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}>
<img
src={originalUrl(lightboxId, lightboxUserId)}
alt=""
style={{ maxWidth: lightboxInfo ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }}
/>
{/* Info panel — liquid glass */}
{lightboxInfo && (
<div style={{
width: 240, flexShrink: 0, borderRadius: 16, padding: 18,
background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
border: '1px solid rgba(255,255,255,0.12)', color: 'white',
display: 'flex', flexDirection: 'column', gap: 14, maxHeight: '100%', overflowY: 'auto',
}}>
{/* Date */}
{lightboxInfo.takenAt && (
<div>
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>Date</div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{new Date(lightboxInfo.takenAt).toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })}</div>
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)' }}>{new Date(lightboxInfo.takenAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</div>
</div>
)}
{/* Location */}
{(lightboxInfo.city || lightboxInfo.country) && (
<div>
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>
<MapPin size={9} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />Location
</div>
<div style={{ fontSize: 13, fontWeight: 600 }}>
{[lightboxInfo.city, lightboxInfo.state, lightboxInfo.country].filter(Boolean).join(', ')}
</div>
</div>
)}
{/* Camera */}
{lightboxInfo.camera && (
<div>
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>Camera</div>
<div style={{ fontSize: 12, fontWeight: 500 }}>{lightboxInfo.camera}</div>
{lightboxInfo.lens && <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', marginTop: 2 }}>{lightboxInfo.lens}</div>}
</div>
)}
{/* Settings */}
{(lightboxInfo.focalLength || lightboxInfo.aperture || lightboxInfo.iso) && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{lightboxInfo.focalLength && (
<div>
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Focal</div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.focalLength}</div>
</div>
)}
{lightboxInfo.aperture && (
<div>
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Aperture</div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.aperture}</div>
</div>
)}
{lightboxInfo.shutter && (
<div>
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Shutter</div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.shutter}</div>
</div>
)}
{lightboxInfo.iso && (
<div>
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>ISO</div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.iso}</div>
</div>
)}
</div>
)}
{/* Resolution & File */}
{(lightboxInfo.width || lightboxInfo.fileName) && (
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 10 }}>
{lightboxInfo.width && lightboxInfo.height && (
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>{lightboxInfo.width} × {lightboxInfo.height}</div>
)}
{lightboxInfo.fileSize && (
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)' }}>{(lightboxInfo.fileSize / 1024 / 1024).toFixed(1)} MB</div>
)}
</div>
)}
</div>
)}
{lightboxInfoLoading && (
<div style={{ width: 240, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="w-6 h-6 border-2 rounded-full animate-spin" style={{ borderColor: 'rgba(255,255,255,0.2)', borderTopColor: 'white' }} />
</div>
)}
</div>
</div>
)}
</div>
)
}
@@ -3,6 +3,7 @@ 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) {
@@ -11,6 +12,13 @@ function noteIconSvg(iconId) {
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' }))
}
const TRANSPORT_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
function transportIconSvg(type) {
if (!_renderToStaticMarkup) return ''
const Icon = TRANSPORT_ICON_MAP[type] || Ticket
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' }))
}
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
@@ -88,9 +96,21 @@ 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
reservations?: any[]
t: (key: string, params?: Record<string, string | number>) => string
locale: string
}
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, reservations = [], t: _t, locale: _locale }: downloadTripPDFProps) {
await ensureRenderer()
const loc = _locale || 'de-DE'
const loc = _locale || undefined
const tr = _t || (k => k)
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number)
const range = longDateRange(sorted, loc)
@@ -111,15 +131,46 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
const cost = dayCost(assignments, day.id, loc)
// Transport bookings for this day
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
const dayTransport = (reservations || []).filter(r => {
if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false
return day.date && r.reservation_time.split('T')[0] === day.date
})
const merged = []
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 }))
dayTransport.forEach(r => {
const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
merged.push({ type: 'transport', k: pos, data: r })
})
merged.sort((a, b) => a.k - b.k)
let pi = 0
const itemsHtml = merged.length === 0
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
: merged.map(item => {
if (item.type === 'transport') {
const r = item.data
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
const icon = transportIconSvg(r.type)
let subtitle = ''
if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport}${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
return `
<div class="note-card" style="border-left: 3px solid #3b82f6;">
<div class="note-line" style="background: #3b82f6;"></div>
<span class="note-icon">${icon}</span>
<div class="note-body">
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
</div>
</div>`
}
if (item.type === 'note') {
const note = item.data
return `
@@ -153,7 +204,7 @@ 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>` : '',
place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString(loc)} EUR</span>` : '',
].filter(Boolean).join('')
return `
@@ -178,7 +229,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
<div class="day-section${di > 0 ? ' page-break' : ''}">
<div class="day-header">
<span class="day-tag">${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()}</span>
<span class="day-title">${escHtml(day.title || `Tag ${day.day_number}`)}</span>
<span class="day-title">${escHtml(day.title || tr('dayplan.dayN', { n: day.day_number }))}</span>
${day.date ? `<span class="day-date">${shortDate(day.date, loc)}</span>` : ''}
${cost ? `<span class="day-cost">${cost}</span>` : ''}
</div>
@@ -187,7 +238,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
}).join('')
const html = `<!DOCTYPE html>
<html lang="de">
<html lang="${loc.split('-')[0]}">
<head>
<meta charset="UTF-8">
<base href="${window.location.origin}/">
@@ -365,7 +416,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
<div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div>
</div>
${totalCost > 0 ? `<div>
<div class="cover-stat-num">${totalCost.toLocaleString('de-DE')}</div>
<div class="cover-stat-num">${totalCost.toLocaleString(loc)}</div>
<div class="cover-stat-lbl">${escHtml(tr('pdf.costLabel'))}</div>
</div>` : ''}
</div>
@@ -1,527 +0,0 @@
import React, { useState, useMemo, useRef } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import {
CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight,
Sparkles, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage,
} from 'lucide-react'
const VORSCHLAEGE = [
{ 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
const KAT_COLORS = [
'#3b82f6', // blue
'#a855f7', // purple
'#ec4899', // pink
'#22c55e', // green
'#f97316', // orange
'#06b6d4', // cyan
'#ef4444', // red
'#eab308', // yellow
'#8b5cf6', // violet
'#14b8a6', // teal
]
// Stable color assignment: category name → index via simple hash
function katColor(kat, allCategories) {
const idx = allCategories ? allCategories.indexOf(kat) : -1
if (idx >= 0) return KAT_COLORS[idx % KAT_COLORS.length]
// Fallback: hash-based
let h = 0
for (let i = 0; i < kat.length; i++) h = ((h << 5) - h + kat.charCodeAt(i)) | 0
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length]
}
// ── Artikel-Zeile ──────────────────────────────────────────────────────────
function ArtikelZeile({ item, tripId, categories, onCategoryChange }) {
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(item.name)
const [hovered, setHovered] = useState(false)
const [showCatPicker, setShowCatPicker] = useState(false)
const { togglePackingItem, updatePackingItem, deletePackingItem } = useTripStore()
const toast = useToast()
const { t } = useTranslation()
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
const handleSaveName = async () => {
if (!editName.trim()) { setEditing(false); setEditName(item.name); return }
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
catch { toast.error(t('packing.toast.saveError')) }
}
const handleDelete = async () => {
try { await deletePackingItem(tripId, item.id) }
catch { toast.error(t('packing.toast.deleteError')) }
}
const handleCatChange = async (cat) => {
setShowCatPicker(false)
if (cat === item.category) return
try { await updatePackingItem(tripId, item.id, { category: cat }) }
catch { toast.error(t('common.error')) }
}
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => { setHovered(false); setShowCatPicker(false) }}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 10px', borderRadius: 10, position: 'relative',
background: hovered ? 'var(--bg-secondary)' : 'transparent',
transition: 'background 0.1s',
}}
>
<button onClick={handleToggle} style={{
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex',
color: item.checked ? '#10b981' : 'var(--text-faint)', transition: 'color 0.15s',
}}>
{item.checked ? <CheckSquare size={18} /> : <Square size={18} />}
</button>
{editing ? (
<input
type="text" value={editName} autoFocus
onChange={e => setEditName(e.target.value)}
onBlur={handleSaveName}
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(item.name) } }}
style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
/>
) : (
<span
onClick={() => !item.checked && setEditing(true)}
style={{
flex: 1, fontSize: 13.5,
cursor: item.checked ? 'default' : 'text',
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
textDecoration: item.checked ? 'line-through' : 'none',
}}
>
{item.name}
</span>
)}
<div style={{ display: 'flex', gap: 2, alignItems: 'center', opacity: hovered ? 1 : 0, transition: 'opacity 0.12s', flexShrink: 0 }}>
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowCatPicker(p => !p)}
title={t('packing.changeCategory')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 5px', borderRadius: 6, display: 'flex', alignItems: 'center', color: 'var(--text-faint)', fontSize: 10, gap: 2 }}
>
<span style={{ width: 7, height: 7, borderRadius: '50%', background: katColor(item.category || t('packing.defaultCategory'), categories), display: 'inline-block' }} />
</button>
{showCatPicker && (
<div style={{
position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)',
border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)',
padding: 4, minWidth: 140,
}}>
{categories.map(cat => (
<button key={cat} onClick={() => handleCatChange(cat)} style={{
display: 'flex', alignItems: 'center', gap: 7, width: '100%',
padding: '6px 10px', background: cat === (item.category || t('packing.defaultCategory')) ? 'var(--bg-tertiary)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 12.5, fontFamily: 'inherit',
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
}}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(cat, categories), flexShrink: 0 }} />
{cat}
</button>
))}
</div>
)}
</div>
<button onClick={() => setEditing(true)} title={t('common.rename')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={13} />
</button>
<button onClick={handleDelete} title={t('common.delete')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={13} />
</button>
</div>
</div>
)
}
// ── Kategorie-Gruppe ───────────────────────────────────────────────────────
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll }) {
const [offen, setOffen] = useState(true)
const [editingName, setEditingName] = useState(false)
const [editKatName, setEditKatName] = useState(kategorie)
const [showMenu, setShowMenu] = useState(false)
const { togglePackingItem } = useTripStore()
const toast = useToast()
const { t } = useTranslation()
const abgehakt = items.filter(i => i.checked).length
const alleAbgehakt = abgehakt === items.length
const dot = katColor(kategorie, allCategories)
const handleSaveKatName = async () => {
const neu = editKatName.trim()
if (!neu || neu === kategorie) { setEditingName(false); setEditKatName(kategorie); return }
try { await onRename(kategorie, neu); setEditingName(false) }
catch { toast.error(t('packing.toast.renameError')) }
}
const handleCheckAll = async () => {
for (const item of items) {
if (!item.checked) await togglePackingItem(tripId, item.id, true)
}
}
const handleUncheckAll = async () => {
for (const item of items) {
if (item.checked) await togglePackingItem(tripId, item.id, false)
}
}
const handleDeleteAll = async () => {
await onDeleteAll(items)
setShowMenu(false)
}
return (
<div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-secondary)', overflow: 'visible' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', borderBottom: offen ? '1px solid var(--border-secondary)' : 'none' }}>
<button onClick={() => setOffen(o => !o)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex', color: 'var(--text-faint)', flexShrink: 0 }}>
{offen ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
</button>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
{editingName ? (
<input
autoFocus value={editKatName}
onChange={e => setEditKatName(e.target.value)}
onBlur={handleSaveKatName}
onKeyDown={e => { if (e.key === 'Enter') handleSaveKatName(); if (e.key === 'Escape') { setEditingName(false); setEditKatName(kategorie) } }}
style={{ flex: 1, fontSize: 12.5, fontWeight: 600, border: 'none', borderBottom: '2px solid var(--text-primary)', outline: 'none', background: 'transparent', fontFamily: 'inherit', color: 'var(--text-primary)', padding: '0 2px' }}
/>
) : (
<span style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em', flex: 1 }}>
{kategorie}
</span>
)}
<span style={{
fontSize: 11, fontWeight: 600, padding: '1px 8px', borderRadius: 99,
background: alleAbgehakt ? 'rgba(22,163,74,0.12)' : 'var(--bg-tertiary)',
color: alleAbgehakt ? '#16a34a' : 'var(--text-muted)',
}}>
{abgehakt}/{items.length}
</span>
<div style={{ position: 'relative' }}>
<button onClick={() => setShowMenu(m => !m)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<MoreHorizontal size={15} />
</button>
{showMenu && (
<div style={{ position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}
onMouseLeave={() => setShowMenu(false)}>
<MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
</div>
)}
</div>
</div>
{offen && (
<div style={{ padding: '4px 4px 6px' }}>
{items.map(item => (
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} />
))}
</div>
)}
</div>
)
}
function MenuItem({ icon, label, onClick, danger }) {
return (
<button onClick={onClick} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '7px 10px', background: 'none', border: 'none', cursor: 'pointer',
fontSize: 12.5, fontFamily: 'inherit', borderRadius: 7, textAlign: 'left',
color: danger ? '#ef4444' : 'var(--text-secondary)',
}}
onMouseEnter={e => e.currentTarget.style.background = danger ? '#fef2f2' : 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
>
{icon}{label}
</button>
)
}
// ── Haupt-Panel ────────────────────────────────────────────────────────────
export default function PackingListPanel({ tripId, items }) {
const [neuerName, setNeuerName] = useState('')
const [neueKategorie, setNeueKategorie] = useState('')
const [zeigeVorschlaege, setZeigeVorschlaege] = useState(false)
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
const [showKatDropdown, setShowKatDropdown] = useState(false)
const katInputRef = useRef(null)
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
const toast = useToast()
const { t } = useTranslation()
const allCategories = useMemo(() => {
const cats = new Set(items.map(i => i.category || t('packing.defaultCategory')))
return Array.from(cats).sort()
}, [items, t])
const gruppiert = useMemo(() => {
const filtered = items.filter(i => {
if (filter === 'offen') return !i.checked
if (filter === 'erledigt') return i.checked
return true
})
const groups = {}
for (const item of filtered) {
const kat = item.category || t('packing.defaultCategory')
if (!groups[kat]) groups[kat] = []
groups[kat].push(item)
}
return groups
}, [items, filter, t])
const abgehakt = items.filter(i => i.checked).length
const fortschritt = items.length > 0 ? Math.round((abgehakt / items.length) * 100) : 0
const handleAdd = async (e) => {
e.preventDefault()
if (!neuerName.trim()) return
const kat = neueKategorie.trim() || (allCategories[0] || t('packing.defaultCategory'))
try {
await addPackingItem(tripId, { name: neuerName.trim(), category: kat })
setNeuerName('')
} catch { toast.error(t('packing.toast.addError')) }
}
const vorschlaege = t('packing.suggestions.items') || VORSCHLAEGE
const handleVorschlag = async (v) => {
try { await addPackingItem(tripId, { name: v.name, category: v.category || v.kategorie }) }
catch { toast.error(t('packing.toast.addError')) }
}
const handleRenameCategory = async (oldName, newName) => {
const toUpdate = items.filter(i => (i.category || t('packing.defaultCategory')) === oldName)
for (const item of toUpdate) {
await updatePackingItem(tripId, item.id, { category: newName })
}
}
const handleDeleteCategory = async (catItems) => {
for (const item of catItems) {
try { await deletePackingItem(tripId, item.id) } catch {}
}
}
const handleClearChecked = async () => {
if (!confirm(t('packing.confirm.clearChecked', { count: abgehakt }))) return
for (const item of items.filter(i => i.checked)) {
try { await deletePackingItem(tripId, item.id) } catch {}
}
}
const vorhandeneNamen = new Set(items.map(i => i.name.toLowerCase()))
const verfuegbareVorschlaege = vorschlaege.filter(v => !vorhandeneNamen.has(v.name.toLowerCase()))
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
{/* ── Header ── */}
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 14 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{items.length === 0 ? t('packing.empty') : t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
</p>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{abgehakt > 0 && (
<button onClick={handleClearChecked} style={{
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
}}>
<span className="hidden sm:inline">{t('packing.clearChecked', { count: abgehakt })}</span>
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
</button>
)}
<button onClick={() => setZeigeVorschlaege(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
background: zeigeVorschlaege ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: zeigeVorschlaege ? 'var(--text-primary)' : 'var(--border-primary)',
color: zeigeVorschlaege ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
<Sparkles size={12} /> {t('packing.suggestions')}
</button>
</div>
</div>
{items.length > 0 && (
<div style={{ marginBottom: 14 }}>
<div style={{ height: 5, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 99, transition: 'width 0.4s ease',
background: fortschritt === 100 ? '#10b981' : 'linear-gradient(90deg, var(--text-primary) 0%, var(--text-muted) 100%)',
width: `${fortschritt}%`,
}} />
</div>
{fortschritt === 100 && (
<p style={{ fontSize: 11.5, color: '#10b981', marginTop: 4, fontWeight: 600, margin: '4px 0 0' }}>{t('packing.allPacked')}</p>
)}
</div>
)}
<form onSubmit={handleAdd} style={{ display: 'flex', gap: 6 }}>
<input
type="text" value={neuerName} onChange={e => setNeuerName(e.target.value)}
placeholder={t('packing.addPlaceholder')}
style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)' }}
/>
<div style={{ position: 'relative' }}>
<input
ref={katInputRef}
type="text" value={neueKategorie}
onChange={e => { setNeueKategorie(e.target.value); setShowKatDropdown(true) }}
onFocus={() => setShowKatDropdown(true)}
onBlur={() => setTimeout(() => setShowKatDropdown(false), 150)}
placeholder={allCategories[0] || t('packing.categoryPlaceholder')}
style={{ width: 120, padding: '8px 10px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)' }}
/>
{showKatDropdown && allCategories.length > 0 && (
<div style={{ position: 'absolute', top: '100%', left: 0, right: 0, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', zIndex: 50, padding: 4, marginTop: 2 }}>
{allCategories.filter(c => !neueKategorie || c.toLowerCase().includes(neueKategorie.toLowerCase())).map(cat => (
<button key={cat} type="button" onMouseDown={() => setNeueKategorie(cat)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
padding: '6px 10px', background: 'none', border: 'none', cursor: 'pointer',
fontSize: 12.5, fontFamily: 'inherit', color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(cat, allCategories), flexShrink: 0 }} />
{cat}
</button>
))}
</div>
)}
</div>
<button type="submit" style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Plus size={16} />
</button>
</form>
</div>
{/* ── Vorschläge ── */}
{zeigeVorschlaege && (
<div style={{ borderBottom: '1px solid rgba(0,0,0,0.06)', background: 'var(--bg-secondary)', padding: '10px 20px', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('packing.suggestionsTitle')}</span>
<button onClick={() => setZeigeVorschlaege(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex' }}>
<X size={14} style={{ color: 'var(--text-faint)' }} />
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, maxHeight: 110, overflowY: 'auto' }}>
{verfuegbareVorschlaege.map((v, i) => (
<button key={i} onClick={() => handleVorschlag(v)} style={{
fontSize: 12, padding: '4px 10px', borderRadius: 99, border: '1px solid var(--border-primary)',
background: 'var(--bg-card)', cursor: 'pointer', color: 'var(--text-secondary)', fontFamily: 'inherit', transition: 'all 0.1s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--text-primary)'; e.currentTarget.style.color = 'white'; e.currentTarget.style.borderColor = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.color = 'var(--text-secondary)'; e.currentTarget.style.borderColor = 'var(--border-primary)' }}
>
+ {v.name}
</button>
))}
{verfuegbareVorschlaege.length === 0 && <p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0 }}>{t('packing.allSuggested')}</p>}
</div>
</div>
)}
{/* ── Filter-Tabs ── */}
{items.length > 0 && (
<div style={{ display: 'flex', gap: 4, padding: '10px 16px 0', flexShrink: 0 }}>
{[['alle', t('packing.filterAll')], ['offen', t('packing.filterOpen')], ['erledigt', t('packing.filterDone')]].map(([id, label]) => (
<button key={id} onClick={() => setFilter(id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer',
fontSize: 12, fontFamily: 'inherit', fontWeight: filter === id ? 600 : 400,
background: filter === id ? 'var(--text-primary)' : 'transparent',
color: filter === id ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>{label}</button>
))}
</div>
)}
{/* ── Liste ── */}
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 12px 16px' }}>
{items.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<Luggage size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 10px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('packing.emptyTitle')}</p>
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('packing.emptyHint')}</p>
</div>
) : Object.keys(gruppiert).length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text-faint)' }}>
<p style={{ fontSize: 13, margin: 0 }}>{t('packing.emptyFiltered')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{Object.entries(gruppiert).map(([kat, katItems]) => (
<KategorieGruppe
key={kat}
kategorie={kat}
items={katItems}
tripId={tripId}
allCategories={allCategories}
onRename={handleRenameCategory}
onDeleteAll={handleDeleteCategory}
/>
))}
</div>
)}
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -1,12 +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 { getLocaleForLanguage, useTranslation } from '../../i18n'
import type { Photo, Place, Day } from '../../types'
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }) {
const { t } = useTranslation()
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, language } = useTranslation()
const [lightboxIndex, setLightboxIndex] = useState(null)
const [showUpload, setShowUpload] = useState(false)
const [filterDayId, setFilterDayId] = useState('')
@@ -42,7 +53,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
<div style={{ marginRight: 'auto' }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>Fotos</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: '#9ca3af' }}>
{photos.length} Foto{photos.length !== 1 ? 's' : ''}
{photos.length} {photos.length !== 1 ? 'Fotos' : 'Foto'}
</p>
</div>
@@ -54,7 +65,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
<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)}` : ''}
{t('planner.dayN', { n: day.day_number })}{day.date ? ` · ${formatDate(day.date, getLocaleForLanguage(language))}` : ''}
</option>
))}
</select>
@@ -73,7 +84,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
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 whitespace-nowrap"
>
<Upload className="w-4 h-4" />
Fotos hochladen
{t('common.upload')}
</button>
</div>
@@ -90,7 +101,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
style={{ display: 'inline-flex', margin: '0 auto' }}
>
<Upload className="w-4 h-4" />
Fotos hochladen
{t('common.upload')}
</button>
</div>
) : (
@@ -135,7 +146,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
<Modal
isOpen={showUpload}
onClose={() => setShowUpload(false)}
title="Fotos hochladen"
title={t('common.upload')}
size="lg"
>
<PhotoUpload
@@ -153,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)
@@ -168,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'
}}
/>
@@ -193,7 +211,7 @@ function PhotoThumbnail({ photo, days, places, onClick }) {
)
}
function formatDate(dateStr) {
function formatDate(dateStr, locale) {
if (!dateStr) return ''
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', { day: 'numeric', month: 'short' })
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
}
@@ -1,8 +1,20 @@
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)
@@ -215,10 +227,10 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
)
}
function formatDate(dateStr) {
function formatDate(dateStr, locale = 'en-US') {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
return new Date(dateStr).toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })
} catch { return '' }
}
@@ -1,9 +1,18 @@
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('')
@@ -48,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)
@@ -1,503 +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 { useTranslation } from '../../i18n'
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, hasMapsKey } = useAuthStore()
const { t } = useTranslation()
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 [searchSource, setSearchSource] = useState(null)
const handleMapSearch = async () => {
if (!mapQuery.trim()) return
setMapSearching(true)
try {
const data = await mapsApi.search(mapQuery)
setMapResults(data.places || [])
setSearchSource(data.source || 'google')
} catch (err) {
toast.error(err.response?.data?.error || t('places.mapsSearchError'))
} 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>
)}
{/* Place search — Google Maps or OpenStreetMap fallback */}
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
{!hasMapsKey && (
<p className="mb-2 text-xs" style={{ color: 'var(--text-faint)' }}>
{t('places.osmActive')}
</p>
)}
<div className="flex gap-2">
<div className="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={t('places.mapsSearchPlaceholder')}
className="w-full pl-8 pr-3 py-2 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
/>
</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" /> : t('common.search')}
</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>
{/* Reservation */}
<div className="grid grid-cols-1 gap-3">
<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,138 +0,0 @@
import React from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVertical, X, Edit2, Clock, DollarSign, 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,
}
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>
</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, assignment.id)}
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>
)
}
@@ -8,14 +8,20 @@ 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 { getLocaleForLanguage, 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,
}
function WIcon({ main, size = 14 }) {
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} />
}
@@ -32,15 +38,33 @@ function formatTime12(val, is12h) {
return `${h12}:${String(m).padStart(2, '0')} ${period}`
}
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange }) {
const { t, language } = useTranslation()
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
leftWidth?: number
rightWidth?: number
}
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0 }: DayDetailPanelProps) {
const { t, language, locale } = useTranslation()
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
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 [dayAccommodations, setDayAccommodations] = useState<any[]>([])
const [accommodations, setAccommodations] = useState([])
const [showHotelPicker, setShowHotelPicker] = useState(false)
const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id })
@@ -61,10 +85,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
accommodationsApi.list(tripId)
.then(data => {
setAccommodations(data.accommodations || [])
const acc = (data.accommodations || []).find(a =>
const allForDay = (data.accommodations || []).filter(a =>
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
)
setAccommodation(acc || null)
setDayAccommodations(allForDay)
setAccommodation(allForDay[0] || null)
})
.catch(() => {})
}, [tripId, day?.id])
@@ -116,7 +141,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
if (!day) return null
const formattedDate = day.date ? new Date(day.date + 'T00:00:00').toLocaleDateString(
language === 'de' ? 'de-DE' : 'en-US',
getLocaleForLanguage(language),
{ weekday: 'long', day: 'numeric', month: 'long' }
) : null
@@ -124,7 +149,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
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={{ position: 'fixed', bottom: 20, left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, zIndex: 50, ...font }}>
<div style={{
background: 'var(--bg-elevated)',
backdropFilter: 'blur(40px) saturate(180%)',
@@ -226,9 +251,6 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
)
)}
{/* Divider */}
{day.date && lat && lng && <div style={{ height: 1, background: 'var(--border-faint)', margin: '12px 0' }} />}
{/* ── Reservations for this day's assignments ── */}
{(() => {
const dayAssignments = assignments[String(day.id)] || []
@@ -236,6 +258,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
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 => {
@@ -248,9 +271,10 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<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 && (
{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 })}
{new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })}
{r.reservation_end_time && ` ${fmtTime(r.reservation_end_time)}`}
</span>
)}
</div>
@@ -268,57 +292,106 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<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')}
{dayAccommodations.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{dayAccommodations.map(acc => {
const isCheckInDay = acc.start_day_id === day.id
const isCheckOutDay = acc.end_day_id === day.id
const isMiddleDay = !isCheckInDay && !isCheckOutDay
const dayLabel = isCheckInDay && isCheckOutDay ? t('day.checkIn') + ' & ' + t('day.checkOut')
: isCheckInDay ? t('day.checkIn')
: isCheckOutDay ? t('day.checkOut')
: null
const linked = reservations.find(r => r.accommodation_id === acc.id)
const confirmed = linked?.status === 'confirmed'
return (
<div key={acc.id} style={{ borderRadius: 12, background: 'var(--bg-secondary)', overflow: 'hidden' }}>
{/* Day label */}
{dayLabel && (
<div style={{ padding: '4px 12px 0', display: 'flex', alignItems: 'center', gap: 4 }}>
{isCheckInDay && <LogIn size={9} style={{ color: '#22c55e' }} />}
{isCheckOutDay && !isCheckInDay && <LogOut size={9} style={{ color: '#ef4444' }} />}
<span style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: isCheckOutDay && !isCheckInDay ? '#ef4444' : '#22c55e' }}>{dayLabel}</span>
</div>
)}
{/* Hotel header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px' }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{acc.place_image ? (
<img src={acc.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' }}>{acc.place_name}</div>
{acc.place_address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_address}</div>}
</div>
<button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
<Pencil size={12} style={{ color: 'var(--text-faint)' }} />
</button>
<button onClick={() => { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
<X size={12} style={{ color: 'var(--text-faint)' }} />
</button>
</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')}
{/* Details grid */}
<div style={{ display: 'flex', gap: 0, margin: '0 12px 8px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}>
{acc.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(acc.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>
)}
{acc.check_out && (
<div style={{ flex: 1, padding: '8px 10px', borderRight: acc.confirmation ? '1px solid var(--border-faint)' : 'none', textAlign: 'center' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{fmtTime(acc.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>
)}
{acc.confirmation && (
<div style={{ flex: 1, padding: '8px 10px', textAlign: 'center' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{acc.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>
)}
</div>
{/* Linked booking */}
{linked && (
<div style={{ margin: '0 12px 8px', padding: '6px 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)'}`, display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: confirmed ? '#16a34a' : '#d97706', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.title}</div>
<div style={{ fontSize: 9, color: 'var(--text-faint)', display: 'flex', gap: 6, marginTop: 1 }}>
<span>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
{linked.confirmation_number && <span
onMouseEnter={e => { if (blurCodes) e.currentTarget.style.filter = 'none' }}
onMouseLeave={e => { if (blurCodes) e.currentTarget.style.filter = 'blur(4px)' }}
onClick={e => { if (blurCodes) { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(4px)' : 'none' } }}
style={{ filter: blurCodes ? 'blur(4px)' : 'none', transition: 'filter 0.2s', cursor: blurCodes ? 'pointer' : 'default' }}
>#{linked.confirmation_number}</span>}
</div>
</div>
</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>
)
})}
{/* Add another hotel */}
<button onClick={() => setShowHotelPicker(true)} style={{
width: '100%', padding: 8, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
fontSize: 10, color: 'var(--text-faint)', fontFamily: 'inherit',
}}>
<Hotel size={10} /> {t('day.addAccommodation')}
</button>
</div>
) : (
<button onClick={() => setShowHotelPicker(true)} style={{
@@ -358,7 +431,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
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' })}` : ''}`,
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`,
}))}
size="sm"
/>
@@ -370,7 +443,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
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' })}` : ''}`,
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`,
}))}
size="sm"
/>
@@ -505,7 +578,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
)
}
function Chip({ icon: Icon, value }) {
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 }} />
@@ -514,7 +592,16 @@ function Chip({ icon: Icon, value }) {
)
}
function InfoChip({ icon: Icon, label, value, placeholder, onEdit, type }) {
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)
-138
View File
@@ -1,138 +0,0 @@
import React from 'react'
import { CalendarDays, MapPin, Plus } from 'lucide-react'
import WeatherWidget from '../Weather/WeatherWidget'
import { useTranslation } from '../../i18n'
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 { t } = useTranslation()
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">{t('planner.dayPlan')}</h2>
<p className="text-xs text-gray-400 mt-0.5">{t('planner.dayCount', { n: days.length })}</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'}`}>
{t('planner.allPlaces')}
</p>
<p className="text-xs text-gray-400">{t('planner.overview')}</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">{t('planner.noDays')}</p>
<p className="text-xs text-gray-300 mt-1">{t('planner.editTripToAddDays')}</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 === 1 ? t('planner.placeOne') : t('planner.placeN', { n: placeCount })}
</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">{t('planner.totalCost')}</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,225 +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'
import { useTranslation } from '../../i18n'
export function PlaceDetailPanel({
place, categories, tags, selectedDayId, dayAssignments,
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
}) {
const { t } = useTranslation()
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
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>
)}
{/* 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" />
{t('planner.removeFromDay')}
</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" />
{t('planner.addToThisDay')}
</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" />
{t('common.edit')}
</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,4 +1,4 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useState, useEffect, useRef, useMemo } from 'react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { mapsApi } from '../../api/client'
@@ -7,8 +7,23 @@ import { useToast } from '../shared/Toast'
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
import type { Place, Category, Assignment } from '../../types'
const DEFAULT_FORM = {
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: PlaceFormData = {
name: '',
description: '',
address: '',
@@ -22,10 +37,23 @@ const DEFAULT_FORM = {
website: '',
}
interface PlaceFormModalProps {
isOpen: boolean
onClose: () => void
onSave: (data: PlaceFormData, files?: File[]) => Promise<void> | void
place: Place | null
prefillCoords?: { lat: number; lng: number; name?: string; address?: string } | null
tripId: number
categories: Category[]
onCategoryCreated: (category: Category) => void
assignmentId: number | null
dayAssignments?: Assignment[]
}
export default function PlaceFormModal({
isOpen, onClose, onSave, place, tripId, categories,
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
onCategoryCreated, assignmentId, dayAssignments = [],
}) {
}: PlaceFormModalProps) {
const [form, setForm] = useState(DEFAULT_FORM)
const [mapsSearch, setMapsSearch] = useState('')
const [mapsResults, setMapsResults] = useState([])
@@ -54,11 +82,19 @@ export default function PlaceFormModal({
transport_mode: place.transport_mode || 'walking',
website: place.website || '',
})
} else if (prefillCoords) {
setForm({
...DEFAULT_FORM,
lat: String(prefillCoords.lat),
lng: String(prefillCoords.lng),
name: prefillCoords.name || '',
address: prefillCoords.address || '',
})
} else {
setForm(DEFAULT_FORM)
}
setPendingFiles([])
}, [place, isOpen])
}, [place, prefillCoords, isOpen])
const handleChange = (field, value) => {
setForm(prev => ({ ...prev, [field]: value }))
@@ -68,9 +104,27 @@ export default function PlaceFormModal({
if (!mapsSearch.trim()) return
setIsSearchingMaps(true)
try {
// Detect Google Maps URLs and resolve them directly
const trimmed = mapsSearch.trim()
if (trimmed.match(/^https?:\/\/(www\.)?(google\.[a-z.]+\/maps|maps\.google\.[a-z.]+|maps\.app\.goo\.gl|goo\.gl)/i)) {
const resolved = await mapsApi.resolveUrl(trimmed)
if (resolved.lat && resolved.lng) {
setForm(prev => ({
...prev,
name: resolved.name || prev.name,
address: resolved.address || prev.address,
lat: String(resolved.lat),
lng: String(resolved.lng),
}))
setMapsResults([])
setMapsSearch('')
toast.success(t('places.urlResolved'))
return
}
}
const result = await mapsApi.search(mapsSearch, language)
setMapsResults(result.places || [])
} catch (err) {
} catch (err: unknown) {
toast.error(t('places.mapsSearchError'))
} finally {
setIsSearchingMaps(false)
@@ -85,6 +139,9 @@ export default function PlaceFormModal({
lat: result.lat || prev.lat,
lng: result.lng || prev.lng,
google_place_id: result.google_place_id || prev.google_place_id,
osm_id: result.osm_id || prev.osm_id,
website: result.website || prev.website,
phone: result.phone || prev.phone,
}))
setMapsResults([])
setMapsSearch('')
@@ -97,13 +154,13 @@ 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.files || [])
const files = Array.from((e.target as HTMLInputElement).files || [])
setPendingFiles(prev => [...prev, ...files])
e.target.value = ''
}
@@ -116,7 +173,7 @@ export default function PlaceFormModal({
const handlePaste = (e) => {
const items = e.clipboardData?.items
if (!items) return
for (const item of items) {
for (const item of Array.from(items)) {
if (item.type.startsWith('image/') || item.type === 'application/pdf') {
e.preventDefault()
const file = item.getAsFile()
@@ -144,8 +201,8 @@ export default function PlaceFormModal({
_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)
}
@@ -242,6 +299,15 @@ export default function PlaceFormModal({
step="any"
value={form.lat}
onChange={e => handleChange('lat', e.target.value)}
onPaste={e => {
const text = e.clipboardData.getData('text').trim()
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
if (match) {
e.preventDefault()
handleChange('lat', match[1])
handleChange('lng', match[2])
}
}}
placeholder={t('places.formLat')}
className="form-input"
/>
@@ -371,7 +437,16 @@ export default function PlaceFormModal({
)
}
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, t }) {
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 []
@@ -5,6 +5,7 @@ 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()
@@ -19,23 +20,21 @@ function setSessionCache(key, value) {
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
}
function useGoogleDetails(googlePlaceId, language) {
function usePlaceDetails(googlePlaceId, osmId, language) {
const [details, setDetails] = useState(null)
const cacheKey = `gdetails_${googlePlaceId}_${language}`
const detailId = googlePlaceId || osmId
const cacheKey = `gdetails_${detailId}_${language}`
useEffect(() => {
if (!googlePlaceId) { setDetails(null); return }
// In-memory cache (fastest)
if (!detailId) { setDetails(null); return }
if (detailsCache.has(cacheKey)) { setDetails(detailsCache.get(cacheKey)); return }
// sessionStorage cache (survives reload)
const cached = getSessionCache(cacheKey)
if (cached) { detailsCache.set(cacheKey, cached); setDetails(cached); return }
// Fetch from API
mapsApi.details(googlePlaceId, language).then(data => {
mapsApi.details(detailId, language).then(data => {
detailsCache.set(cacheKey, data.place)
setSessionCache(cacheKey, data.place)
setDetails(data.place)
}).catch(() => {})
}, [googlePlaceId, language])
}, [detailId, language])
return details
}
@@ -97,18 +96,70 @@ 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
leftWidth?: number
rightWidth?: number
}
export default function PlaceInspector({
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
files, onFileUpload, tripMembers = [], onSetParticipants,
}) {
files, onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace,
leftWidth = 0, rightWidth = 0,
}: 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 googleDetails = usePlaceDetails(place?.google_place_id, place?.osm_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
@@ -121,10 +172,10 @@ export default function PlaceInspector({
const selectedDay = days?.find(d => d.id === selectedDayId)
const weekdayIndex = getWeekdayIndex(selectedDay?.date)
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id))
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id) || (f.linked_place_ids || []).includes(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 {
@@ -135,7 +186,7 @@ export default function PlaceInspector({
await onFileUpload(fd)
}
setFilesExpanded(true)
} catch (err) {
} catch (err: unknown) {
console.error('Upload failed', err)
} finally {
setIsUploading(false)
@@ -148,9 +199,9 @@ export default function PlaceInspector({
style={{
position: 'absolute',
bottom: 20,
left: '50%',
left: `calc(${leftWidth}px + (100% - ${leftWidth}px - ${rightWidth}px) / 2)`,
transform: 'translateX(-50%)',
width: 'min(800px, calc(100vw - 32px))',
width: `min(800px, calc(100% - ${leftWidth}px - ${rightWidth}px - 32px))`,
zIndex: 50,
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}}
@@ -192,7 +243,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 (
@@ -250,7 +315,7 @@ export default function PlaceInspector({
icon={<Star size={12} fill="#facc15" color="#facc15" />}
text={<>
{googleDetails.rating.toFixed(1)}
{googleDetails.rating_count ? <span style={{ opacity: 0.5 }}> ({googleDetails.rating_count.toLocaleString('de-DE')})</span> : ''}
{googleDetails.rating_count ? <span style={{ opacity: 0.5 }}> ({googleDetails.rating_count.toLocaleString(locale)})</span> : ''}
{shortReview && <span className="hidden md:inline" style={{ opacity: 0.6, fontWeight: 400, fontStyle: 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> · {shortReview.text}"</span>}
</>}
color="var(--text-secondary)" bg="var(--bg-hover)"
@@ -263,20 +328,20 @@ export default function PlaceInspector({
</div>
{/* Telefon */}
{place.phone && (
{(place.phone || googleDetails?.phone) && (
<div style={{ display: 'flex', gap: 12 }}>
<a href={`tel:${place.phone}`}
<a href={`tel:${place.phone || googleDetails.phone}`}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', textDecoration: 'none' }}>
<Phone size={12} /> {place.phone}
<Phone size={12} /> {place.phone || googleDetails.phone}
</a>
</div>
)}
{/* Description */}
{(place.description || place.notes) && (
{/* Description / Summary */}
{(place.description || place.notes || googleDetails?.summary) && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px' }}>
{place.description || place.notes}
{place.description || place.notes || googleDetails?.summary}
</p>
</div>
)}
@@ -310,10 +375,13 @@ export default function PlaceInspector({
<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 && (
{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' })}</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 && (
@@ -324,6 +392,20 @@ export default function PlaceInspector({
)}
</div>
{res.notes && <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}>{res.notes}</div>}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
const parts: string[] = []
if (meta.airline && meta.flight_number) parts.push(`${meta.airline} ${meta.flight_number}`)
else if (meta.flight_number) parts.push(meta.flight_number)
if (meta.departure_airport && meta.arrival_airport) parts.push(`${meta.departure_airport}${meta.arrival_airport}`)
if (meta.train_number) parts.push(meta.train_number)
if (meta.platform) parts.push(`Gl. ${meta.platform}`)
if (meta.check_in_time) parts.push(`Check-in ${meta.check_in_time}`)
if (meta.check_out_time) parts.push(`Check-out ${meta.check_out_time}`)
if (parts.length === 0) return null
return <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-muted)', fontWeight: 500 }}>{parts.join(' · ')}</div>
})()}
</div>
)
})()}
@@ -435,8 +517,12 @@ export default function PlaceInspector({
<ActionButton onClick={() => window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={<Navigation size={13} />}
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
)}
{place.website && (
<ActionButton onClick={() => window.open(place.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
{!googleDetails?.google_maps_url && place.lat && place.lng && (
<ActionButton onClick={() => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank')} variant="ghost" icon={<Navigation size={13} />}
label={<span className="hidden sm:inline">Google Maps</span>} />
)}
{(place.website || googleDetails?.website) && (
<ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
)}
<div style={{ flex: 1 }} />
@@ -448,7 +534,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>
@@ -457,7 +550,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>
@@ -466,7 +564,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)' },
@@ -491,7 +596,17 @@ function ActionButton({ onClick, variant, icon, label }) {
)
}
function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticipants, selectedAssignmentId, selectedDayId, t }) {
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)
@@ -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,35 +1,85 @@
import React, { useState } from 'react'
import React from 'react'
import ReactDOM from 'react-dom'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation } from 'lucide-react'
import { useState, useRef, useMemo, useCallback } from 'react'
import DOM from 'react-dom'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import { placesApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
interface PlacesSidebarProps {
tripId: number
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
onCategoryFilterChange?: (categoryId: string) => void
}
export default function PlacesSidebar({
places, categories, assignments, selectedDayId, selectedPlaceId,
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile,
}) {
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange,
}: PlacesSidebarProps) {
const { t } = useTranslation()
const toast = useToast()
const ctxMenu = useContextMenu()
const gpxInputRef = useRef<HTMLInputElement>(null)
const tripStore = useTripStore()
const handleGpxImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
try {
const result = await placesApi.importGpx(tripId, file)
await tripStore.loadTrip(tripId)
toast.success(t('places.gpxImported', { count: result.count }))
} catch (err: any) {
toast.error(err?.response?.data?.error || t('places.gpxError'))
}
}
const [search, setSearch] = useState('')
const [filter, setFilter] = useState('all')
const [categoryFilter, setCategoryFilter] = useState('')
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
const toggleCategoryFilter = (catId: string) => {
setCategoryFiltersLocal(prev => {
const next = new Set(prev)
if (next.has(catId)) next.delete(catId); else next.add(catId)
// Notify parent with first selected or empty
onCategoryFilterChange?.(next.size === 1 ? [...next][0] : '')
return next
})
}
const [dayPickerPlace, setDayPickerPlace] = useState(null)
const [catDropOpen, setCatDropOpen] = useState(false)
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
const plannedIds = new Set(
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
)
const filtered = places.filter(p => {
const filtered = useMemo(() => places.filter(p => {
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
if (categoryFilter && String(p.category_id) !== String(categoryFilter)) return false
if (categoryFilters.size > 0 && !categoryFilters.has(String(p.category_id))) return false
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
return true
})
}), [places, filter, categoryFilters, search, plannedIds.size])
const isAssignedToSelectedDay = (placeId) =>
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
@@ -49,6 +99,19 @@ export default function PlacesSidebar({
>
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
</button>
<input ref={gpxInputRef} type="file" accept=".gpx" style={{ display: 'none' }} onChange={handleGpxImport} />
<button
onClick={() => gpxInputRef.current?.click()}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
width: '100%', padding: '5px 12px', borderRadius: 8, marginBottom: 10,
border: '1px dashed var(--border-primary)', background: 'none',
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
</button>
{/* Filter-Tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
@@ -83,21 +146,69 @@ export default function PlacesSidebar({
)}
</div>
{/* Kategoriefilter */}
{categories.length > 0 && (
<div style={{ marginTop: 6 }}>
<CustomSelect
value={categoryFilter}
onChange={setCategoryFilter}
placeholder={t('places.allCategories')}
size="sm"
options={[
{ value: '', label: t('places.allCategories') },
...categories.map(c => ({ value: String(c.id), label: c.name }))
]}
/>
</div>
)}
{/* Category multi-select dropdown */}
{categories.length > 0 && (() => {
const label = categoryFilters.size === 0
? t('places.allCategories')
: categoryFilters.size === 1
? categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories')
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
return (
<div style={{ marginTop: 6, position: 'relative' }}>
<button onClick={() => setCatDropOpen(v => !v)} style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
background: 'var(--bg-card)', fontSize: 12, color: 'var(--text-primary)',
cursor: 'pointer', fontFamily: 'inherit',
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<ChevronDown size={12} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
</button>
{catDropOpen && (
<div style={{
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, maxHeight: 200, overflowY: 'auto',
}}>
{categories.map(c => {
const active = categoryFilters.has(String(c.id))
const CatIcon = getCategoryIcon(c.icon)
return (
<button key={c.id} onClick={() => toggleCategoryFilter(String(c.id))} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
background: active ? 'var(--bg-hover)' : 'transparent',
fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
textAlign: 'left',
}}>
<div style={{
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
border: active ? 'none' : '1.5px solid var(--border-primary)',
background: active ? (c.color || 'var(--accent)') : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{active && <Check size={10} strokeWidth={3} color="white" />}
</div>
<CatIcon size={12} strokeWidth={2} color={c.color || 'var(--text-muted)'} />
<span style={{ flex: 1 }}>{c.name}</span>
</button>
)
})}
{categoryFilters.size > 0 && (
<button onClick={() => { setCategoryFiltersLocal(new Set()); onCategoryFilterChange?.('') }} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
background: 'transparent', fontFamily: 'inherit', fontSize: 11, color: 'var(--text-faint)',
marginTop: 2, borderTop: '1px solid var(--border-faint)',
}}>
<X size={10} /> {t('places.clearFilter')}
</button>
)}
</div>
)}
</div>
)
})()}
</div>
{/* Anzahl */}
@@ -146,7 +257,7 @@ export default function PlacesSidebar({
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
{ divider: true },
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => { if (confirm(t('trip.confirm.deletePlace'))) onDeletePlace(place.id) } },
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])}
style={{
display: 'flex', alignItems: 'center', gap: 10,
@@ -155,6 +266,8 @@ export default function PlacesSidebar({
background: isSelected ? 'var(--border-faint)' : 'transparent',
borderBottom: '1px solid var(--border-faint)',
transition: 'background 0.1s',
contentVisibility: 'auto',
containIntrinsicSize: '0 52px',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
@@ -1,875 +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'
import { useTranslation } from '../../i18n'
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 [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 { t } = useTranslation()
const SEGMENTS = [
{ id: 'plan', label: 'Plan' },
{ id: 'orte', label: t('planner.places') },
{ id: 'reservierungen', label: t('planner.bookings') },
{ id: 'packliste', label: t('planner.packingList') },
{ id: 'dokumente', label: t('planner.documents') },
]
const dayNotes = tripStore.dayNotes || {}
const 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(t('planner.minTwoPlaces'))
return
}
setIsCalculatingRoute(true)
try {
const result = await calculateRoute(waypoints, 'walking')
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
onRouteCalculated?.(result)
toast.success(t('planner.routeCalculated'))
} catch {
toast.error(t('planner.routeCalcFailed'))
} 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(t('planner.routeOptimized'))
}
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(t('planner.noGeoPlaces'))
}
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(t('planner.reservationUpdated'))
} else {
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
toast.success(t('planner.reservationAdded'))
}
setShowReservationModal(false)
} catch (err) {
toast.error(err.message)
}
}
const handleDeleteReservation = async (id) => {
if (!confirm(t('planner.confirmDeleteReservation'))) return
try {
await tripStore.deleteReservation(tripId, id)
toast.success(t('planner.reservationDeleted'))
} 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} ${t('planner.days')}`}
</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'}`}>
{t('planner.allPlaces')}
</p>
<p className="text-xs text-gray-400">{t('planner.totalPlaces', { n: places.length })}</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">{t('planner.noDaysPlanned')}</p>
<button onClick={onEditTrip} className="mt-2 text-slate-700 text-sm">
{t('planner.editTrip')}
</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 === 1 ? t('planner.placeOne') : t('planner.placeN', { n: da.length })}
</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={t('planner.addNote')}
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">{t('planner.noEntries')}</p>
<button
onClick={() => { onSelectDay(day.id); setActiveSegment('orte') }}
className="mt-1 text-xs text-slate-700"
>
{t('planner.addPlaceShort')}
</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}{place.end_time ? ` ${place.end_time}` : ''}</span>
)}
{place.price > 0 && (
<span className="text-[11px] text-gray-400">{place.price} {place.currency || currency}</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={t('planner.noteTimePlaceholder')}
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
/>
</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={t('planner.notePlaceholder')}
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" /> {t('common.save')}
</button>
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
{t('common.cancel')}
</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={t('planner.noteTimePlaceholder')}
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
/>
</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={t('planner.noteExamplePlaceholder')}
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" /> {t('common.add')}
</button>
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
{t('common.cancel')}
</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" />
{t('planner.addNote')}
</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">
{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 ? t('planner.calculating') : t('planner.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" />
{t('planner.optimize')}
</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" />
{t('planner.openGoogleMaps')}
</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">{t('planner.totalCost')}</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={t('planner.searchPlaces')}
className="w-full pl-8 pr-8 py-2 bg-gray-100 rounded-[10px] text-sm focus:outline-none focus:bg-white focus:ring-2 focus:ring-slate-400 transition-colors"
/>
{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="">{t('planner.allCategories')}</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" />
{t('planner.new')}
</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">{t('planner.noPlacesFound')}</p>
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm">
{t('planner.addFirstPlace')}
</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"
>
{t('planner.addToDay')}
</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">
{t('planner.reservations')}
{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" />
{t('common.add')}
</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">{t('planner.noReservations')}</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,291 +0,0 @@
import React, { 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, Link2 } from 'lucide-react'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
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 buildAssignmentOptions(days, assignments, t, locale) {
const options = []
for (const day of (days || [])) {
const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
if (da.length === 0) continue
const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number })
const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : ''
// Group header (non-selectable)
options.push({ value: `_header_${day.id}`, label: `${dayLabel}${dateStr}`, disabled: true, isHeader: true })
for (let i = 0; i < da.length; i++) {
const place = da[i].place
if (!place) continue
const timeStr = place.place_time ? ` · ${place.place_time}${place.end_time ? ' ' + place.end_time : ''}` : ''
options.push({
value: da[i].id,
label: ` ${i + 1}. ${place.name}${timeStr}`,
})
}
}
return options
}
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete }) {
const toast = useToast()
const { t, locale } = useTranslation()
const fileInputRef = useRef(null)
const [form, setForm] = useState({
title: '', type: 'other', status: 'pending',
reservation_time: '', location: '', confirmation_number: '',
notes: '', assignment_id: '',
})
const [isSaving, setIsSaving] = useState(false)
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState([])
const assignmentOptions = useMemo(
() => buildAssignmentOptions(days, assignments, t, locale),
[days, assignments, t, locale]
)
useEffect(() => {
if (reservation) {
setForm({
title: reservation.title || '',
type: reservation.type || 'other',
status: reservation.status || 'pending',
reservation_time: reservation.reservation_time ? reservation.reservation_time.slice(0, 16) : '',
location: reservation.location || '',
confirmation_number: reservation.confirmation_number || '',
notes: reservation.notes || '',
assignment_id: reservation.assignment_id || '',
})
} else {
setForm({
title: '', type: 'other', status: 'pending',
reservation_time: '', location: '', confirmation_number: '',
notes: '', assignment_id: '',
})
setPendingFiles([])
}
}, [reservation, isOpen, selectedDayId])
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
const handleSubmit = async (e) => {
e.preventDefault()
if (!form.title.trim()) return
setIsSaving(true)
try {
const saved = await onSave({
...form,
assignment_id: form.assignment_id || null,
})
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', saved.id)
fd.append('description', form.title)
await onFileUpload(fd)
}
}
} finally {
setIsSaving(false)
}
}
const handleFileChange = async (e) => {
const file = e.target.files?.[0]
if (!file) return
if (reservation?.id) {
setUploadingFile(true)
try {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', reservation.id)
fd.append('description', reservation.title)
await onFileUpload(fd)
toast.success(t('reservations.toast.fileUploaded'))
} catch {
toast.error(t('reservations.toast.uploadError'))
} finally {
setUploadingFile(false)
e.target.value = ''
}
} else {
setPendingFiles(prev => [...prev, file])
e.target.value = ''
}
}
const attachedFiles = reservation?.id ? files.filter(f => f.reservation_id === reservation.id) : []
const inputStyle = {
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
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: 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="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: 5 }}>
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => (
<button key={value} type="button" onClick={() => set('type', value)} style={{
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={11} /> {t(labelKey)}
</button>
))}
</div>
</div>
{/* Title */}
<div>
<label style={labelStyle}>{t('reservations.titleLabel')} *</label>
<input type="text" value={form.title} onChange={e => set('title', e.target.value)} required
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
</div>
{/* Assignment Picker */}
{assignmentOptions.length > 0 && (
<div>
<label style={labelStyle}>
<Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
{t('reservations.linkAssignment')}
</label>
<CustomSelect
value={form.assignment_id}
onChange={value => set('assignment_id', value)}
placeholder={t('reservations.pickAssignment')}
options={[
{ value: '', label: t('reservations.noAssignment') },
...assignmentOptions,
]}
searchable
size="sm"
/>
</div>
)}
{/* Date/Time + Status */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.datetime')}</label>
<CustomDateTimePicker value={form.reservation_time} onChange={v => set('reservation_time', v)} />
</div>
<div>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
</div>
</div>
{/* Location + Booking Code */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<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>
<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>
</div>
{/* Notes */}
<div>
<label style={labelStyle}>{t('reservations.notes')}</label>
<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>
{/* Files */}
<div>
<label style={labelStyle}>{t('files.title')}</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{attachedFiles.map(f => (
<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 }}>
<X size={11} />
</button>
)}
</div>
))}
{pendingFiles.map((f, i) => (
<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 }}>
<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: 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>
</div>
{/* 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: '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: 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>
</form>
</Modal>
)
}
function formatDate(dateStr, locale) {
if (!dateStr) return ''
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString(locale || 'de-DE', { day: 'numeric', month: 'short' })
}
@@ -0,0 +1,577 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams } from 'react-router-dom'
import apiClient from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
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 { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
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 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>
accommodations?: Accommodation[]
}
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [] }: ReservationModalProps) {
const { id: tripId } = useParams<{ id: string }>()
const loadFiles = useTripStore(s => s.loadFiles)
const toast = useToast()
const { t, locale } = useTranslation()
const fileInputRef = useRef(null)
const [form, setForm] = useState({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
notes: '', assignment_id: '', accommodation_id: '',
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
meta_train_number: '', meta_platform: '', meta_seat: '',
meta_check_in_time: '', meta_check_out_time: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
})
const [isSaving, setIsSaving] = useState(false)
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState([])
const [showFilePicker, setShowFilePicker] = useState(false)
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
const [unlinkedFileIds, setUnlinkedFileIds] = useState<number[]>([])
const assignmentOptions = useMemo(
() => buildAssignmentOptions(days, assignments, t, locale),
[days, assignments, t, locale]
)
useEffect(() => {
if (reservation) {
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
setForm({
title: reservation.title || '',
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 || '',
assignment_id: reservation.assignment_id || '',
accommodation_id: reservation.accommodation_id || '',
meta_airline: meta.airline || '',
meta_flight_number: meta.flight_number || '',
meta_departure_airport: meta.departure_airport || '',
meta_arrival_airport: meta.arrival_airport || '',
meta_train_number: meta.train_number || '',
meta_platform: meta.platform || '',
meta_seat: meta.seat || '',
meta_check_in_time: meta.check_in_time || '',
meta_check_out_time: meta.check_out_time || '',
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
})
} else {
setForm({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
notes: '', assignment_id: '', accommodation_id: '',
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
meta_train_number: '', meta_platform: '', meta_seat: '',
meta_check_in_time: '', meta_check_out_time: '',
})
setPendingFiles([])
}
}, [reservation, isOpen, selectedDayId])
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
const handleSubmit = async (e) => {
e.preventDefault()
if (!form.title.trim()) return
setIsSaving(true)
try {
const metadata: Record<string, string> = {}
if (form.type === 'flight') {
if (form.meta_airline) metadata.airline = form.meta_airline
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
if (form.meta_departure_airport) metadata.departure_airport = form.meta_departure_airport
if (form.meta_arrival_airport) metadata.arrival_airport = form.meta_arrival_airport
} else if (form.type === 'hotel') {
if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time
if (form.meta_check_out_time) metadata.check_out_time = form.meta_check_out_time
} else if (form.type === 'train') {
if (form.meta_train_number) metadata.train_number = form.meta_train_number
if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat
}
const saveData: Record<string, any> = {
title: form.title, type: form.type, status: form.status,
reservation_time: form.reservation_time, reservation_end_time: form.reservation_end_time,
location: form.location, confirmation_number: form.confirmation_number,
notes: form.notes,
assignment_id: form.assignment_id || null,
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
metadata: Object.keys(metadata).length > 0 ? metadata : null,
}
// If hotel with place + days, pass hotel data for auto-creation or update
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
saveData.create_accommodation = {
place_id: form.hotel_place_id,
start_day_id: form.hotel_start_day,
end_day_id: form.hotel_end_day,
check_in: form.meta_check_in_time || null,
check_out: form.meta_check_out_time || null,
confirmation: form.confirmation_number || null,
}
}
const saved = await onSave(saveData)
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', saved.id)
fd.append('description', form.title)
await onFileUpload(fd)
}
}
} finally {
setIsSaving(false)
}
}
const handleFileChange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
if (reservation?.id) {
setUploadingFile(true)
try {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', reservation.id)
fd.append('description', reservation.title)
await onFileUpload(fd)
toast.success(t('reservations.toast.fileUploaded'))
} catch {
toast.error(t('reservations.toast.uploadError'))
} finally {
setUploadingFile(false)
e.target.value = ''
}
} else {
setPendingFiles(prev => [...prev, file])
e.target.value = ''
}
}
const attachedFiles = reservation?.id
? files.filter(f =>
f.reservation_id === reservation.id ||
linkedFileIds.includes(f.id) ||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(reservation.id))
)
: []
const inputStyle = {
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
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: 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="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: 5 }}>
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => (
<button key={value} type="button" onClick={() => set('type', value)} style={{
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={11} /> {t(labelKey)}
</button>
))}
</div>
</div>
{/* Title */}
<div>
<label style={labelStyle}>{t('reservations.titleLabel')} *</label>
<input type="text" value={form.title} onChange={e => set('title', e.target.value)} required
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
</div>
{/* Assignment Picker + Date (hidden for hotels) */}
{form.type !== 'hotel' && (
<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>
)}
{/* Start Time + End Time + Status */}
<div style={{ display: 'flex', gap: 8 }}>
{form.type !== 'hotel' && (
<>
<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}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
</div>
</div>
{/* Location + Booking Code */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<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>
<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>
</div>
{/* Type-specific fields */}
{form.type === 'flight' && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.airline') || 'Airline'}</label>
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
placeholder="Lufthansa" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.flightNumber') || 'Flight No.'}</label>
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
placeholder="LH 123" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.from') || 'From'}</label>
<input type="text" value={form.meta_departure_airport} onChange={e => set('meta_departure_airport', e.target.value)}
placeholder="FRA" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.to') || 'To'}</label>
<input type="text" value={form.meta_arrival_airport} onChange={e => set('meta_arrival_airport', e.target.value)}
placeholder="NRT" style={inputStyle} />
</div>
</div>
)}
{form.type === 'hotel' && (
<>
{/* Hotel place + day range */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.hotelPlace')}</label>
<CustomSelect
value={form.hotel_place_id}
onChange={value => {
set('hotel_place_id', value)
const p = places.find(pl => pl.id === value)
if (p) {
if (!form.title) set('title', p.name)
if (!form.location && p.address) set('location', p.address)
}
}}
placeholder={t('reservations.meta.pickHotel')}
options={[
{ value: '', label: '—' },
...places.map(p => ({ value: p.id, label: p.name })),
]}
searchable
size="sm"
/>
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.fromDay')}</label>
<CustomSelect
value={form.hotel_start_day}
onChange={value => set('hotel_start_day', value)}
placeholder={t('reservations.meta.selectDay')}
options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))}
size="sm"
/>
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.toDay')}</label>
<CustomSelect
value={form.hotel_end_day}
onChange={value => set('hotel_end_day', value)}
placeholder={t('reservations.meta.selectDay')}
options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))}
size="sm"
/>
</div>
</div>
{/* Check-in/out times */}
<div className="grid grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.checkIn')}</label>
<CustomTimePicker value={form.meta_check_in_time} onChange={v => set('meta_check_in_time', v)} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.checkOut')}</label>
<CustomTimePicker value={form.meta_check_out_time} onChange={v => set('meta_check_out_time', v)} />
</div>
</div>
</>
)}
{form.type === 'train' && (
<div className="grid grid-cols-3 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.trainNumber') || 'Train No.'}</label>
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
placeholder="ICE 123" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.platform') || 'Platform'}</label>
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
placeholder="12" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.seat') || 'Seat'}</label>
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
placeholder="42A" 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={2}
placeholder={t('reservations.notesPlaceholder')}
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div>
{/* Files */}
<div>
<label style={labelStyle}>{t('files.title')}</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{attachedFiles.map(f => (
<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>
<button type="button" onClick={async () => {
// Always unlink, never delete the file
// Clear primary reservation_id if it points to this reservation
if (f.reservation_id === reservation?.id) {
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
}
// Remove from file_links if linked there
try {
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
} catch {}
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
if (tripId) loadFiles(tripId)
}} 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: '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 }}>
<X size={11} />
</button>
</div>
))}
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
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>
{/* Link existing file picker */}
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
<div style={{ position: 'relative' }}>
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
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: 'pointer', fontFamily: 'inherit',
}}>
<Link2 size={11} /> {t('reservations.linkExisting')}
</button>
{showFilePicker && (
<div style={{
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, zIndex: 50,
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: 220, maxHeight: 200, overflowY: 'auto',
}}>
{files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => (
<button key={f.id} type="button" onClick={async () => {
try {
await apiClient.post(`/trips/${tripId}/files/${f.id}/link`, { reservation_id: reservation.id })
setLinkedFileIds(prev => [...prev, f.id])
setShowFilePicker(false)
if (tripId) loadFiles(tripId)
} catch {}
}}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit',
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<FileText size={12} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* 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: '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: 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>
</form>
</Modal>
)
}
function formatDate(dateStr, locale) {
if (!dateStr) return ''
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short' })
}
@@ -1,4 +1,5 @@
import React, { useState, useMemo } from 'react'
import { useState, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
@@ -8,6 +9,16 @@ import {
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' },
@@ -37,23 +48,36 @@ function buildAssignmentLookup(days, assignments) {
return map
}
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }) {
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 blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
const [codeRevealed, setCodeRevealed] = useState(false)
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 attachedFiles = files.filter(f => f.reservation_id === r.id || (f.linked_reservation_ids || []).includes(r.id))
const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
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
setShowDeleteConfirm(false)
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
}
@@ -84,7 +108,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
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 }}
<button onClick={() => setShowDeleteConfirm(true)} 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} />
@@ -92,7 +116,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
</div>
{/* Details */}
{(r.reservation_time || r.confirmation_number || r.location || linked) && (
{(r.reservation_time || r.confirmation_number || r.location || linked || r.metadata) && (
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{/* Row 1: Date, Time, Code */}
{(r.reservation_time || r.confirmation_number) && (
@@ -103,22 +127,62 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{fmtDate(r.reservation_time)}</div>
</div>
)}
{r.reservation_time && (
{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)}</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
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
onClick={() => blurCodes && setCodeRevealed(v => !v)}
style={{
fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1,
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
cursor: blurCodes ? 'pointer' : 'default',
transition: 'filter 0.2s',
}}
>
{r.confirmation_number}
</div>
</div>
)}
</div>
)}
{/* Row 1b: Type-specific metadata */}
{(() => {
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
const cells: { label: string; value: string }[] = []
if (meta.airline) cells.push({ label: t('reservations.meta.airline'), value: meta.airline })
if (meta.flight_number) cells.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number })
if (meta.departure_airport) cells.push({ label: t('reservations.meta.from'), value: meta.departure_airport })
if (meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: meta.check_in_time })
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: meta.check_out_time })
if (cells.length === 0) return null
return (
<div style={{ display: 'flex', gap: 0, borderRadius: 8, overflow: 'hidden', background: 'var(--bg-secondary)', boxShadow: '0 1px 6px rgba(0,0,0,0.08)' }}>
{cells.map((c, i) => (
<div key={i} style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: i < cells.length - 1 ? '1px solid var(--border-faint)' : 'none' }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{c.label}</div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{c.value}</div>
</div>
))}
</div>
)
})()}
{/* Row 2: Location + Assignment */}
{(r.location || linked) && (
{(r.location || linked || r.accommodation_name) && (
<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>
@@ -129,6 +193,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
</div>
</div>
)}
{r.accommodation_name && (
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.meta.linkAccommodation')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
<Hotel size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.accommodation_name}</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>
@@ -170,11 +243,59 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
</div>
</div>
)}
{/* Delete confirmation popup */}
{showDeleteConfirm && ReactDOM.createPortal(
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
}} onClick={() => setShowDeleteConfirm(false)}>
<div style={{
width: 340, background: 'var(--bg-card)', borderRadius: 16,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
display: 'flex', flexDirection: 'column', gap: 12,
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: '50%', background: 'rgba(239,68,68,0.12)',
}}>
<Trash2 size={18} strokeWidth={1.8} color="#ef4444" />
</div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
{t('reservations.confirm.deleteTitle')}
</div>
</div>
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
{t('reservations.confirm.deleteBody', { name: r.title })}
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
<button onClick={() => setShowDeleteConfirm(false)} 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={handleDelete} style={{
fontSize: 12, background: '#ef4444', color: 'white',
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
}}>{t('common.confirm')}</button>
</div>
</div>
</div>,
document.body
)}
</div>
)
}
function Section({ title, count, children, defaultOpen = true, accent }) {
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 }}>
@@ -195,7 +316,19 @@ function Section({ title, count, children, defaultOpen = true, accent }) {
)
}
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }) {
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'))
@@ -1,590 +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'
import { useTranslation } from '../../i18n'
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 [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()
const { t } = useTranslation()
const TABS = [
{ id: 'orte', label: t('planner.places'), icon: '📍' },
{ id: 'tagesplan', label: t('planner.dayPlan'), icon: '📅' },
{ id: 'reservierungen', label: t('planner.reservations'), icon: '🎫' },
{ id: 'packliste', label: t('planner.packingList'), icon: '🎒' },
]
// Filtered places for Orte tab
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(t('planner.minTwoPlaces'))
return
}
setIsCalculatingRoute(true)
try {
const result = await calculateRoute(waypoints, 'walking')
if (result) {
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
onRouteCalculated?.(result)
toast.success(t('planner.routeCalculated'))
} else {
toast.error(t('planner.routeCalcFailed'))
}
} catch (err) {
toast.error(t('planner.routeError'))
} 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(t('planner.routeOptimized'))
}
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(t('planner.noGeoPlaces'))
}
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(t('planner.reservationUpdated'))
} else {
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
toast.success(t('planner.reservationAdded'))
}
setShowReservationModal(false)
} catch (err) {
toast.error(err.message)
}
}
const handleDeleteReservation = async (id) => {
if (!confirm(t('planner.confirmDeleteReservation'))) return
try {
await tripStore.deleteReservation(tripId, id)
toast.success(t('planner.reservationDeleted'))
} 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={t('planner.searchPlaces')}
className="w-full pl-8 pr-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
/>
{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="">{t('planner.allCategories')}</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" />
{t('planner.addPlace')}
</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">{t('planner.noPlacesFound')}</p>
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm hover:underline">
{t('planner.addFirstPlace')}
</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"
>
{t('planner.addToDay')}
</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}{place.end_time ? ` ${place.end_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">{t('planner.selectDayHint')}</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 === 1 ? t('planner.placeOne') : t('planner.placeN', { n: dayAssignments.length })}
{dayAssignments.length > 0 && ` · ${dayAssignments.reduce((s, a) => s + (a.place?.duration_minutes || 60), 0)} ${t('planner.minTotal')}`}
</p>
</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">{t('planner.noPlacesForDay')}</p>
<button
onClick={() => setActiveTab('orte')}
className="mt-3 text-slate-700 text-sm hover:underline"
>
{t('planner.addPlacesLink')}
</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 ? t('planner.calculating') : t('planner.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"
>
<RotateCcw className="w-3.5 h-3.5" />
{t('planner.optimize')}
</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" />
{t('planner.openGoogleMaps')}
</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">
{t('planner.reservations')}
{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" />
{t('common.add')}
</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">{t('planner.noReservations')}</p>
<button onClick={handleAddReservation} className="mt-3 text-slate-700 text-sm hover:underline">
{t('planner.addFirstReservation')}
</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,16 +1,28 @@
import React, { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef } from 'react'
import Modal from '../shared/Modal'
import { Calendar, Camera, X, Clipboard } from 'lucide-react'
import { tripsApi } from '../../api/client'
import { Calendar, Camera, X, Clipboard, UserPlus } from 'lucide-react'
import { tripsApi, authApi } from '../../api/client'
import CustomSelect from '../shared/CustomSelect'
import { useAuthStore } from '../../store/authStore'
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()
const { t } = useTranslation()
const currentUser = useAuthStore(s => s.user)
const [formData, setFormData] = useState({
title: '',
@@ -23,6 +35,9 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const [coverPreview, setCoverPreview] = useState(null)
const [pendingCoverFile, setPendingCoverFile] = useState(null)
const [uploadingCover, setUploadingCover] = useState(false)
const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([])
const [selectedMembers, setSelectedMembers] = useState<number[]>([])
const [memberSelectValue, setMemberSelectValue] = useState('')
useEffect(() => {
if (trip) {
@@ -38,7 +53,11 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
setCoverPreview(null)
}
setPendingCoverFile(null)
setSelectedMembers([])
setError('')
if (!trip) {
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
}
}, [trip, isOpen])
const handleSubmit = async (e) => {
@@ -56,6 +75,15 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
start_date: formData.start_date || null,
end_date: formData.end_date || null,
})
// Add selected members for newly created trips
if (selectedMembers.length > 0 && result?.trip?.id) {
for (const userId of selectedMembers) {
const user = allUsers.find(u => u.id === userId)
if (user) {
try { await tripsApi.addMember(result.trip.id, user.username) } catch {}
}
}
}
// Upload pending cover for newly created trips
if (pendingCoverFile && result?.trip?.id) {
try {
@@ -68,8 +96,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
}
}
onClose()
} catch (err) {
setError(err.message || t('places.saveError'))
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('places.saveError'))
} finally {
setIsLoading(false)
}
@@ -88,7 +116,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
}
const handleCoverChange = (e) => {
handleCoverSelect(e.target.files?.[0])
handleCoverSelect((e.target as HTMLInputElement).files?.[0])
e.target.value = ''
}
@@ -128,7 +156,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const handlePaste = (e) => {
const items = e.clipboardData?.items
if (!items) return
for (const item of items) {
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
e.preventDefault()
const file = item.getAsFile()
@@ -203,7 +231,10 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
</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' }}
onDragOver={e => { e.preventDefault(); e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.background = 'rgba(99,102,241,0.04)' }}
onDragLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.background = 'none' }}
onDrop={e => { e.preventDefault(); e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.background = 'none'; const file = e.dataTransfer.files?.[0]; if (file?.type.startsWith('image/')) handleCoverSelect(file) }}
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', transition: 'all 0.15s' }}
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')}
@@ -241,6 +272,46 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
</div>
</div>
{/* Members — only for new trips */}
{!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
<UserPlus className="inline w-4 h-4 mr-1" />{t('dashboard.addMembers')}
</label>
{selectedMembers.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{selectedMembers.map(uid => {
const user = allUsers.find(u => u.id === uid)
if (!user) return null
return (
<span key={uid} onClick={() => setSelectedMembers(prev => prev.filter(id => id !== uid))}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 99,
background: 'var(--bg-secondary)', fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', cursor: 'pointer',
border: '1px solid var(--border-primary)',
}}>
{user.username}
<X size={11} style={{ color: 'var(--text-faint)' }} />
</span>
)
})}
</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<CustomSelect
value={memberSelectValue}
onChange={value => {
if (value) { setSelectedMembers(prev => prev.includes(Number(value)) ? prev : [...prev, Number(value)]); setMemberSelectValue('') }
}}
placeholder={t('dashboard.addMember')}
options={allUsers.filter(u => u.id !== currentUser?.id && !selectedMembers.includes(u.id)).map(u => ({ value: u.id, label: u.username }))}
searchable
size="sm"
/>
</div>
</div>
)}
{!formData.start_date && !formData.end_date && (
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
{t('dashboard.noDateHint')}
@@ -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 { tripsApi, authApi, shareApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useAuthStore } from '../../store/authStore'
import { Crown, UserMinus, UserPlus, Users, LogOut } from 'lucide-react'
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check } 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,137 @@ function Avatar({ username, avatarUrl, size = 32 }) {
)
}
export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }) {
function ShareLinkSection({ tripId, t }: { tripId: number; t: any }) {
const [shareToken, setShareToken] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false)
const [perms, setPerms] = useState({ share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false })
const toast = useToast()
useEffect(() => {
shareApi.getLink(tripId).then(d => {
setShareToken(d.token)
if (d.token) setPerms({ share_map: d.share_map ?? true, share_bookings: d.share_bookings ?? true, share_packing: d.share_packing ?? false, share_budget: d.share_budget ?? false, share_collab: d.share_collab ?? false })
setLoading(false)
}).catch(() => setLoading(false))
}, [tripId])
const shareUrl = shareToken ? `${window.location.origin}/shared/${shareToken}` : null
const handleCreate = async () => {
try {
const d = await shareApi.createLink(tripId, perms)
setShareToken(d.token)
} catch { toast.error(t('share.createError')) }
}
const handleUpdatePerms = async (key: string, val: boolean) => {
const newPerms = { ...perms, [key]: val }
setPerms(newPerms)
if (shareToken) {
try { await shareApi.createLink(tripId, newPerms) } catch {}
}
}
const handleDelete = async () => {
try {
await shareApi.deleteLink(tripId)
setShareToken(null)
} catch {}
}
const handleCopy = () => {
if (shareUrl) {
navigator.clipboard.writeText(shareUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
if (loading) return null
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
<Link2 size={14} style={{ color: 'var(--text-muted)' }} />
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('share.linkTitle')}</span>
</div>
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 10, lineHeight: 1.5 }}>{t('share.linkHint')}</p>
{/* Permission checkboxes */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
{[
{ key: 'share_map', label: t('share.permMap'), always: true },
{ key: 'share_bookings', label: t('share.permBookings') },
{ key: 'share_packing', label: t('share.permPacking') },
{ key: 'share_budget', label: t('share.permBudget') },
{ key: 'share_collab', label: t('share.permCollab') },
].map(opt => (
<button key={opt.key} onClick={() => !opt.always && handleUpdatePerms(opt.key, !perms[opt.key])}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 20,
border: '1.5px solid', fontSize: 11, fontWeight: 500, cursor: opt.always ? 'default' : 'pointer',
fontFamily: 'inherit', transition: 'all 0.12s',
background: perms[opt.key] ? 'var(--text-primary)' : 'transparent',
borderColor: perms[opt.key] ? 'var(--text-primary)' : 'var(--border-primary)',
color: perms[opt.key] ? 'var(--bg-primary)' : 'var(--text-muted)',
opacity: opt.always ? 0.7 : 1,
}}>
{perms[opt.key] ? <Check size={10} /> : null}
{opt.label}
</button>
))}
</div>
{shareUrl ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px',
background: 'var(--bg-tertiary)', borderRadius: 8, border: '1px solid var(--border-faint)',
}}>
<input type="text" value={shareUrl} readOnly style={{
flex: 1, border: 'none', background: 'none', fontSize: 11, color: 'var(--text-primary)',
outline: 'none', fontFamily: 'monospace',
}} />
<button onClick={handleCopy} style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 6,
border: 'none', background: copied ? '#16a34a' : 'var(--accent)', color: copied ? 'white' : 'var(--accent-text)',
fontSize: 10, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', transition: 'background 0.2s',
}}>
{copied ? <><Check size={10} /> {t('common.copied')}</> : <><Copy size={10} /> {t('common.copy')}</>}
</button>
</div>
<button onClick={handleDelete} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', borderRadius: 8, border: '1px solid rgba(239,68,68,0.3)',
background: 'rgba(239,68,68,0.06)', color: '#ef4444', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}>
<Trash2 size={11} /> {t('share.deleteLink')}
</button>
</div>
) : (
<button onClick={handleCreate} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
width: '100%', padding: '8px 0', borderRadius: 8, border: '1px dashed var(--border-primary)',
background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}>
<Link2 size={12} /> {t('share.createLink')}
</button>
)}
</div>
)
}
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 +208,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)
}
@@ -109,8 +246,12 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle })
] : []
return (
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="sm">
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} className="share-modal-grid">
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
{/* Left column: Members */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Trip name */}
<div style={{ padding: '10px 14px', background: 'var(--bg-secondary)', borderRadius: 10, border: '1px solid var(--border-secondary)' }}>
@@ -214,6 +355,13 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle })
)}
</div>
</div>
{/* Right column: Share Link */}
<div style={{ borderLeft: '1px solid var(--border-faint)', paddingLeft: 24 }}>
<ShareLinkSection tripId={tripId} t={t} />
</div>
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
</div>
</Modal>
@@ -1,4 +1,4 @@
import React, { useMemo, useState, useCallback } from 'react'
import { useMemo, useState, useCallback } from 'react'
import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n'
import { isWeekend } from './holidays'
@@ -26,6 +26,7 @@ export default function VacayCalendar() {
}, [entries])
const blockWeekends = plan?.block_weekends !== false
const weekendDays: number[] = plan?.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]
const companyHolidaysEnabled = plan?.company_holidays_enabled !== false
const handleCellClick = useCallback(async (dateStr) => {
@@ -35,7 +36,7 @@ export default function VacayCalendar() {
return
}
if (holidays[dateStr]) return
if (blockWeekends && isWeekend(dateStr)) return
if (blockWeekends && isWeekend(dateStr, weekendDays)) return
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
await toggleEntry(dateStr, selectedUserId || undefined)
}, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId])
@@ -57,6 +58,7 @@ export default function VacayCalendar() {
onCellClick={handleCellClick}
companyMode={companyMode}
blockWeekends={blockWeekends}
weekendDays={weekendDays}
/>
))}
</div>
@@ -1,20 +1,39 @@
import React, { useMemo } from 'react'
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']
const WEEKDAY_KEYS = ['vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat', 'vacay.sun'] as const
function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r},${g},${b},${alpha})`
}
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
weekendDays?: number[]
}
export default function VacayMonthCard({
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
onCellClick, companyMode, blockWeekends
}) {
const { language } = useTranslation()
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN
const monthNames = language === 'de' ? MONTHS_DE : MONTHS_EN
onCellClick, companyMode, blockWeekends, weekendDays = [0, 6]
}: VacayMonthCardProps) {
const { t, locale } = useTranslation()
const weekdays = WEEKDAY_KEYS.map(k => t(k))
const monthName = useMemo(() => new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(year, month, 1)), [locale, year, month])
const weeks = useMemo(() => {
const firstDay = new Date(year, month, 1)
const daysInMonth = new Date(year, month + 1, 0).getDate()
@@ -34,7 +53,7 @@ export default function VacayMonthCard({
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>
<span className="text-xs font-semibold" style={{ color: 'var(--text-primary)', textTransform: 'capitalize' }}>{monthName}</span>
</div>
<div className="grid grid-cols-7 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
@@ -52,7 +71,8 @@ export default function VacayMonthCard({
if (day === null) return <div key={di} style={{ height: 28 }} />
const dateStr = `${year}-${pad(month + 1)}-${pad(day)}`
const weekend = di >= 5
const dayOfWeek = new Date(year, month, day).getDay()
const weekend = weekendDays.includes(dayOfWeek)
const holiday = holidays[dateStr]
const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr)
const dayEntries = entryMap[dateStr] || []
@@ -73,7 +93,7 @@ export default function VacayMonthCard({
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)' }} />}
{holiday && <div className="absolute inset-0.5 rounded" style={{ background: hexToRgba(holiday.color, 0.12) }} />}
{isCompany && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(245,158,11,0.15)' }} />}
{dayEntries.length === 1 && (
@@ -102,7 +122,7 @@ export default function VacayMonthCard({
)}
<span className="relative z-[1] text-[11px] font-medium" style={{
color: holiday ? '#dc2626' : weekend ? 'var(--text-faint)' : 'var(--text-primary)',
color: holiday ? holiday.color : weekend ? 'var(--text-faint)' : 'var(--text-primary)',
fontWeight: dayEntries.length > 0 ? 700 : 500,
}}>
{day}
@@ -1,9 +1,11 @@
import React, { useState, useEffect } from 'react'
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'
@@ -46,8 +48,8 @@ export default function VacayPersons() {
toast.success(t('vacay.inviteSent'))
setShowInvite(false)
setSelectedInviteUser(null)
} catch (err) {
toast.error(err.response?.data?.error || t('vacay.inviteError'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('vacay.inviteError')))
} finally {
setInviting(false)
}
@@ -1,213 +0,0 @@
import React, { useState, useEffect } from 'react'
import { MapPin, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import apiClient from '../../api/client'
export default function VacaySettings({ onClose }) {
const { t } = useTranslation()
const toast = useToast()
const { plan, updatePlan, isFused, dissolve, users } = useVacayStore()
const [countries, setCountries] = useState([])
const [regions, setRegions] = useState([])
const [loadingRegions, setLoadingRegions] = useState(false)
const { language } = useTranslation()
// Load available countries with localized names
useEffect(() => {
apiClient.get('/addons/vacay/holidays/countries').then(r => {
let displayNames
try { displayNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) } catch { /* */ }
const list = r.data.map(c => ({
value: c.countryCode,
label: displayNames ? (displayNames.of(c.countryCode) || c.name) : c.name,
}))
list.sort((a, b) => a.label.localeCompare(b.label))
setCountries(list)
}).catch(() => {})
}, [language])
// When country changes, check if it has regions
const selectedCountry = plan?.holidays_region?.split('-')[0] || ''
const selectedRegion = plan?.holidays_region?.includes('-') ? plan.holidays_region : ''
useEffect(() => {
if (!selectedCountry || !plan?.holidays_enabled) { setRegions([]); return }
setLoadingRegions(true)
const year = new Date().getFullYear()
apiClient.get(`/addons/vacay/holidays/${year}/${selectedCountry}`).then(r => {
const allCounties = new Set()
r.data.forEach(h => {
if (h.counties) h.counties.forEach(c => allCounties.add(c))
})
if (allCounties.size > 0) {
let subdivisionNames
try { subdivisionNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) } catch { /* */ }
const regionList = [...allCounties].sort().map(c => {
let label = c.split('-')[1] || c
// Try Intl for full subdivision name (not all browsers support subdivision codes)
// Fallback: use known mappings for DE
if (c.startsWith('DE-')) {
const deRegions = { BW:'Baden-Württemberg',BY:'Bayern',BE:'Berlin',BB:'Brandenburg',HB:'Bremen',HH:'Hamburg',HE:'Hessen',MV:'Mecklenburg-Vorpommern',NI:'Niedersachsen',NW:'Nordrhein-Westfalen',RP:'Rheinland-Pfalz',SL:'Saarland',SN:'Sachsen',ST:'Sachsen-Anhalt',SH:'Schleswig-Holstein',TH:'Thüringen' }
label = deRegions[c.split('-')[1]] || label
} else if (c.startsWith('CH-')) {
const chRegions = { AG:'Aargau',AI:'Appenzell Innerrhoden',AR:'Appenzell Ausserrhoden',BE:'Bern',BL:'Basel-Landschaft',BS:'Basel-Stadt',FR:'Freiburg',GE:'Genf',GL:'Glarus',GR:'Graubünden',JU:'Jura',LU:'Luzern',NE:'Neuenburg',NW:'Nidwalden',OW:'Obwalden',SG:'St. Gallen',SH:'Schaffhausen',SO:'Solothurn',SZ:'Schwyz',TG:'Thurgau',TI:'Tessin',UR:'Uri',VD:'Waadt',VS:'Wallis',ZG:'Zug',ZH:'Zürich' }
label = chRegions[c.split('-')[1]] || label
}
return { value: c, label }
})
setRegions(regionList)
} else {
setRegions([])
// If no regions, just set country code as region
if (plan.holidays_region !== selectedCountry) {
updatePlan({ holidays_region: selectedCountry })
}
}
}).catch(() => setRegions([])).finally(() => setLoadingRegions(false))
}, [selectedCountry, plan?.holidays_enabled])
if (!plan) return null
const toggle = (key) => updatePlan({ [key]: !plan[key] })
const handleCountryChange = (countryCode) => {
updatePlan({ holidays_region: countryCode })
}
const handleRegionChange = (regionCode) => {
updatePlan({ holidays_region: regionCode })
}
return (
<div className="space-y-5">
{/* Block weekends */}
<SettingToggle
icon={CalendarOff}
label={t('vacay.blockWeekends')}
hint={t('vacay.blockWeekendsHint')}
value={plan.block_weekends}
onChange={() => toggle('block_weekends')}
/>
{/* Carry-over */}
<SettingToggle
icon={ArrowRightLeft}
label={t('vacay.carryOver')}
hint={t('vacay.carryOverHint')}
value={plan.carry_over_enabled}
onChange={() => toggle('carry_over_enabled')}
/>
{/* Company holidays */}
<div>
<SettingToggle
icon={Building2}
label={t('vacay.companyHolidays')}
hint={t('vacay.companyHolidaysHint')}
value={plan.company_holidays_enabled}
onChange={() => toggle('company_holidays_enabled')}
/>
{plan.company_holidays_enabled && (
<div className="ml-7 mt-2">
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md" style={{ background: 'var(--bg-secondary)' }}>
<AlertCircle size={12} style={{ color: 'var(--text-faint)' }} />
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{t('vacay.companyHolidaysNoDeduct')}</span>
</div>
</div>
)}
</div>
{/* Public holidays */}
<div>
<SettingToggle
icon={Globe}
label={t('vacay.publicHolidays')}
hint={t('vacay.publicHolidaysHint')}
value={plan.holidays_enabled}
onChange={() => toggle('holidays_enabled')}
/>
{plan.holidays_enabled && (
<div className="ml-7 mt-2 space-y-2">
<CustomSelect
value={selectedCountry}
onChange={handleCountryChange}
options={countries}
placeholder={t('vacay.selectCountry')}
searchable
/>
{regions.length > 0 && (
<CustomSelect
value={selectedRegion}
onChange={handleRegionChange}
options={regions}
placeholder={t('vacay.selectRegion')}
searchable
/>
)}
</div>
)}
</div>
{/* Dissolve fusion */}
{isFused && (
<div className="pt-4 mt-2 border-t" style={{ borderColor: 'var(--border-secondary)' }}>
<div className="rounded-xl overflow-hidden" style={{ border: '1px solid rgba(239,68,68,0.2)' }}>
<div className="px-4 py-3 flex items-center gap-3" style={{ background: 'rgba(239,68,68,0.06)' }}>
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ background: 'rgba(239,68,68,0.1)' }}>
<Unlink size={16} className="text-red-500" />
</div>
<div>
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.dissolve')}</p>
<p className="text-[11px]" style={{ color: 'var(--text-faint)' }}>{t('vacay.dissolveHint')}</p>
</div>
</div>
<div className="px-4 py-3 flex items-center gap-2 flex-wrap" style={{ borderTop: '1px solid rgba(239,68,68,0.1)' }}>
{users.map(u => (
<div key={u.id} className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ background: 'var(--bg-secondary)' }}>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: u.color || '#6366f1' }} />
<span className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>{u.username}</span>
</div>
))}
</div>
<div className="px-4 py-3" style={{ borderTop: '1px solid rgba(239,68,68,0.1)' }}>
<button
onClick={async () => {
await dissolve()
toast.success(t('vacay.dissolved'))
onClose()
}}
className="w-full px-3 py-2 text-xs font-medium bg-red-500 hover:bg-red-600 text-white rounded-lg transition-colors"
>
{t('vacay.dissolveAction')}
</button>
</div>
</div>
</div>
)}
</div>
)
}
function SettingToggle({ icon: Icon, label, hint, value, onChange }) {
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<Icon size={15} className="shrink-0" style={{ color: 'var(--text-muted)' }} />
<div className="min-w-0">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
<p className="text-[11px]" style={{ color: 'var(--text-faint)' }}>{hint}</p>
</div>
</div>
<button onClick={onChange}
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: value ? 'var(--text-primary)' : 'var(--border-primary)' }}>
<span className="absolute left-1 h-4 w-4 rounded-full transition-transform duration-200"
style={{ background: 'var(--bg-card)', transform: value ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
)
}
@@ -0,0 +1,426 @@
import { useState, useEffect } from 'react'
import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2 } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { getIntlLanguage, useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import apiClient from '../../api/client'
import type { VacayHolidayCalendar } from '../../types'
interface VacaySettingsProps {
onClose: () => void
}
export default function VacaySettings({ onClose }: VacaySettingsProps) {
const { t } = useTranslation()
const toast = useToast()
const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, dissolve, users } = useVacayStore()
const [countries, setCountries] = useState<{ value: string; label: string }[]>([])
const [showAddForm, setShowAddForm] = 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([getIntlLanguage(language)], { 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])
if (!plan) return null
const toggle = (key: string) => updatePlan({ [key]: !plan[key] })
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')}
/>
{/* Weekend days selector */}
{plan.block_weekends !== false && (
<div style={{ paddingLeft: 36 }}>
<p className="text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>{t('vacay.weekendDays')}</p>
<div className="flex flex-wrap gap-1.5">
{[
{ day: 1, label: t('vacay.mon') },
{ day: 2, label: t('vacay.tue') },
{ day: 3, label: t('vacay.wed') },
{ day: 4, label: t('vacay.thu') },
{ day: 5, label: t('vacay.fri') },
{ day: 6, label: t('vacay.sat') },
{ day: 0, label: t('vacay.sun') },
].map(({ day, label }) => {
const current: number[] = plan.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]
const active = current.includes(day)
return (
<button key={day} onClick={() => {
const next = active ? current.filter(d => d !== day) : [...current, day]
updatePlan({ weekend_days: next.join(',') })
}}
style={{
padding: '4px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer',
fontFamily: 'inherit', border: '1px solid', transition: 'all 0.12s',
background: active ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: active ? 'var(--text-primary)' : 'var(--border-primary)',
color: active ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
{label}
</button>
)
})}
</div>
</div>
)}
{/* 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">
{(plan.holiday_calendars ?? []).length === 0 && (
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('vacay.noCalendars')}</p>
)}
{(plan.holiday_calendars ?? []).map(cal => (
<CalendarRow
key={cal.id}
cal={cal}
countries={countries}
language={language}
onUpdate={(data) => updateHolidayCalendar(cal.id, data)}
onDelete={() => deleteHolidayCalendar(cal.id)}
/>
))}
{showAddForm ? (
<AddCalendarForm
countries={countries}
language={language}
onAdd={async (data) => { await addHolidayCalendar(data); setShowAddForm(false) }}
onCancel={() => setShowAddForm(false)}
/>
) : (
<button
onClick={() => setShowAddForm(true)}
className="flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md transition-colors"
style={{ color: 'var(--text-muted)', background: 'var(--bg-secondary)' }}
>
<Plus size={12} />
{t('vacay.addCalendar')}
</button>
)}
</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: LucideIcon
label: string
hint: string
value: boolean
onChange: () => 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>
)
}
// ── shared region-loading helper ─────────────────────────────────────────────
async function fetchRegionOptions(country: string): Promise<{ value: string; label: string }[]> {
try {
const year = new Date().getFullYear()
const r = await apiClient.get(`/addons/vacay/holidays/${year}/${country}`)
const allCounties = new Set<string>()
r.data.forEach(h => { if (h.counties) h.counties.forEach(c => allCounties.add(c)) })
if (allCounties.size === 0) return []
return [...allCounties].sort().map(c => {
let label = c.split('-')[1] || c
if (c.startsWith('DE-')) {
const m: 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' }
label = m[c.split('-')[1]] || label
} else if (c.startsWith('CH-')) {
const m: Record<string, string> = { 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 = m[c.split('-')[1]] || label
}
return { value: c, label }
})
} catch {
return []
}
}
// ── Existing calendar row (inline edit) ──────────────────────────────────────
function CalendarRow({ cal, countries, onUpdate, onDelete }: {
cal: VacayHolidayCalendar
countries: { value: string; label: string }[]
language: string
onUpdate: (data: { region?: string; color?: string; label?: string | null }) => void
onDelete: () => void
}) {
const { t } = useTranslation()
const [localColor, setLocalColor] = useState(cal.color)
const [localLabel, setLocalLabel] = useState(cal.label || '')
const [regions, setRegions] = useState<{ value: string; label: string }[]>([])
const selectedCountry = cal.region.split('-')[0]
const selectedRegion = cal.region.includes('-') ? cal.region : ''
useEffect(() => { setLocalColor(cal.color) }, [cal.color])
useEffect(() => { setLocalLabel(cal.label || '') }, [cal.label])
useEffect(() => {
if (!selectedCountry) { setRegions([]); return }
fetchRegionOptions(selectedCountry).then(setRegions)
}, [selectedCountry])
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
const [showColorPicker, setShowColorPicker] = useState(false)
return (
<div className="flex gap-3 items-start p-3 rounded-xl" style={{ background: 'var(--bg-secondary)' }}>
<div style={{ position: 'relative', flexShrink: 0 }}>
<button
onClick={() => setShowColorPicker(!showColorPicker)}
style={{ width: 28, height: 28, borderRadius: 8, background: localColor, border: '2px solid var(--border-primary)', cursor: 'pointer' }}
title={t('vacay.calendarColor')}
/>
{showColorPicker && (
<div style={{ position: 'absolute', top: 34, left: 0, zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12, padding: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.12)', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 4, width: 120 }}>
{PRESET_COLORS.map(c => (
<button key={c} onClick={() => { setLocalColor(c); setShowColorPicker(false); if (c !== cal.color) onUpdate({ color: c }) }}
style={{ width: 24, height: 24, borderRadius: 6, background: c, border: localColor === c ? '2px solid var(--text-primary)' : '2px solid transparent', cursor: 'pointer' }} />
))}
</div>
)}
</div>
<div className="flex-1 min-w-0 space-y-1.5">
<input
type="text"
value={localLabel}
onChange={e => setLocalLabel(e.target.value)}
onBlur={() => { const v = localLabel.trim() || null; if (v !== cal.label) onUpdate({ label: v }) }}
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
placeholder={t('vacay.calendarLabel')}
style={{ width: '100%', fontSize: 12, padding: '6px 10px', borderRadius: 8, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
/>
<CustomSelect
value={selectedCountry}
onChange={v => onUpdate({ region: v })}
options={countries}
placeholder={t('vacay.selectCountry')}
searchable
/>
{regions.length > 0 && (
<CustomSelect
value={selectedRegion}
onChange={v => onUpdate({ region: v })}
options={regions}
placeholder={t('vacay.selectRegion')}
searchable
/>
)}
</div>
<button
onClick={onDelete}
className="shrink-0 p-1.5 rounded-md transition-colors"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'rgba(239,68,68,0.1)' }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<Trash2 size={13} />
</button>
</div>
)
}
// ── Add-new-calendar form ─────────────────────────────────────────────────────
function AddCalendarForm({ countries, onAdd, onCancel }: {
countries: { value: string; label: string }[]
language: string
onAdd: (data: { region: string; color: string; label: string | null }) => void
onCancel: () => void
}) {
const { t } = useTranslation()
const [region, setRegion] = useState('')
const [color, setColor] = useState('#fecaca')
const [label, setLabel] = useState('')
const [regions, setRegions] = useState<{ value: string; label: string }[]>([])
const [loadingRegions, setLoadingRegions] = useState(false)
const selectedCountry = region.split('-')[0] || ''
const selectedRegion = region.includes('-') ? region : ''
useEffect(() => {
if (!selectedCountry) { setRegions([]); return }
setLoadingRegions(true)
fetchRegionOptions(selectedCountry).then(list => { setRegions(list) }).finally(() => setLoadingRegions(false))
}, [selectedCountry])
const canAdd = selectedCountry && (regions.length === 0 || selectedRegion !== '')
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
const [showColorPicker, setShowColorPicker] = useState(false)
return (
<div className="flex gap-3 items-start p-3 rounded-xl border border-dashed" style={{ borderColor: 'var(--border-primary)' }}>
<div style={{ position: 'relative', flexShrink: 0 }}>
<button
onClick={() => setShowColorPicker(!showColorPicker)}
style={{ width: 28, height: 28, borderRadius: 8, background: color, border: '2px solid var(--border-primary)', cursor: 'pointer' }}
title={t('vacay.calendarColor')}
/>
{showColorPicker && (
<div style={{ position: 'absolute', top: 34, left: 0, zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12, padding: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.12)', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 4, width: 120 }}>
{PRESET_COLORS.map(c => (
<button key={c} onClick={() => { setColor(c); setShowColorPicker(false) }}
style={{ width: 24, height: 24, borderRadius: 6, background: c, border: color === c ? '2px solid var(--text-primary)' : '2px solid transparent', cursor: 'pointer' }} />
))}
</div>
)}
</div>
<div className="flex-1 min-w-0 space-y-1.5">
<input
type="text"
value={label}
onChange={e => setLabel(e.target.value)}
placeholder={t('vacay.calendarLabel')}
style={{ width: '100%', fontSize: 12, padding: '6px 10px', borderRadius: 8, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
/>
<CustomSelect
value={selectedCountry}
onChange={v => { setRegion(v); setRegions([]) }}
options={countries}
placeholder={t('vacay.selectCountry')}
searchable
/>
{regions.length > 0 && (
<CustomSelect
value={selectedRegion}
onChange={v => setRegion(v)}
options={regions}
placeholder={t('vacay.selectRegion')}
searchable
/>
)}
<div className="flex gap-1.5 pt-0.5">
<button
disabled={!canAdd}
onClick={() => onAdd({ region: region || selectedCountry, color, label: label.trim() || null })}
className="flex-1 text-xs px-2 py-1.5 rounded-md font-medium transition-colors disabled:opacity-40"
style={{ background: 'var(--text-primary)', color: 'var(--bg-card)' }}
>
{t('vacay.add')}
</button>
<button
onClick={onCancel}
className="text-xs px-2 py-1.5 rounded-md transition-colors"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
>
</button>
</div>
</div>
</div>
)
}
@@ -1,8 +1,16 @@
import React, { useEffect, useState } from 'react'
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()
@@ -41,7 +49,16 @@ export default function VacayStats() {
)
}
function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }) {
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
-146
View File
@@ -1,146 +0,0 @@
// German public holidays (Feiertage) calculation per Bundesland
// Includes fixed and Easter-dependent movable holidays
const BUNDESLAENDER = {
BW: 'Baden-Württemberg',
BY: 'Bayern',
BE: 'Berlin',
BB: 'Brandenburg',
HB: 'Bremen',
HH: 'Hamburg',
HE: 'Hessen',
MV: 'Mecklenburg-Vorpommern',
NI: 'Niedersachsen',
NW: 'Nordrhein-Westfalen',
RP: 'Rheinland-Pfalz',
SL: 'Saarland',
SN: 'Sachsen',
ST: 'Sachsen-Anhalt',
SH: 'Schleswig-Holstein',
TH: 'Thüringen',
};
// Gauss Easter algorithm
function easterSunday(year) {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return new Date(year, month - 1, day);
}
function addDays(date, days) {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
}
function fmt(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
export function getHolidays(year, bundesland = 'NW') {
const easter = easterSunday(year);
const holidays = {};
// Fixed holidays (nationwide)
holidays[`${year}-01-01`] = 'Neujahr';
holidays[`${year}-05-01`] = 'Tag der Arbeit';
holidays[`${year}-10-03`] = 'Tag der Deutschen Einheit';
holidays[`${year}-12-25`] = '1. Weihnachtsfeiertag';
holidays[`${year}-12-26`] = '2. Weihnachtsfeiertag';
// Easter-dependent (nationwide)
holidays[fmt(addDays(easter, -2))] = 'Karfreitag';
holidays[fmt(addDays(easter, 1))] = 'Ostermontag';
holidays[fmt(addDays(easter, 39))] = 'Christi Himmelfahrt';
holidays[fmt(addDays(easter, 50))] = 'Pfingstmontag';
// State-specific
const bl = bundesland.toUpperCase();
// Heilige Drei Könige (6. Jan) — BW, BY, ST
if (['BW', 'BY', 'ST'].includes(bl)) {
holidays[`${year}-01-06`] = 'Heilige Drei Könige';
}
// Internationaler Frauentag (8. März) — BE, MV
if (['BE', 'MV'].includes(bl)) {
holidays[`${year}-03-08`] = 'Internationaler Frauentag';
}
// Fronleichnam — BW, BY, HE, NW, RP, SL, SN (teilweise), TH (teilweise)
if (['BW', 'BY', 'HE', 'NW', 'RP', 'SL'].includes(bl)) {
holidays[fmt(addDays(easter, 60))] = 'Fronleichnam';
}
// Mariä Himmelfahrt (15. Aug) — SL, BY (teilweise)
if (['SL'].includes(bl)) {
holidays[`${year}-08-15`] = 'Mariä Himmelfahrt';
}
// Weltkindertag (20. Sep) — TH
if (bl === 'TH') {
holidays[`${year}-09-20`] = 'Weltkindertag';
}
// Reformationstag (31. Okt) — BB, HB, HH, MV, NI, SN, ST, SH, TH
if (['BB', 'HB', 'HH', 'MV', 'NI', 'SN', 'ST', 'SH', 'TH'].includes(bl)) {
holidays[`${year}-10-31`] = 'Reformationstag';
}
// Allerheiligen (1. Nov) — BW, BY, NW, RP, SL
if (['BW', 'BY', 'NW', 'RP', 'SL'].includes(bl)) {
holidays[`${year}-11-01`] = 'Allerheiligen';
}
// Buß- und Bettag — SN (Mittwoch vor dem 23. November)
if (bl === 'SN') {
const nov23 = new Date(year, 10, 23);
let bbt = new Date(nov23);
while (bbt.getDay() !== 3) bbt.setDate(bbt.getDate() - 1);
holidays[fmt(bbt)] = 'Buß- und Bettag';
}
return holidays;
}
export function isWeekend(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
const day = d.getDay();
return day === 0 || day === 6;
}
export function getWeekday(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getDay()];
}
export function getWeekdayFull(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getDay()];
}
export function daysInMonth(year, month) {
return new Date(year, month, 0).getDate();
}
export function formatDate(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' });
}
export { BUNDESLAENDER };
+130
View File
@@ -0,0 +1,130 @@
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, weekendDays: number[] = [0, 6]): boolean {
const d = new Date(dateStr + 'T00:00:00')
return weekendDays.includes(d.getDay())
}
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, locale?: string): string {
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString(locale || undefined, { 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)
@@ -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>
)
}
@@ -1,10 +1,25 @@
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(null) // { x, y, items }
const [menu, setMenu] = useState<MenuState | null>(null)
const open = (e, items) => {
const open = (e: React.MouseEvent, items: MenuItem[]) => {
e.preventDefault()
e.stopPropagation()
setMenu({ x: e.clientX, y: e.clientY, items })
@@ -15,8 +30,13 @@ export function useContextMenu() {
return { menu, open, close }
}
export function ContextMenu({ menu, onClose }) {
const ref = useRef(null)
interface ContextMenuProps {
menu: MenuState | null
onClose: () => void
}
export function ContextMenu({ menu, onClose }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!menu) return
@@ -29,7 +49,6 @@ export function ContextMenu({ menu, onClose }) {
}
}, [menu, onClose])
// Adjust position if menu would overflow viewport
useEffect(() => {
if (!menu || !ref.current) return
const el = ref.current
@@ -60,7 +79,7 @@ export function ContextMenu({ menu, onClose }) {
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={{
<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',
@@ -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,11 +57,45 @@ 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
const [textInput, setTextInput] = useState('')
const [isTyping, setIsTyping] = useState(false)
const handleTextSubmit = () => {
setIsTyping(false)
if (!textInput.trim()) return
// Try to parse various date formats
const input = textInput.trim()
// ISO: 2026-03-29
if (/^\d{4}-\d{2}-\d{2}$/.test(input)) { onChange(input); return }
// EU: 29.03.2026 or 29/03/2026
const euMatch = input.match(/^(\d{1,2})[./](\d{1,2})[./](\d{2,4})$/)
if (euMatch) {
const y = euMatch[3].length === 2 ? 2000 + parseInt(euMatch[3]) : parseInt(euMatch[3])
onChange(`${y}-${String(euMatch[2]).padStart(2, '0')}-${String(euMatch[1]).padStart(2, '0')}`)
return
}
// Try native Date parse as fallback
const d = new Date(input)
if (!isNaN(d.getTime())) {
onChange(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
}
}
return (
<div ref={ref} style={{ position: 'relative', ...style }}>
<button type="button" onClick={() => setOpen(o => !o)}
{isTyping ? (
<input autoFocus type="text" value={textInput} onChange={e => setTextInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleTextSubmit(); if (e.key === 'Escape') setIsTyping(false) }}
onBlur={handleTextSubmit}
placeholder="DD.MM.YYYY"
style={{
width: '100%', padding: '8px 14px', borderRadius: 10, border: '1px solid var(--text-faint)',
background: 'var(--bg-input)', color: 'var(--text-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none',
}} />
) : (
<button type="button" onClick={() => setOpen(o => !o)} onDoubleClick={() => { setTextInput(value || ''); setIsTyping(true) }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 14px', borderRadius: 10,
@@ -69,6 +109,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
<Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayValue || placeholder || t('common.date')}</span>
</button>
)}
{open && ReactDOM.createPortal(
<div ref={dropRef} style={{
@@ -81,11 +122,8 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
const vh = window.innerHeight
let left = r.left
let top = r.bottom + 4
// Keep within viewport horizontally
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
// If not enough space below, open above
if (top + 320 > vh) top = Math.max(pad, r.top - 320)
// On very small screens, center horizontally
if (vw < 360) left = Math.max(pad, (vw - w) / 2)
return { top, left }
})(),
@@ -161,18 +199,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 (
@@ -185,5 +228,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'
@@ -67,9 +107,15 @@ export default function CustomSelect({
{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 })(),
width: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.width : 200 })(),
...(() => {
const r = ref.current?.getBoundingClientRect()
if (!r) return { top: 0, left: 0, width: 200 }
const spaceBelow = window.innerHeight - r.bottom
const openUp = spaceBelow < 220 && r.top > spaceBelow
return openUp
? { bottom: window.innerHeight - r.top + 4, left: r.left, width: r.width }
: { top: r.bottom + 4, left: r.left, width: r.width }
})(),
zIndex: 99999,
background: 'var(--bg-card)',
backdropFilter: 'blur(24px) saturate(180%)',
@@ -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))
@@ -139,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)'}
@@ -163,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,12 +1,23 @@
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',
xl: 'max-w-2xl',
'2xl': 'max-w-4xl',
'3xl': 'max-w-5xl',
}
interface ModalProps {
isOpen: boolean
onClose: () => void
title?: React.ReactNode
children?: React.ReactNode
size?: string
footer?: React.ReactNode
hideCloseButton?: boolean
}
export default function Modal({
@@ -17,8 +28,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,7 +44,7 @@ export default function Modal({
}
}, [isOpen, handleEsc])
const mouseDownTarget = useRef(null)
const mouseDownTarget = useRef<EventTarget | null>(null)
if (!isOpen) return null
@@ -1,60 +0,0 @@
import React, { useState, useEffect } from 'react'
import { mapsApi } from '../../api/client'
import { getCategoryIcon } from './categoryIcons'
const googlePhotoCache = new Map()
export default function PlaceAvatar({ place, size = 32, category }) {
const [photoSrc, setPhotoSrc] = useState(place.image_url || null)
useEffect(() => {
if (place.image_url) { setPhotoSrc(place.image_url); return }
if (!place.google_place_id) { setPhotoSrc(null); return }
if (googlePhotoCache.has(place.google_place_id)) {
setPhotoSrc(googlePhotoCache.get(place.google_place_id))
return
}
mapsApi.placePhoto(place.google_place_id)
.then(data => {
if (data.photoUrl) {
googlePhotoCache.set(place.google_place_id, data.photoUrl)
setPhotoSrc(data.photoUrl)
}
})
.catch(() => {})
}, [place.id, place.image_url, place.google_place_id])
const bgColor = category?.color || '#6366f1'
const IconComp = getCategoryIcon(category?.icon)
const iconSize = Math.round(size * 0.46)
const containerStyle = {
width: size, height: size,
borderRadius: '50%',
overflow: 'hidden',
flexShrink: 0,
backgroundColor: bgColor,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}
if (photoSrc) {
return (
<div style={containerStyle}>
<img
src={photoSrc}
alt={place.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => setPhotoSrc(null)}
/>
</div>
)
}
return (
<div style={containerStyle}>
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
</div>
)
}
@@ -0,0 +1,101 @@
import React, { useState, useEffect } from 'react'
import { mapsApi } from '../../api/client'
import { getCategoryIcon } from './categoryIcons'
import type { Place } from '../../types'
interface Category {
color?: string
icon?: string
}
interface PlaceAvatarProps {
place: Pick<Place, 'id' | 'name' | 'image_url' | 'google_place_id' | 'osm_id' | 'lat' | 'lng'>
size?: number
category?: Category | null
}
const photoCache = new Map<string, string | null>()
const photoInFlight = new Set<string>()
// Event-based notification instead of polling intervals
const photoListeners = new Map<string, Set<(url: string | null) => void>>()
function notifyListeners(key: string, url: string | null) {
const listeners = photoListeners.get(key)
if (listeners) {
listeners.forEach(fn => fn(url))
photoListeners.delete(key)
}
}
export default React.memo(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 }
const photoId = place.google_place_id || place.osm_id
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
const cacheKey = photoId || `${place.lat},${place.lng}`
if (photoCache.has(cacheKey)) {
const cached = photoCache.get(cacheKey)
if (cached) setPhotoSrc(cached)
return
}
if (photoInFlight.has(cacheKey)) {
// Subscribe to notification instead of polling
if (!photoListeners.has(cacheKey)) photoListeners.set(cacheKey, new Set())
const handler = (url: string | null) => { if (url) setPhotoSrc(url) }
photoListeners.get(cacheKey)!.add(handler)
return () => { photoListeners.get(cacheKey)?.delete(handler) }
}
photoInFlight.add(cacheKey)
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
.then((data: { photoUrl?: string }) => {
const url = data.photoUrl || null
photoCache.set(cacheKey, url)
if (url) setPhotoSrc(url)
notifyListeners(cacheKey, url)
photoInFlight.delete(cacheKey)
})
.catch(() => {
photoCache.set(cacheKey, null)
notifyListeners(cacheKey, null)
photoInFlight.delete(cacheKey)
})
}, [place.id, place.image_url, place.google_place_id, place.osm_id])
const bgColor = category?.color || '#6366f1'
const IconComp = getCategoryIcon(category?.icon)
const iconSize = Math.round(size * 0.46)
const containerStyle: React.CSSProperties = {
width: size, height: size,
borderRadius: '50%',
overflow: 'hidden',
flexShrink: 0,
backgroundColor: bgColor,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}
if (photoSrc) {
return (
<div style={containerStyle}>
<img
src={photoSrc}
alt={place.name}
loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => setPhotoSrc(null)}
/>
</div>
)
}
return (
<div style={containerStyle}>
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
</div>
)
})
-95
View File
@@ -1,95 +0,0 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
const ToastContext = createContext(null)
let toastIdCounter = 0
export function ToastContainer() {
const [toasts, setToasts] = useState([])
const addToast = useCallback((message, type = 'info', duration = 3000) => {
const id = ++toastIdCounter
setToasts(prev => [...prev, { id, message, type, duration, removing: false }])
if (duration > 0) {
setTimeout(() => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 300)
}, duration)
}
return id
}, [])
const removeToast = useCallback((id) => {
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 = {
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 = {
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',
info: 'bg-white border-l-4 border-blue-500',
}
return (
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm w-full pointer-events-none">
{toasts.map(toast => (
<div
key={toast.id}
className={`
${bgColors[toast.type] || bgColors.info}
${toast.removing ? 'toast-exit' : 'toast-enter'}
flex items-start gap-3 p-4 rounded-lg shadow-lg pointer-events-auto
min-w-0
`}
>
{icons[toast.type] || icons.info}
<p className="text-sm text-slate-700 flex-1 leading-relaxed">{toast.message}</p>
<button
onClick={() => removeToast(toast.id)}
className="text-slate-400 hover:text-slate-600 transition-colors flex-shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)
}
export const useToast = () => {
const show = useCallback((message, type, duration) => {
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),
}
}
export default useToast
+156
View File
@@ -0,0 +1,156 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
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
const ICON_COLORS: Record<ToastType, string> = {
success: '#22c55e',
error: '#ef4444',
warning: '#f59e0b',
info: '#6366f1',
}
export function ToastContainer() {
const [toasts, setToasts] = useState<Toast[]>([])
const addToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
const id = ++toastIdCounter
setToasts(prev => [...prev, { id, message, type, duration, removing: false }])
if (duration > 0) {
setTimeout(() => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 400)
}, duration)
}
return 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))
}, 400)
}, [])
useEffect(() => {
window.__addToast = addToast
return () => { delete window.__addToast }
}, [addToast])
const icons: Record<ToastType, React.ReactNode> = {
success: <CheckCircle size={18} style={{ color: ICON_COLORS.success, flexShrink: 0 }} />,
error: <XCircle size={18} style={{ color: ICON_COLORS.error, flexShrink: 0 }} />,
warning: <AlertCircle size={18} style={{ color: ICON_COLORS.warning, flexShrink: 0 }} />,
info: <Info size={18} style={{ color: ICON_COLORS.info, flexShrink: 0 }} />,
}
return (
<>
<style>{`
@keyframes toast-in {
from { opacity: 0; transform: translateY(16px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes toast-out {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(8px) scale(0.95); }
}
.nomad-toast {
background: rgba(255, 255, 255, 0.65);
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), inset 0 0.5px 0 rgba(255,255,255,0.5);
}
.nomad-toast span { color: rgba(0, 0, 0, 0.8) !important; }
.dark .nomad-toast {
background: rgba(30, 30, 40, 0.55);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25), inset 0 0.5px 0 rgba(255,255,255,0.08);
}
.dark .nomad-toast span { color: rgba(255, 255, 255, 0.9) !important; }
.nomad-toast-close { color: rgba(0, 0, 0, 0.4); }
.dark .nomad-toast-close { color: rgba(255, 255, 255, 0.4); }
`}</style>
<div style={{
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
zIndex: 9999, display: 'flex', flexDirection: 'column-reverse', gap: 8,
pointerEvents: 'none', maxWidth: 420, width: '100%', padding: '0 16px',
}}>
{toasts.map(toast => (
<div
key={toast.id}
className="nomad-toast"
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 14px',
borderRadius: 14,
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
pointerEvents: 'auto',
animation: toast.removing ? 'toast-out 0.35s ease forwards' : 'toast-in 0.35s cubic-bezier(0.16,1,0.3,1) forwards',
}}
>
{icons[toast.type] || icons.info}
<span style={{
flex: 1, fontSize: 13, fontWeight: 500, color: 'rgba(255, 255, 255, 0.9)',
lineHeight: 1.4,
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}}>
{toast.message}
</span>
<button
onClick={() => removeToast(toast.id)}
className="nomad-toast-close"
style={{
background: 'none', border: 'none', cursor: 'pointer',
display: 'flex', padding: 2,
flexShrink: 0, borderRadius: 6, transition: 'opacity 0.15s',
opacity: 0.35,
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.7'}
onMouseLeave={e => e.currentTarget.style.opacity = '0.35'}
>
<X size={14} />
</button>
</div>
))}
</div>
</>
)
}
export const useToast = () => {
const show = useCallback((message: string, type: ToastType, duration?: number) => {
if (window.__addToast) {
window.__addToast(message, type, duration)
}
}, [])
return {
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),
}
}
export default useToast
@@ -9,9 +9,10 @@ import {
Church, Library, Store, Home, Cross,
Heart, Star, CreditCard, Wifi,
Luggage, Backpack, Zap,
LucideIcon,
} from 'lucide-react'
export const CATEGORY_ICON_MAP = {
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,
@@ -24,7 +25,7 @@ export const CATEGORY_ICON_MAP = {
Luggage, Backpack, Zap,
}
export const ICON_LABELS = {
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',
@@ -38,6 +39,6 @@ export const ICON_LABELS = {
Luggage: 'Luggage', Backpack: 'Backpack', Zap: 'Adventure',
}
export function getCategoryIcon(iconName) {
return CATEGORY_ICON_MAP[iconName] || MapPin
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 }
}

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