Compare commits

..

140 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
123 changed files with 27293 additions and 1301 deletions
+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
+5 -2
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++
@@ -30,6 +30,9 @@ COPY --from=client-builder /app/client/public/fonts ./public/fonts
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
ENV PORT=3000
+61 -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,19 +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
@@ -84,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
@@ -92,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>
@@ -112,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
@@ -142,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>
@@ -163,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;
@@ -204,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.
@@ -220,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
+3 -3
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 -->
+1473 -10
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "nomad-client",
"version": "2.6.1",
"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"
},
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

+26 -3
View File
@@ -11,6 +11,7 @@ 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'
@@ -62,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: { demo_mode?: boolean; has_maps_key?: boolean }) => {
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(() => {})
}, [])
@@ -107,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={
+63 -4
View File
@@ -39,8 +39,13 @@ apiClient.interceptors.response.use(
)
export const authApi = {
register: (data: { username: string; email: string; password: string }) => apiClient.post('/auth/register', data).then(r => r.data),
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),
@@ -86,6 +91,10 @@ export const placesApi = {
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 = {
@@ -103,9 +112,17 @@ export const assignmentsApi = {
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 = {
@@ -135,6 +152,24 @@ export const adminApi = {
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 = {
@@ -143,8 +178,10 @@ export const addonsApi = {
export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${placeId}`, { params: { lang } }).then(r => r.data),
placePhoto: (placeId: string) => apiClient.get(`/maps/place-photo/${placeId}`).then(r => r.data),
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 = {
@@ -155,15 +192,23 @@ export const budgetApi = {
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) => apiClient.get(`/trips/${tripId}/files`).then(r => r.data),
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 = {
@@ -171,6 +216,7 @@ export const reservationsApi = {
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 = {
@@ -245,4 +291,17 @@ export const backupApi = {
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
+42 -8
View File
@@ -3,10 +3,10 @@ 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,
}
interface Addon {
@@ -27,7 +27,7 @@ function AddonIcon({ name, size = 20 }: AddonIconProps) {
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)
@@ -84,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>
@@ -104,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>
)}
@@ -136,8 +157,21 @@ interface AddonRowProps {
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 */}
@@ -148,7 +182,7 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
{/* 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
@@ -161,12 +195,12 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
{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>
)
}
+86 -3
View File
@@ -3,6 +3,8 @@ 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 = [
@@ -21,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)
@@ -51,6 +69,7 @@ export default function BackupPanel() {
try {
const data = await backupApi.getAutoSettings()
setAutoSettings(data.settings)
if (data.timezone) setServerTimezone(data.timezone)
} catch {}
}
@@ -147,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 }
}
@@ -331,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>
@@ -198,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>
+62 -28
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'
import { useTranslation } from '../../i18n'
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,9 +18,8 @@ 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: unknown) {
@@ -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>
)
}
+148 -24
View File
@@ -3,10 +3,11 @@ 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
@@ -28,13 +29,29 @@ interface PerPersonSummaryEntry {
}
// ── 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)
@@ -143,9 +160,11 @@ interface ChipWithTooltipProps {
label: string
avatarUrl: string | null
size?: number
paid?: boolean
onClick?: () => void
}
function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps) {
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)
@@ -158,13 +177,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps)
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' }} />
@@ -175,11 +200,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps)
<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
)}
@@ -192,10 +225,11 @@ interface BudgetMemberChipsProps {
members?: BudgetMember[]
tripMembers?: TripMember[]
onSetMembers: (memberIds: number[]) => void
onTogglePaid?: (userId: number, paid: boolean) => void
compact?: boolean
}
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compact = true }: BudgetMemberChipsProps) {
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)
@@ -235,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={{
@@ -374,15 +411,23 @@ interface BudgetPanelProps {
}
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers } = useTripStore()
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 })
}
@@ -537,13 +582,14 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
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 ? (
@@ -551,6 +597,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
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')} />
@@ -620,12 +667,97 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
</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 && (
@@ -639,27 +771,19 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<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>
)}
+79 -8
View File
@@ -1,7 +1,9 @@
import ReactDOM from 'react-dom'
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import DOM from 'react-dom'
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink } from 'lucide-react'
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'
@@ -412,7 +414,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
outline: 'none',
boxSizing: 'border-box',
resize: 'vertical',
minHeight: 90,
minHeight: 180,
lineHeight: 1.5,
}}
/>
@@ -690,13 +692,14 @@ interface NoteCardProps {
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, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
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) }
@@ -749,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}
@@ -799,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 */}
@@ -872,6 +883,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
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)
@@ -1243,6 +1255,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
onUpdate={handleUpdateNote}
onDelete={handleDeleteNote}
onEdit={setEditingNote}
onView={setViewingNote}
onPreviewFile={setPreviewFile}
getCategoryColor={getCategoryColor}
tripId={tripId}
@@ -1254,6 +1267,64 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
</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)}
@@ -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 { 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>
+557 -172
View File
@@ -1,15 +1,15 @@
import ReactDOM from 'react-dom'
import { useState, useCallback } from 'react'
import DOM 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 } from 'lucide-react'
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 type { Place, Reservation, TripFile } from '../../types'
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/') // covers jpg, png, gif, webp, etc.
return mimeType.startsWith('image/')
}
function getFileIcon(mimeType) {
@@ -68,7 +68,7 @@ function ImageLightbox({ file, onClose }: ImageLightboxProps) {
)
}
// Source badge — unified style for both place and reservation
// Source badge
interface SourceBadgeProps {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
label: string
@@ -89,40 +89,156 @@ function SourceBadge({ icon: Icon, label }: SourceBadgeProps) {
)
}
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<void>
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, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
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)
await onUpload(formData)
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])
}, [onUpload, toast, t, places, reservations])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
@@ -130,24 +246,24 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
noClick: false,
})
// Paste support
const handlePaste = useCallback((e) => {
const items = e.clipboardData?.items
if (!items) return
const files = []
const pastedFiles = []
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) files.push(file)
if (file) pastedFiles.push(file)
}
}
if (files.length > 0) {
if (pastedFiles.length > 0) {
e.preventDefault()
onDrop(files)
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')
@@ -156,16 +272,25 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
})
const handleDelete = async (id) => {
if (!confirm(t('files.confirm.delete'))) return
try {
await onDelete(id)
toast.success(t('files.toast.deleted'))
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)) {
@@ -175,12 +300,316 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
}
}
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)} />}
{/* Datei-Vorschau Modal — portal to body to escape stacking context */}
{/* 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 }}
@@ -225,172 +654,128 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{/* 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>
<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)' }}>
{files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length })}
{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>
{/* 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')}
{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>
) : (
<>
<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>
{/* 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>
) : (
<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>
)
})}
{/* 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>
)}
</div>
</>
)}
<style>{`
div:hover > .file-actions { opacity: 1 !important; }
@media (max-width: 767px) {
.file-actions button { padding: 8px !important; }
.file-actions svg { width: 18px !important; height: 18px !important; }
}
`}</style>
</div>
)
+72 -8
View File
@@ -25,7 +25,7 @@ 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',
@@ -48,7 +48,7 @@ const texts: Record<string, DemoTexts> = {
['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',
@@ -57,7 +57,7 @@ const texts: Record<string, DemoTexts> = {
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',
@@ -80,12 +80,76 @@ const texts: Record<string, DemoTexts> = {
['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]
@@ -123,7 +187,7 @@ export default function DemoBanner(): React.ReactElement | null {
<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>
@@ -151,7 +215,7 @@ export default function DemoBanner(): React.ReactElement | null {
</div>
</div>
{/* What is NOMAD */}
{/* What is TREK */}
<div style={{
background: '#f8fafc', borderRadius: 12, padding: '12px 14px', marginBottom: 16,
border: '1px solid #e2e8f0',
@@ -159,7 +223,7 @@ export default function DemoBanner(): React.ReactElement | null {
<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>
@@ -213,7 +277,7 @@ export default function DemoBanner(): React.ReactElement | null {
<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>
+10 -4
View File
@@ -67,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)',
@@ -91,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 */}
@@ -124,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>
)
})}
@@ -231,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>
+153 -30
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, useMemo } from 'react'
import { useEffect, useRef, useState, useMemo, useCallback } from 'react'
import DOM from 'react-dom'
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
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'
@@ -65,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>`,
@@ -107,20 +107,14 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
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
}
@@ -182,6 +176,16 @@ function MapClickHandler({ onClick }: MapClickHandlerProps) {
return null
}
function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) {
const map = useMap()
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]
@@ -234,6 +238,97 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
// 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 = [],
@@ -243,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',
@@ -264,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 (
@@ -302,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
@@ -326,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)
@@ -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>
)
}
+45 -6
View File
@@ -12,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>`
@@ -96,13 +103,14 @@ interface downloadTripPDFProps {
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, t: _t, locale: _locale }: downloadTripPDFProps) {
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)
@@ -123,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 `
@@ -165,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 `
@@ -190,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>
@@ -199,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}/">
@@ -377,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,10 +1,12 @@
import { useState, useMemo, useRef } from 'react'
import { useState, useMemo, useRef, useEffect } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { packingApi, tripsApi, adminApi } from '../../api/client'
import ReactDOM from 'react-dom'
import {
CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight,
Sparkles, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage,
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus, Upload,
} from 'lucide-react'
import type { PackingItem } from '../../types'
@@ -64,19 +66,27 @@ function katColor(kat, allCategories) {
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length]
}
interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null }
// ── Artikel-Zeile ──────────────────────────────────────────────────────────
interface ArtikelZeileProps {
item: PackingItem
tripId: number
categories: string[]
onCategoryChange: () => void
bagTrackingEnabled?: boolean
bags?: PackingBag[]
onCreateBag: (name: string) => Promise<PackingBag | undefined>
}
function ArtikelZeile({ item, tripId, categories, onCategoryChange }: ArtikelZeileProps) {
function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag }: ArtikelZeileProps) {
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(item.name)
const [hovered, setHovered] = useState(false)
const [showCatPicker, setShowCatPicker] = useState(false)
const [showBagPicker, setShowBagPicker] = useState(false)
const [bagInlineCreate, setBagInlineCreate] = useState(false)
const [bagInlineName, setBagInlineName] = useState('')
const { togglePackingItem, updatePackingItem, deletePackingItem } = useTripStore()
const toast = useToast()
const { t } = useTranslation()
@@ -103,8 +113,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange }: ArtikelZei
return (
<div
className="group"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => { setHovered(false); setShowCatPicker(false) }}
onMouseLeave={() => { setHovered(false); setShowCatPicker(false); setShowBagPicker(false) }}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 10px', borderRadius: 10, position: 'relative',
@@ -141,7 +152,102 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange }: ArtikelZei
</span>
)}
<div style={{ display: 'flex', gap: 2, alignItems: 'center', opacity: hovered ? 1 : 0, transition: 'opacity 0.12s', flexShrink: 0 }}>
{/* Weight + Bag (when enabled) */}
{bagTrackingEnabled && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent' }}>
<input
type="text" inputMode="numeric"
value={item.weight_grams ?? ''}
onChange={async e => {
const raw = e.target.value.replace(/[^0-9]/g, '')
const v = raw === '' ? null : parseInt(raw)
try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch {}
}}
placeholder="—"
style={{ width: 36, border: 'none', fontSize: 12, textAlign: 'right', fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)', background: 'transparent', padding: 0 }}
/>
<span style={{ fontSize: 10, color: 'var(--text-faint)', userSelect: 'none' }}>g</span>
</div>
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowBagPicker(p => !p)}
style={{
width: 22, height: 22, borderRadius: '50%', cursor: 'pointer', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
border: item.bag_id ? `2.5px solid ${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}` : '2px dashed var(--border-primary)',
background: item.bag_id ? `${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}30` : 'transparent',
}}
>
{!item.bag_id && <Package size={9} style={{ color: 'var(--text-faint)' }} />}
</button>
{showBagPicker && (
<div style={{
position: 'absolute', right: 0, top: '100%', marginTop: 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: 160,
}}>
{item.bag_id && (
<button onClick={async () => { setShowBagPicker(false); try { await updatePackingItem(tripId, item.id, { bag_id: null }) } catch {} }}
style={{ display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '6px 10px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit', color: 'var(--text-faint)', borderRadius: 7 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', border: '2px dashed var(--border-primary)' }} />
{t('packing.noBag')}
</button>
)}
{bags.map(b => (
<button key={b.id} onClick={async () => { setShowBagPicker(false); try { await updatePackingItem(tripId, item.id, { bag_id: b.id }) } catch {} }}
style={{
display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '6px 10px',
background: item.bag_id === b.id ? 'var(--bg-tertiary)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit', color: 'var(--text-secondary)', borderRadius: 7,
}}
onMouseEnter={e => { if (item.bag_id !== b.id) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
onMouseLeave={e => { if (item.bag_id !== b.id) e.currentTarget.style.background = 'none' }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: b.color, flexShrink: 0 }} />
{b.name}
</button>
))}
{bags.length > 0 && <div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />}
<div style={{ padding: '4px 6px' }}>
{bagInlineCreate ? (
<div style={{ display: 'flex', gap: 4 }}>
<input autoFocus value={bagInlineName} onChange={e => setBagInlineName(e.target.value)}
onKeyDown={async e => {
if (e.key === 'Enter' && bagInlineName.trim()) {
const newBag = await onCreateBag(bagInlineName.trim())
if (newBag) { try { await updatePackingItem(tripId, item.id, { bag_id: newBag.id }) } catch {} }
setBagInlineName(''); setBagInlineCreate(false); setShowBagPicker(false)
}
if (e.key === 'Escape') { setBagInlineCreate(false); setBagInlineName('') }
}}
placeholder={t('packing.bagName')}
style={{ flex: 1, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', fontSize: 11, fontFamily: 'inherit', outline: 'none' }} />
<button onClick={async () => {
if (bagInlineName.trim()) {
const newBag = await onCreateBag(bagInlineName.trim())
if (newBag) { try { await updatePackingItem(tripId, item.id, { bag_id: newBag.id }) } catch {} }
setBagInlineName(''); setBagInlineCreate(false); setShowBagPicker(false)
}
}}
style={{ padding: '3px 6px', borderRadius: 6, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Plus size={11} />
</button>
</div>
) : (
<button onClick={() => setBagInlineCreate(true)}
style={{ display: 'flex', alignItems: 'center', gap: 5, width: '100%', padding: '5px 6px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, fontFamily: 'inherit', color: 'var(--text-faint)', borderRadius: 7 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Plus size={11} /> {t('packing.addBag')}
</button>
)}
</div>
</div>
)}
</div>
</div>
)}
<div className="sm:opacity-0 sm:group-hover:opacity-100" style={{ display: 'flex', gap: 2, alignItems: 'center', transition: 'opacity 0.12s', flexShrink: 0 }}>
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowCatPicker(p => !p)}
@@ -186,6 +292,19 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange }: ArtikelZei
}
// ── Kategorie-Gruppe ───────────────────────────────────────────────────────
interface TripMember {
id: number
username: string
avatar?: string | null
avatar_url?: string | null
}
interface CategoryAssignee {
user_id: number
username: string
avatar?: string | null
}
interface KategorieGruppeProps {
kategorie: string
items: PackingItem[]
@@ -193,16 +312,39 @@ interface KategorieGruppeProps {
allCategories: string[]
onRename: (oldName: string, newName: string) => Promise<void>
onDeleteAll: (items: PackingItem[]) => Promise<void>
onAddItem: (category: string, name: string) => Promise<void>
assignees: CategoryAssignee[]
tripMembers: TripMember[]
onSetAssignees: (category: string, userIds: number[]) => Promise<void>
bagTrackingEnabled?: boolean
bags?: PackingBag[]
onCreateBag: (name: string) => Promise<PackingBag | undefined>
}
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll }: KategorieGruppeProps) {
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag }: KategorieGruppeProps) {
const [offen, setOffen] = useState(true)
const [editingName, setEditingName] = useState(false)
const [editKatName, setEditKatName] = useState(kategorie)
const [showMenu, setShowMenu] = useState(false)
const [showAssigneeDropdown, setShowAssigneeDropdown] = useState(false)
const [showAddItem, setShowAddItem] = useState(false)
const [newItemName, setNewItemName] = useState('')
const addItemRef = useRef<HTMLInputElement>(null)
const assigneeDropdownRef = useRef<HTMLDivElement>(null)
const { togglePackingItem } = useTripStore()
const toast = useToast()
const { t } = useTranslation()
useEffect(() => {
if (!showAssigneeDropdown) return
const handleClickOutside = (e: MouseEvent) => {
if (assigneeDropdownRef.current && !assigneeDropdownRef.current.contains(e.target as Node)) {
setShowAssigneeDropdown(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showAssigneeDropdown])
const abgehakt = items.filter(i => i.checked).length
const alleAbgehakt = abgehakt === items.length
const dot = katColor(kategorie, allCategories)
@@ -247,11 +389,98 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
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 }}>
<span style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{kategorie}
</span>
)}
{/* Assignee chips */}
<div style={{ display: 'flex', alignItems: 'center', gap: 3, flex: 1, minWidth: 0, marginLeft: 4 }}>
{assignees.map(a => (
<div key={a.user_id} style={{ position: 'relative' }}
onClick={e => { e.stopPropagation(); onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }}
>
<div className="assignee-chip"
style={{
width: 22, height: 22, borderRadius: '50%', flexShrink: 0, cursor: 'pointer',
background: `hsl(${a.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
border: '2px solid var(--bg-card)', transition: 'opacity 0.15s',
}}
>
{a.username[0]}
</div>
<div className="assignee-tooltip" style={{
position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
marginTop: 6, padding: '3px 8px', borderRadius: 6, zIndex: 60,
background: 'var(--text-primary)', color: 'var(--bg-primary)',
fontSize: 10, fontWeight: 600, whiteSpace: 'nowrap',
pointerEvents: 'none', opacity: 0, transition: 'opacity 0.15s',
}}>
{a.username}
</div>
</div>
))}
<div ref={assigneeDropdownRef} style={{ position: 'relative' }}>
<button onClick={e => { e.stopPropagation(); setShowAssigneeDropdown(v => !v) }}
style={{
width: 20, height: 20, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--text-faint)', flexShrink: 0, padding: 0, transition: 'all 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-muted)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}
>
<UserPlus size={10} />
</button>
{showAssigneeDropdown && (
<div style={{
position: 'absolute', left: 0, top: '100%', marginTop: 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: 160,
}}>
{tripMembers.map(m => {
const isAssigned = assignees.some(a => a.user_id === m.id)
return (
<button key={m.id} onClick={e => {
e.stopPropagation()
const newIds = isAssigned
? assignees.filter(a => a.user_id !== m.id).map(a => a.user_id)
: [...assignees.map(a => a.user_id), m.id]
onSetAssignees(kategorie, newIds)
}}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '6px 10px', borderRadius: 8, border: 'none', cursor: 'pointer',
background: isAssigned ? 'var(--bg-hover)' : 'transparent',
fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!isAssigned) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
onMouseLeave={e => { if (!isAssigned) e.currentTarget.style.background = 'transparent' }}
>
<div style={{
width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
background: `hsl(${m.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
}}>
{m.username[0]}
</div>
<span style={{ flex: 1 }}>{m.username}</span>
{isAssigned && <Check size={12} style={{ color: 'var(--text-muted)' }} />}
</button>
)
})}
{tripMembers.length === 0 && (
<div style={{ padding: '8px 10px', fontSize: 11, color: 'var(--text-faint)' }}>{t('packing.noMembers')}</div>
)}
</div>
)}
</div>
</div>
<span style={{
fontSize: 11, fontWeight: 600, padding: '1px 8px', borderRadius: 99,
background: alleAbgehakt ? 'rgba(22,163,74,0.12)' : 'var(--bg-tertiary)',
@@ -281,8 +510,45 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
{offen && (
<div style={{ padding: '4px 4px 6px' }}>
{items.map(item => (
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} />
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} />
))}
{/* Inline add item */}
{showAddItem ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px' }}>
<input
ref={addItemRef}
autoFocus
value={newItemName}
onChange={e => setNewItemName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && newItemName.trim()) {
onAddItem(kategorie, newItemName.trim())
setNewItemName('')
setTimeout(() => addItemRef.current?.focus(), 30)
}
if (e.key === 'Escape') { setShowAddItem(false); setNewItemName('') }
}}
placeholder={t('packing.addItemPlaceholder')}
style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 12.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'var(--bg-input)' }}
/>
<button onClick={() => { if (newItemName.trim()) { onAddItem(kategorie, newItemName.trim()); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) } }}
disabled={!newItemName.trim()}
style={{ padding: '5px 8px', borderRadius: 8, border: 'none', background: newItemName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newItemName.trim() ? 'pointer' : 'default', display: 'flex' }}>
<Plus size={14} />
</button>
<button onClick={() => { setShowAddItem(false); setNewItemName('') }}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: 'var(--text-faint)' }}>
<X size={14} />
</button>
</div>
) : (
<button onClick={() => { setShowAddItem(true); setTimeout(() => addItemRef.current?.focus(), 30) }}
style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px', margin: '2px 4px', borderRadius: 8, border: 'none', background: 'none', cursor: 'pointer', fontSize: 12, color: 'var(--text-faint)', fontFamily: 'inherit' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Plus size={12} /> {t('packing.addItem')}
</button>
)}
</div>
)}
</div>
@@ -319,19 +585,45 @@ interface PackingListPanelProps {
}
export default function PackingListPanel({ tripId, items }: PackingListPanelProps) {
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 [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('')
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
const toast = useToast()
const { t } = useTranslation()
// Trip members & category assignees
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
const [categoryAssignees, setCategoryAssignees] = useState<Record<string, CategoryAssignee[]>>({})
useEffect(() => {
tripsApi.getMembers(tripId).then(data => {
const all: TripMember[] = []
if (data.owner) all.push({ id: data.owner.id, username: data.owner.username, avatar: data.owner.avatar_url })
if (data.members) all.push(...data.members.map((m: any) => ({ id: m.id, username: m.username, avatar: m.avatar_url })))
setTripMembers(all)
}).catch(() => {})
packingApi.getCategoryAssignees(tripId).then(data => {
setCategoryAssignees(data.assignees || {})
}).catch(() => {})
}, [tripId])
const handleSetAssignees = async (category: string, userIds: number[]) => {
try {
const data = await packingApi.setCategoryAssignees(tripId, category, userIds)
setCategoryAssignees(prev => ({ ...prev, [category]: data.assignees || [] }))
} catch {
toast.error(t('packing.toast.saveError'))
}
}
const allCategories = useMemo(() => {
const cats = new Set(items.map(i => i.category || t('packing.defaultCategory')))
return Array.from(cats).sort()
const seen: string[] = []
for (const item of items) {
const cat = item.category || t('packing.defaultCategory')
if (!seen.includes(cat)) seen.push(cat)
}
return seen
}, [items, t])
const gruppiert = useMemo(() => {
@@ -352,21 +644,24 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
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'))
const handleAddItemToCategory = async (category: string, name: string) => {
try {
await addPackingItem(tripId, { name: neuerName.trim(), category: kat })
setNeuerName('')
await addPackingItem(tripId, { name, category })
} 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 handleAddNewCategory = async () => {
if (!newCatName.trim()) return
let catName = newCatName.trim()
// Allow duplicate display names — append invisible zero-width spaces to make unique internally
while (allCategories.includes(catName)) {
catName += '\u200B'
}
try {
await addPackingItem(tripId, { name: '...', category: catName })
setNewCatName('')
setAddingCategory(false)
} catch { toast.error(t('packing.toast.addError')) }
}
const handleRenameCategory = async (oldName, newName) => {
@@ -389,8 +684,120 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
}
}
const vorhandeneNamen = new Set(items.map(i => i.name.toLowerCase()))
const verfuegbareVorschlaege = vorschlaege.filter(v => !vorhandeneNamen.has(v.name.toLowerCase()))
// Bag tracking
const [bagTrackingEnabled, setBagTrackingEnabled] = useState(false)
const [bags, setBags] = useState<PackingBag[]>([])
const [newBagName, setNewBagName] = useState('')
const [showAddBag, setShowAddBag] = useState(false)
const [showBagModal, setShowBagModal] = useState(false)
useEffect(() => {
adminApi.getBagTracking().then(d => {
setBagTrackingEnabled(d.enabled)
if (d.enabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {})
}).catch(() => {})
}, [tripId])
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b']
const handleCreateBag = async () => {
if (!newBagName.trim()) return
try {
const data = await packingApi.createBag(tripId, { name: newBagName.trim(), color: BAG_COLORS[bags.length % BAG_COLORS.length] })
setBags(prev => [...prev, data.bag])
setNewBagName(''); setShowAddBag(false)
} catch { toast.error(t('packing.toast.saveError')) }
}
const handleCreateBagByName = async (name: string): Promise<PackingBag | undefined> => {
try {
const data = await packingApi.createBag(tripId, { name, color: BAG_COLORS[bags.length % BAG_COLORS.length] })
setBags(prev => [...prev, data.bag])
return data.bag
} catch { toast.error(t('packing.toast.saveError')); return undefined }
}
const handleDeleteBag = async (bagId: number) => {
try {
await packingApi.deleteBag(tripId, bagId)
setBags(prev => prev.filter(b => b.id !== bagId))
} catch { toast.error(t('packing.toast.deleteError')) }
}
// Templates
const [availableTemplates, setAvailableTemplates] = useState<{ id: number; name: string; item_count: number }[]>([])
const [showTemplateDropdown, setShowTemplateDropdown] = useState(false)
const [applyingTemplate, setApplyingTemplate] = useState(false)
const [showImportModal, setShowImportModal] = useState(false)
const [importText, setImportText] = useState('')
const csvInputRef = useRef<HTMLInputElement>(null)
const templateDropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
}, [tripId])
useEffect(() => {
if (!showTemplateDropdown) return
const handler = (e: MouseEvent) => {
if (templateDropdownRef.current && !templateDropdownRef.current.contains(e.target as Node)) setShowTemplateDropdown(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [showTemplateDropdown])
const handleApplyTemplate = async (templateId: number) => {
setApplyingTemplate(true)
try {
const data = await packingApi.applyTemplate(tripId, templateId)
toast.success(t('packing.templateApplied', { count: data.count }))
setShowTemplateDropdown(false)
// Reload packing items
window.location.reload()
} catch {
toast.error(t('packing.templateError'))
} finally {
setApplyingTemplate(false)
}
}
const parseImportLines = (text: string) => {
return text.split('\n').map(line => line.trim()).filter(Boolean).map(line => {
// Format: Category, Name, Weight (optional), Bag (optional), checked/unchecked (optional)
const parts = line.split(/[,;\t]/).map(s => s.trim())
if (parts.length >= 2) {
const category = parts[0]
const name = parts[1]
const weight_grams = parts[2] || undefined
const bag = parts[3] || undefined
const checked = parts[4]?.toLowerCase() === 'checked' || parts[4] === '1'
return { name, category, weight_grams, bag, checked }
}
// Single value = just a name
return { name: parts[0], category: undefined, weight_grams: undefined, bag: undefined, checked: false }
}).filter(i => i.name)
}
const handleBulkImport = async () => {
const parsed = parseImportLines(importText)
if (parsed.length === 0) { toast.error(t('packing.importEmpty')); return }
try {
const result = await packingApi.bulkImport(tripId, parsed)
toast.success(t('packing.importSuccess', { count: result.count }))
setImportText('')
setShowImportModal(false)
window.location.reload()
} catch { toast.error(t('packing.importError')) }
}
const handleCsvFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
const reader = new FileReader()
reader.onload = () => { if (typeof reader.result === 'string') setImportText(reader.result) }
reader.readAsText(file)
}
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
@@ -416,15 +823,64 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
</button>
)}
<button onClick={() => setZeigeVorschlaege(v => !v)} style={{
<button onClick={() => setShowImportModal(true)} 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)',
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
}}>
<Sparkles size={12} /> {t('packing.suggestions')}
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
</button>
{availableTemplates.length > 0 && (
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
background: showTemplateDropdown ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: showTemplateDropdown ? 'var(--text-primary)' : 'var(--border-primary)',
color: showTemplateDropdown ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
<Package size={12} /> <span className="hidden sm:inline">{t('packing.applyTemplate')}</span><span className="sm:hidden">{t('packing.template')}</span>
</button>
{showTemplateDropdown && (
<div style={{
position: 'absolute', right: 0, top: '100%', marginTop: 6, 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: 200,
}}>
{availableTemplates.map(tmpl => (
<button key={tmpl.id} onClick={() => handleApplyTemplate(tmpl.id)}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '8px 12px', borderRadius: 8, border: 'none', cursor: 'pointer',
background: 'transparent', fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
transition: 'background 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Package size={13} style={{ color: 'var(--text-faint)' }} />
<div style={{ flex: 1, textAlign: 'left' }}>
<div style={{ fontWeight: 600 }}>{tmpl.name}</div>
<div style={{ fontSize: 10, color: 'var(--text-faint)' }}>{tmpl.item_count} {t('admin.packingTemplates.items')}</div>
</div>
</button>
))}
</div>
)}
</div>
)}
{bagTrackingEnabled && (
<button onClick={() => setShowBagModal(true)} className="xl:!hidden"
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
background: showBagModal ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: showBagModal ? 'var(--text-primary)' : 'var(--border-primary)',
color: showBagModal ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
<Luggage size={12} /> {t('packing.bags')}
</button>
)}
</div>
</div>
@@ -443,71 +899,33 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
</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' }}>
{addingCategory ? (
<div style={{ display: 'flex', gap: 6 }}>
<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)' }}
autoFocus
type="text" value={newCatName} onChange={e => setNewCatName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddNewCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
placeholder={t('packing.newCategoryPlaceholder')}
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)' }}
/>
{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 onClick={handleAddNewCategory} disabled={!newCatName.trim()}
style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: newCatName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newCatName.trim() ? 'pointer' : 'default', display: 'flex', alignItems: 'center' }}>
<Check size={16} />
</button>
<button onClick={() => { setAddingCategory(false); setNewCatName('') }}
style={{ padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}>
<X size={16} />
</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>
)}
) : (
<button onClick={() => setAddingCategory(true)}
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '9px 14px', borderRadius: 10, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-faint)', fontFamily: 'inherit', transition: 'all 0.15s' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-secondary)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
<FolderPlus size={14} /> {t('packing.addCategory')}
</button>
)}
</div>
{/* ── Filter-Tabs ── */}
{items.length > 0 && (
@@ -523,7 +941,8 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
</div>
)}
{/* ── Liste ── */}
{/* ── Liste + Bags Sidebar ── */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 12px 16px' }}>
{items.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
@@ -546,11 +965,246 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
allCategories={allCategories}
onRename={handleRenameCategory}
onDeleteAll={handleDeleteCategory}
onAddItem={handleAddItemToCategory}
assignees={categoryAssignees[kat] || []}
tripMembers={tripMembers}
onSetAssignees={handleSetAssignees}
bagTrackingEnabled={bagTrackingEnabled}
bags={bags}
onCreateBag={handleCreateBagByName}
/>
))}
</div>
)}
</div>
{/* ── Bag Weight Sidebar ── */}
{bagTrackingEnabled && bags.length > 0 && (
<div className="hidden xl:block" style={{ width: 260, borderLeft: '1px solid var(--border-secondary)', overflowY: 'auto', padding: 16, flexShrink: 0 }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-faint)', marginBottom: 12 }}>
{t('packing.bags')}
</div>
{bags.map(bag => {
const bagItems = items.filter(i => i.bag_id === bag.id)
const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0)
const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
return (
<div key={bag.id} style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: bag.color, flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{bag.name}</span>
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 500 }}>
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
</span>
<button onClick={() => handleDeleteBag(bag.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
<X size={11} />
</button>
</div>
<div style={{ height: 6, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
</div>
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2 }}>{bagItems.length} {t('admin.packingTemplates.items')}</div>
</div>
)
})}
{/* Unassigned */}
{(() => {
const unassigned = items.filter(i => !i.bag_id)
const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0)
if (unassigned.length === 0) return null
return (
<div style={{ marginBottom: 14, opacity: 0.6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', border: '2px dashed var(--border-primary)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, fontWeight: 600, color: 'var(--text-faint)' }}>{t('packing.noBag')}</span>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>
{unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
</span>
</div>
<div style={{ fontSize: 10, color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div>
</div>
)
})()}
{/* Total */}
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span>
<span>{(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
</div>
</div>
{/* Add bag */}
{showAddBag ? (
<div style={{ display: 'flex', gap: 4, marginTop: 12 }}>
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
placeholder={t('packing.bagName')}
style={{ flex: 1, padding: '5px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 11, fontFamily: 'inherit', outline: 'none' }} />
<button onClick={handleCreateBag} style={{ padding: '4px 8px', borderRadius: 8, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Plus size={12} />
</button>
</div>
) : (
<button onClick={() => setShowAddBag(true)}
style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 12, padding: '5px 8px', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%' }}>
<Plus size={11} /> {t('packing.addBag')}
</button>
)}
</div>
)}
</div>
{/* ── Bag Modal (mobile + click) ── */}
{showBagModal && bagTrackingEnabled && (
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}
onClick={() => setShowBagModal(false)}>
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: '80vh', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)' }}
onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.bags')}</h3>
<button onClick={() => setShowBagModal(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><X size={18} /></button>
</div>
{bags.map(bag => {
const bagItems = items.filter(i => i.bag_id === bag.id)
const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0)
const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
return (
<div key={bag.id} style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ width: 12, height: 12, borderRadius: '50%', background: bag.color, flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>{bag.name}</span>
<span style={{ fontSize: 13, color: 'var(--text-muted)', fontWeight: 500 }}>
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
</span>
<button onClick={() => handleDeleteBag(bag.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
<Trash2 size={13} />
</button>
</div>
<div style={{ height: 8, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
</div>
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>{bagItems.length} {t('admin.packingTemplates.items')}</div>
</div>
)
})}
{/* Unassigned */}
{(() => {
const unassigned = items.filter(i => !i.bag_id)
const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0)
if (unassigned.length === 0) return null
return (
<div style={{ marginBottom: 16, opacity: 0.6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ width: 12, height: 12, borderRadius: '50%', border: '2px dashed var(--border-primary)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 14, fontWeight: 600, color: 'var(--text-faint)' }}>{t('packing.noBag')}</span>
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
{unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div>
</div>
)
})()}
{/* Total */}
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span>
<span>{(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
</div>
</div>
{/* Add bag */}
{showAddBag ? (
<div style={{ display: 'flex', gap: 6, marginTop: 14 }}>
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
placeholder={t('packing.bagName')}
style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none' }} />
<button onClick={handleCreateBag} disabled={!newBagName.trim()}
style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: newBagName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newBagName.trim() ? 'pointer' : 'default', display: 'flex', alignItems: 'center' }}>
<Plus size={14} />
</button>
</div>
) : (
<button onClick={() => setShowAddBag(true)}
style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 14, padding: '9px 14px', borderRadius: 10, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%', transition: 'all 0.15s' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-secondary)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
<Plus size={14} /> {t('packing.addBag')}
</button>
)}
</div>
</div>
)}
<style>{`
.assignee-chip:hover + .assignee-tooltip { opacity: 1 !important; }
.assignee-chip:hover { opacity: 0.7; }
`}</style>
{/* Bulk Import Modal */}
{showImportModal && 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={() => setShowImportModal(false)}>
<div style={{
width: 420, maxHeight: '80vh', 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: 14,
}} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('packing.importTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', lineHeight: 1.5 }}>{t('packing.importHint')}</div>
<textarea
value={importText}
onChange={e => setImportText(e.target.value)}
rows={10}
placeholder={t('packing.importPlaceholder')}
style={{
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
padding: '10px 12px', fontSize: 13, fontFamily: 'monospace',
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)',
background: 'var(--bg-input)', resize: 'vertical', lineHeight: 1.5,
}}
/>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<input ref={csvInputRef} type="file" accept=".csv,.txt" style={{ display: 'none' }} onChange={handleCsvFile} />
<button onClick={() => csvInputRef.current?.click()} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
}}>
<Upload size={11} /> {t('packing.importCsv')}
</button>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => setShowImportModal(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={handleBulkImport} disabled={!importText.trim()} style={{
fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)',
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600,
fontFamily: 'inherit', opacity: importText.trim() ? 1 : 0.5,
}}>{t('packing.importAction', { count: parseImportLines(importText).length })}</button>
</div>
</div>
</div>
</div>,
document.body
)}
</div>
)
}
@@ -3,7 +3,7 @@ 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'
interface PhotoGalleryProps {
@@ -17,7 +17,7 @@ interface PhotoGalleryProps {
}
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }: PhotoGalleryProps) {
const { t } = useTranslation()
const { t, language } = useTranslation()
const [lightboxIndex, setLightboxIndex] = useState(null)
const [showUpload, setShowUpload] = useState(false)
const [filterDayId, setFilterDayId] = useState('')
@@ -53,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>
@@ -65,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>
@@ -84,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>
@@ -101,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>
) : (
@@ -146,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
@@ -211,7 +211,7 @@ function PhotoThumbnail({ photo, days, places, onClick }: PhotoThumbnailProps) {
)
}
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' })
}
@@ -227,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 '' }
}
+112 -58
View File
@@ -8,7 +8,7 @@ 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 = {
@@ -50,17 +50,21 @@ interface DayDetailPanelProps {
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 }: DayDetailPanelProps) {
const { t, language } = useTranslation()
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 })
@@ -81,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])
@@ -136,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
@@ -144,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%)',
@@ -268,7 +273,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
</div>
{r.reservation_time?.includes('T') && (
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
{new Date(r.reservation_time).toLocaleTimeString(language === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit', hour12: is12h })}
{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>
)}
@@ -287,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={{
@@ -377,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"
/>
@@ -389,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"
/>
+742 -66
View File
@@ -7,6 +7,7 @@ import ReactDOM from 'react-dom'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react'
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
import { assignmentsApi, reservationsApi } from '../../api/client'
import { downloadTripPDF } from '../PDF/TripPDF'
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
@@ -17,7 +18,7 @@ import { getCategoryIcon } from '../shared/categoryIcons'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { formatDate, formatTime, dayTotalCost } from '../../utils/formatters'
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
import { useDayNotes } from '../../hooks/useDayNotes'
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
@@ -74,6 +75,7 @@ interface DayPlanSidebarProps {
onDeletePlace: (placeId: number) => void
reservations?: Reservation[]
onAddReservation: () => void
onNavigateToFiles?: () => void
}
export default function DayPlanSidebar({
@@ -85,6 +87,7 @@ export default function DayPlanSidebar({
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
reservations = [],
onAddReservation,
onNavigateToFiles,
}: DayPlanSidebarProps) {
const toast = useToast()
const { t, language, locale } = useTranslation()
@@ -108,11 +111,22 @@ export default function DayPlanSidebar({
const [draggingId, setDraggingId] = useState(null)
const [lockedIds, setLockedIds] = useState(new Set())
const [lockHoverId, setLockHoverId] = useState(null)
const [dropTargetKey, setDropTargetKey] = useState(null)
const [dropTargetKey, _setDropTargetKey] = useState(null)
const dropTargetRef = useRef(null)
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
const [dragOverDayId, setDragOverDayId] = useState(null)
const [hoveredId, setHoveredId] = useState(null)
const [transportDetail, setTransportDetail] = useState(null)
const [timeConfirm, setTimeConfirm] = useState<{
dayId: number; fromId: number; time: string;
// For drag & drop reorder
fromType?: string; toType?: string; toId?: number; insertAfter?: boolean;
// For arrow reorder
reorderIds?: number[];
} | null>(null)
const inputRef = useRef(null)
const dragDataRef = useRef(null) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
const dragDataRef = useRef(null)
const initedTransportIds = useRef(new Set<number>()) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
const currency = trip?.currency || 'EUR'
@@ -176,16 +190,137 @@ export default function DayPlanSidebar({
})
}
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
const getTransportForDay = (dayId: number) => {
const day = days.find(d => d.id === dayId)
if (!day?.date) return []
return reservations.filter(r => {
if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false
const resDate = r.reservation_time.split('T')[0]
return resDate === day.date
})
}
const getDayAssignments = (dayId) =>
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
// Helper: parse time string ("HH:MM" or ISO) to minutes since midnight, or null
const parseTimeToMinutes = (time?: string | null): number | null => {
if (!time) return null
// ISO-Format "2025-03-30T09:00:00"
if (time.includes('T')) {
const [h, m] = time.split('T')[1].split(':').map(Number)
return h * 60 + m
}
// Einfaches "HH:MM" Format
const parts = time.split(':').map(Number)
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1]
return null
}
// Compute initial day_plan_position for a transport based on time
const computeTransportPosition = (r, da) => {
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
// Find the last place with time <= transport time
let afterIdx = -1
for (const a of da) {
const pm = parseTimeToMinutes(a.place?.place_time)
if (pm !== null && pm <= minutes) afterIdx = a.order_index
}
// Position: midpoint between afterIdx and afterIdx+1 (leaves room for other items)
return afterIdx >= 0 ? afterIdx + 0.5 : da.length + 0.5
}
// Auto-initialize transport positions on first render if not set
const initTransportPositions = (dayId) => {
const da = getDayAssignments(dayId)
const transport = getTransportForDay(dayId)
const needsInit = transport.filter(r => r.day_plan_position == null && !initedTransportIds.current.has(r.id))
if (needsInit.length === 0) return
const sorted = [...needsInit].sort((a, b) =>
(parseTimeToMinutes(a.reservation_time) ?? 0) - (parseTimeToMinutes(b.reservation_time) ?? 0)
)
const positions = sorted.map((r, idx) => ({
id: r.id,
day_plan_position: computeTransportPosition(r, da) + idx * 0.01,
}))
// Mark as initialized immediately to prevent re-entry
for (const p of positions) {
initedTransportIds.current.add(p.id)
const res = reservations.find(x => x.id === p.id)
if (res) res.day_plan_position = p.day_plan_position
}
// Persist to server (fire and forget)
reservationsApi.updatePositions(tripId, positions).catch(() => {})
}
const getMergedItems = (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 })),
const transport = getTransportForDay(dayId)
// Initialize positions for transports that don't have one yet
if (transport.some(r => r.day_plan_position == null)) {
initTransportPositions(dayId)
}
// Build base list: untimed places + notes sorted by order_index/sort_order
const timedPlaces = da.filter(a => parseTimeToMinutes(a.place?.place_time) !== null)
const freePlaces = da.filter(a => parseTimeToMinutes(a.place?.place_time) === null)
const baseItems = [
...freePlaces.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
].sort((a, b) => a.sortKey - b.sortKey)
// Timed places + transports: compute sortKeys based on time, inserted among base items
const allTimed = [
...timedPlaces.map(a => ({ type: 'place' as const, data: a, minutes: parseTimeToMinutes(a.place?.place_time)! })),
...transport.map(r => ({ type: 'transport' as const, data: r, minutes: parseTimeToMinutes(r.reservation_time) ?? 0 })),
].sort((a, b) => a.minutes - b.minutes)
if (allTimed.length === 0) return baseItems
if (baseItems.length === 0) {
return allTimed.map((item, i) => ({ ...item, sortKey: i }))
}
// Insert timed items among base items using time-to-position mapping.
// Each timed item finds the last base place whose order_index corresponds
// to a reasonable position, then gets a fractional sortKey after it.
const result = [...baseItems]
for (let ti = 0; ti < allTimed.length; ti++) {
const timed = allTimed[ti]
const minutes = timed.minutes
// For transports, use persisted position if available
if (timed.type === 'transport' && timed.data.day_plan_position != null) {
result.push({ type: timed.type, sortKey: timed.data.day_plan_position, data: timed.data })
continue
}
// Find insertion position: after the last base item with time <= this item's time
let insertAfterKey = -Infinity
for (const item of result) {
if (item.type === 'place') {
const pm = parseTimeToMinutes(item.data?.place?.place_time)
if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey
} else if (item.type === 'transport') {
const tm = parseTimeToMinutes(item.data?.reservation_time)
if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey
}
}
const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0
const sortKey = insertAfterKey === -Infinity
? lastKey + 0.5 + ti * 0.01
: insertAfterKey + 0.01 + ti * 0.001
result.push({ type: timed.type, sortKey, data: timed.data })
}
return result.sort((a, b) => a.sortKey - b.sortKey)
}
const openAddNote = (dayId, e) => {
@@ -195,6 +330,41 @@ export default function DayPlanSidebar({
})
}
// Check if a proposed reorder of place IDs would break chronological order
// of ALL timed items (places with time + transport bookings)
const wouldBreakChronology = (dayId: number, newPlaceIds: number[]) => {
const da = getDayAssignments(dayId)
const transport = getTransportForDay(dayId)
// Simulate the merged list with places in new order + transports at their positions
// Places get sequential integer positions
const simItems: { pos: number; minutes: number }[] = []
newPlaceIds.forEach((id, idx) => {
const a = da.find(x => x.id === id)
const m = parseTimeToMinutes(a?.place?.place_time)
if (m !== null) simItems.push({ pos: idx, minutes: m })
})
// Transports: compute where they'd go with the new place order
for (const r of transport) {
const rMin = parseTimeToMinutes(r.reservation_time)
if (rMin === null) continue
// Find the last place (in new order) with time <= transport time
let afterIdx = -1
newPlaceIds.forEach((id, idx) => {
const a = da.find(x => x.id === id)
const pm = parseTimeToMinutes(a?.place?.place_time)
if (pm !== null && pm <= rMin) afterIdx = idx
})
const pos = afterIdx >= 0 ? afterIdx + 0.5 : newPlaceIds.length + 0.5
simItems.push({ pos, minutes: rMin })
}
// Sort by position and check chronological order
simItems.sort((a, b) => a.pos - b.pos)
return !simItems.every((item, i) => i === 0 || item.minutes >= simItems[i - 1].minutes)
}
const openEditNote = (dayId, note, e) => {
e?.stopPropagation()
_openEditNote(dayId, note)
@@ -205,49 +375,180 @@ export default function DayPlanSidebar({
await _deleteNote(dayId, noteId)
}
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
const m = getMergedItems(dayId)
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
// Unified reorder: assigns positions to ALL item types based on new visual order
const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => {
// Places get sequential integer positions (0, 1, 2, ...)
// Non-place items between place N-1 and place N get fractional positions
const assignmentIds: number[] = []
const noteUpdates: { id: number; sort_order: number }[] = []
const transportUpdates: { id: number; day_plan_position: number }[] = []
// Neue Reihenfolge erstellen — VOR dem Ziel einfügen (Standard), oder NACH dem Ziel wenn insertAfter
const newOrder = [...m]
const [moved] = newOrder.splice(fromIdx, 1)
let adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx
if (insertAfter) adjustedTo += 1
newOrder.splice(adjustedTo, 0, moved)
// Orte: neuer order_index über onReorder
const assignmentIds = newOrder.filter(i => i.type === 'place').map(i => i.data.id)
// Notizen: sort_order muss ZWISCHEN den umgebenden order_indices der Orte liegen, niemals gleich sein.
// Formel: Notiz zwischen placesBefore-1 und placesBefore ergibt (placesBefore - 1) + rank/(count+1)
// z.B. einzelne Notiz nach 2 Orten → (2-1) + 0.5 = 1.5 (zwischen order_index 1 und 2)
const groups = {}
let pc = 0
newOrder.forEach(item => {
if (item.type === 'place') { pc++ }
else { if (!groups[pc]) groups[pc] = []; groups[pc].push(item.data.id) }
})
const noteChanges = []
Object.entries(groups).forEach(([pb, ids]) => {
ids.forEach((id, i) => {
noteChanges.push({ id, sort_order: (Number(pb) - 1) + (i + 1) / (ids.length + 1) })
})
})
let placeCount = 0
let i = 0
while (i < newOrder.length) {
if (newOrder[i].type === 'place') {
assignmentIds.push(newOrder[i].data.id)
placeCount++
i++
} else {
// Collect consecutive non-place items
const group: { type: string; data: any }[] = []
while (i < newOrder.length && newOrder[i].type !== 'place') {
group.push(newOrder[i])
i++
}
// Fractional positions between (placeCount-1) and placeCount
const base = placeCount > 0 ? placeCount - 1 : -1
group.forEach((g, idx) => {
const pos = base + (idx + 1) / (group.length + 1)
if (g.type === 'note') noteUpdates.push({ id: g.data.id, sort_order: pos })
else if (g.type === 'transport') transportUpdates.push({ id: g.data.id, day_plan_position: pos })
})
}
}
try {
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
for (const n of noteChanges) {
for (const n of noteUpdates) {
await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
}
if (transportUpdates.length) {
for (const tu of transportUpdates) {
const res = reservations.find(r => r.id === tu.id)
if (res) res.day_plan_position = tu.day_plan_position
}
await reservationsApi.updatePositions(tripId, transportUpdates)
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
// Transport bookings themselves cannot be dragged
if (fromType === 'transport') {
toast.error(t('dayplan.cannotReorderTransport'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
return
}
const m = getMergedItems(dayId)
// Check if a timed place is being moved → would it break chronological order?
if (fromType === 'place') {
const fromItem = m.find(i => i.type === 'place' && i.data.id === fromId)
const fromMinutes = parseTimeToMinutes(fromItem?.data?.place?.place_time)
if (fromItem && fromMinutes !== null) {
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
if (fromIdx !== -1 && toIdx !== -1) {
const simulated = [...m]
const [moved] = simulated.splice(fromIdx, 1)
let insertIdx = simulated.findIndex(i => i.type === toType && i.data.id === toId)
if (insertIdx === -1) insertIdx = simulated.length
if (insertAfter) insertIdx += 1
simulated.splice(insertIdx, 0, moved)
const timedInOrder = simulated
.map(i => {
if (i.type === 'transport') return parseTimeToMinutes(i.data?.reservation_time)
if (i.type === 'place') return parseTimeToMinutes(i.data?.place?.place_time)
return null
})
.filter(t => t !== null)
const isChronological = timedInOrder.every((t, i) => i === 0 || t >= timedInOrder[i - 1])
if (!isChronological) {
const placeTime = fromItem.data.place.place_time
const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime
setTimeConfirm({ dayId, fromType, fromId, toType, toId, insertAfter, time: timeStr })
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
return
}
}
}
}
// Build new order: remove the dragged item, insert at target position
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) {
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
return
}
const newOrder = [...m]
const [moved] = newOrder.splice(fromIdx, 1)
let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId)
if (adjustedTo === -1) adjustedTo = newOrder.length
if (insertAfter) adjustedTo += 1
newOrder.splice(adjustedTo, 0, moved)
await applyMergedOrder(dayId, newOrder)
setDraggingId(null)
setDropTargetKey(null)
dragDataRef.current = null
}
const confirmTimeRemoval = async () => {
if (!timeConfirm) return
const saved = { ...timeConfirm }
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter } = saved
setTimeConfirm(null)
// Remove time from assignment
try {
await assignmentsApi.updateTime(tripId, fromId, { place_time: null, end_time: null })
const key = String(dayId)
const currentAssignments = { ...assignments }
if (currentAssignments[key]) {
currentAssignments[key] = currentAssignments[key].map(a =>
a.id === fromId ? { ...a, place: { ...a.place, place_time: null, end_time: null } } : a
)
tripStore.setAssignments(currentAssignments)
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Unknown error')
return
}
// Build new merged order from either arrow reorderIds or drag & drop params
const m = getMergedItems(dayId)
if (reorderIds) {
// Arrow reorder: rebuild merged list with places in the new order,
// keeping transports and notes at their relative positions
const newMerged: typeof m = []
let rIdx = 0
for (const item of m) {
if (item.type === 'place') {
// Replace with the place from reorderIds at this position
const nextId = reorderIds[rIdx++]
const replacement = m.find(i => i.type === 'place' && i.data.id === nextId)
if (replacement) newMerged.push(replacement)
} else {
newMerged.push(item)
}
}
await applyMergedOrder(dayId, newMerged)
return
}
// Drag & drop reorder
if (fromType && toType) {
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
const newOrder = [...m]
const [moved] = newOrder.splice(fromIdx, 1)
let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId)
if (adjustedTo === -1) adjustedTo = newOrder.length
if (insertAfter) adjustedTo += 1
newOrder.splice(adjustedTo, 0, moved)
await applyMergedOrder(dayId, newOrder)
}
}
const moveNote = async (dayId, noteId, direction) => {
await _moveNote(dayId, noteId, direction, getMergedItems)
}
@@ -396,7 +697,7 @@ export default function DayPlanSidebar({
notes.map(n => ({ ...n, day_id: Number(dayId) }))
)
try {
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, t, locale })
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale })
} catch (e) {
console.error('PDF error:', e)
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
@@ -413,6 +714,34 @@ export default function DayPlanSidebar({
<FileDown size={13} strokeWidth={2} />
{t('dayplan.pdf')}
</button>
<button
onClick={async () => {
try {
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` },
})
if (!res.ok) throw new Error()
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${trip?.title || 'trip'}.ics`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('ICS export failed') }
}}
title={t('dayplan.icsTooltip')}
style={{
flexShrink: 0, display: 'flex', alignItems: 'center', gap: 5,
padding: '5px 10px', borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'none',
color: 'var(--text-muted)', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<FileDown size={13} strokeWidth={2} />
ICS
</button>
</div>
</div>
@@ -491,13 +820,33 @@ export default function DayPlanSidebar({
<Pencil size={10} strokeWidth={1.8} color="var(--text-secondary)" />
</button>
{(() => {
const acc = accommodations.find(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
return acc ? (
<span onClick={e => { e.stopPropagation(); onPlaceClick(acc.place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)', flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}>
<Hotel size={8} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</span>
</span>
) : null
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
// Sort: check-out first, then ongoing stays, then check-in last
.sort((a, b) => {
const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id
const bIsOut = b.end_day_id === day.id && b.start_day_id !== day.id
const aIsIn = a.start_day_id === day.id
const bIsIn = b.start_day_id === day.id
if (aIsOut && !bIsOut) return -1
if (!aIsOut && bIsOut) return 1
if (aIsIn && !bIsIn) return 1
if (!aIsIn && bIsIn) return -1
return 0
})
if (dayAccs.length === 0) return null
return dayAccs.map(acc => {
const isCheckIn = acc.start_day_id === day.id
const isCheckOut = acc.end_day_id === day.id
const bg = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.08)' : isCheckIn ? 'rgba(34,197,94,0.08)' : 'var(--bg-secondary)'
const border = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.2)' : isCheckIn ? 'rgba(34,197,94,0.2)' : 'var(--border-primary)'
const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-muted)'
return (
<span key={acc.id} onClick={e => { e.stopPropagation(); onPlaceClick(acc.place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: bg, border: `1px solid ${border}`, flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}>
<Hotel size={8} style={{ color: iconColor, flexShrink: 0 }} />
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</span>
</span>
)
})
})()}
</div>
)}
@@ -534,11 +883,34 @@ export default function DayPlanSidebar({
{isExpanded && (
<div
style={{ background: 'var(--bg-hover)', paddingTop: 6 }}
onDragOver={e => { e.preventDefault(); if (draggingId) setDropTargetKey(`end-${day.id}`) }}
onDragOver={e => { e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }}
onDrop={e => {
e.preventDefault()
const { assignmentId, noteId, fromDayId } = getDragData(e)
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
// Drop on transport card (detected via dropTargetRef for sync accuracy)
if (dropTargetRef.current?.startsWith('transport-')) {
const transportId = Number(dropTargetRef.current.replace('transport-', ''))
if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id)
} else if (assignmentId && fromDayId !== day.id) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (assignmentId) {
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId)
} else if (noteId && fromDayId !== day.id) {
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId)
}
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
return
}
if (!assignmentId && !noteId && !placeId) { dragDataRef.current = null; window.__dragData = null; return }
if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id)
setDropTargetKey(null); window.__dragData = null; return
}
if (assignmentId && fromDayId !== day.id) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
@@ -569,7 +941,7 @@ export default function DayPlanSidebar({
</div>
) : (
merged.map((item, idx) => {
const itemKey = item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`
const itemKey = item.type === 'transport' ? `transport-${item.data.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey
if (item.type === 'place') {
@@ -582,20 +954,39 @@ export default function DayPlanSidebar({
const isHovered = hoveredId === assignment.id
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
const moveUp = (e) => {
e.stopPropagation()
if (placeIdx === 0) return
const ids = placeItems.map(i => i.data.id)
;[ids[placeIdx - 1], ids[placeIdx]] = [ids[placeIdx], ids[placeIdx - 1]]
onReorder(day.id, ids)
}
const moveDown = (e) => {
e.stopPropagation()
if (placeIdx === placeItems.length - 1) return
const ids = placeItems.map(i => i.data.id)
;[ids[placeIdx], ids[placeIdx + 1]] = [ids[placeIdx + 1], ids[placeIdx]]
onReorder(day.id, ids)
const arrowMove = (direction: 'up' | 'down') => {
const m = getMergedItems(day.id)
const myIdx = m.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
if (myIdx === -1) return
const targetIdx = direction === 'up' ? myIdx - 1 : myIdx + 1
if (targetIdx < 0 || targetIdx >= m.length) return
// Build new order: swap this item with its neighbor in the merged list
const newOrder = [...m]
;[newOrder[myIdx], newOrder[targetIdx]] = [newOrder[targetIdx], newOrder[myIdx]]
// Check chronological order of all timed items in the new order
const placeTime = place.place_time
if (parseTimeToMinutes(placeTime) !== null) {
const timedInNewOrder = newOrder
.map(i => {
if (i.type === 'transport') return parseTimeToMinutes(i.data?.reservation_time)
if (i.type === 'place') return parseTimeToMinutes(i.data?.place?.place_time)
return null
})
.filter(t => t !== null)
const isChronological = timedInNewOrder.every((t, i) => i === 0 || t >= timedInNewOrder[i - 1])
if (!isChronological) {
const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime
// Store the new merged order for confirm action
setTimeConfirm({ dayId: day.id, fromId: assignment.id, time: timeStr, reorderIds: newOrder.filter(i => i.type === 'place').map(i => i.data.id) })
return
}
}
applyMergedOrder(day.id, newOrder)
}
const moveUp = (e) => { e.stopPropagation(); arrowMove('up') }
const moveDown = (e) => { e.stopPropagation(); arrowMove('down') }
return (
<React.Fragment key={`place-${assignment.id}`}>
@@ -735,6 +1126,14 @@ export default function DayPlanSidebar({
{res.reservation_end_time && ` ${res.reservation_end_time}`}
</span>
)}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta) return null
if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
if (meta.train_number) return <span style={{ fontWeight: 400 }}>{meta.train_number}</span>
return null
})()}
</div>
)
})()}
@@ -747,7 +1146,7 @@ export default function DayPlanSidebar({
marginLeft: pi > 0 ? -4 : 0, flexShrink: 0,
overflow: 'hidden',
}}>
{p.avatar ? <img src={p.avatar} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : p.username?.[0]?.toUpperCase()}
{p.avatar ? <img src={`/uploads/avatars/${p.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : p.username?.[0]?.toUpperCase()}
</div>
))}
{assignment.participants.length > 5 && (
@@ -757,10 +1156,10 @@ export default function DayPlanSidebar({
)}
</div>
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
<button onClick={moveUp} disabled={placeIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === 0 ? 'default' : 'pointer', color: placeIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
<button onClick={moveUp} disabled={idx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', color: idx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
<ChevronUp size={12} strokeWidth={2} />
</button>
<button onClick={moveDown} disabled={placeIdx === placeItems.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === placeItems.length - 1 ? 'default' : 'pointer', color: placeIdx === placeItems.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
<button onClick={moveDown} disabled={idx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === merged.length - 1 ? 'default' : 'pointer', color: idx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
<ChevronDown size={12} strokeWidth={2} />
</button>
</div>
@@ -769,6 +1168,90 @@ export default function DayPlanSidebar({
)
}
// Transport booking (flight, train, bus, car, cruise)
if (item.type === 'transport') {
const res = item.data
const TransportIcon = RES_ICONS[res.type] || Ticket
const color = '#3b82f6'
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
const isTransportHovered = hoveredId === `transport-${res.id}`
// Subtitle aus Metadaten zusammensetzen
let subtitle = ''
if (res.type === 'flight') {
const parts = [meta.airline, meta.flight_number].filter(Boolean)
if (meta.departure_airport || meta.arrival_airport)
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
subtitle = parts.join(' · ')
} else if (res.type === 'train') {
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ')
}
return (
<React.Fragment key={`transport-${res.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div
onClick={() => setTransportDetail(res)}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDropTargetKey(`transport-${res.id}`) }}
onDrop={e => {
e.preventDefault(); e.stopPropagation()
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id)
} else if (fromAssignmentId && fromDayId !== day.id) {
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id)
} else if (noteId && fromDayId !== day.id) {
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id)
}
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
}}
onMouseEnter={() => setHoveredId(`transport-${res.id}`)}
onMouseLeave={() => setHoveredId(null)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px 7px 10px',
margin: '1px 8px',
borderRadius: 6,
border: `1px solid ${color}33`,
background: isTransportHovered ? `${color}12` : `${color}08`,
cursor: 'pointer', userSelect: 'none',
transition: 'background 0.1s',
}}
>
<div style={{
width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: '50%', background: `${color}18`,
}}>
<TransportIcon size={14} strokeWidth={1.8} color={color} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{res.title}
</span>
{res.reservation_time?.includes('T') && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
<Clock size={9} strokeWidth={2} />
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
{res.reservation_end_time?.includes('T') && ` ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}
</span>
)}
</div>
{subtitle && (
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{subtitle}
</div>
)}
</div>
</div>
</React.Fragment>
)
}
// Notizkarte
const note = item.data
const isNoteHovered = hoveredId === `note-${note.id}`
@@ -975,11 +1458,204 @@ export default function DayPlanSidebar({
document.body
))}
{/* Confirm: remove time when reordering a timed place */}
{timeConfirm && 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={() => setTimeConfirm(null)}>
<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)',
}}>
<Clock size={18} strokeWidth={1.8} color="#ef4444" />
</div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
{t('dayplan.confirmRemoveTimeTitle')}
</div>
</div>
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
{t('dayplan.confirmRemoveTimeBody', { time: timeConfirm.time })}
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
<button onClick={() => setTimeConfirm(null)} 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={confirmTimeRemoval} 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
)}
{/* Transport-Detail-Modal */}
{transportDetail && 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={() => setTransportDetail(null)}>
<div style={{
width: 380, maxHeight: '80vh', overflowY: 'auto',
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: 14,
}} onClick={e => e.stopPropagation()}>
{(() => {
const res = transportDetail
const TransportIcon = RES_ICONS[res.type] || Ticket
const TRANSPORT_COLORS = { flight: '#3b82f6', train: '#06b6d4', bus: '#f59e0b', car: '#6b7280', cruise: '#0ea5e9' }
const color = TRANSPORT_COLORS[res.type] || 'var(--text-muted)'
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
const detailFields = []
if (res.type === 'flight') {
if (meta.airline) detailFields.push({ label: t('reservations.meta.airline'), value: meta.airline })
if (meta.flight_number) detailFields.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number })
if (meta.departure_airport) detailFields.push({ label: t('reservations.meta.from'), value: meta.departure_airport })
if (meta.arrival_airport) detailFields.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
if (meta.seat) detailFields.push({ label: t('reservations.meta.seat'), value: meta.seat })
} else if (res.type === 'train') {
if (meta.train_number) detailFields.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
if (meta.platform) detailFields.push({ label: t('reservations.meta.platform'), value: meta.platform })
if (meta.seat) detailFields.push({ label: t('reservations.meta.seat'), value: meta.seat })
}
if (res.confirmation_number) detailFields.push({ label: t('reservations.confirmationCode'), value: res.confirmation_number, sensitive: true })
if (res.location) detailFields.push({ label: t('reservations.locationAddress'), value: res.location })
return (
<>
{/* Header */}
<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: `${color}18`,
}}>
<TransportIcon size={18} strokeWidth={1.8} color={color} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{res.title}</div>
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2 }}>
{res.reservation_time?.includes('T')
? new Date(res.reservation_time).toLocaleString(locale, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
: res.reservation_time
? new Date(res.reservation_time + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
: ''
}
{res.reservation_end_time?.includes('T') && ` ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}
</div>
</div>
<div style={{
padding: '3px 8px', borderRadius: 6, fontSize: 10, fontWeight: 600,
background: res.status === 'confirmed' ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)',
color: res.status === 'confirmed' ? '#16a34a' : '#d97706',
}}>
{(res.status === 'confirmed' ? t('planner.resConfirmed') : t('planner.resPending')).replace(/\s*·\s*$/, '')}
</div>
</div>
{/* Detail-Felder */}
{detailFields.length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{detailFields.map((f, i) => {
const shouldBlur = f.sensitive && useSettingsStore.getState().settings.blur_booking_codes
return (
<div key={i} style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{f.label}</div>
<div
onMouseEnter={e => { if (shouldBlur) e.currentTarget.style.filter = 'none' }}
onMouseLeave={e => { if (shouldBlur) e.currentTarget.style.filter = 'blur(5px)' }}
onClick={e => { if (shouldBlur) { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(5px)' : 'none' } }}
style={{
fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', wordBreak: 'break-word',
filter: shouldBlur ? 'blur(5px)' : 'none', transition: 'filter 0.2s',
cursor: shouldBlur ? 'pointer' : 'default',
userSelect: shouldBlur ? 'none' : 'auto',
}}
>{f.value}</div>
</div>
)
})}
</div>
)}
{/* Notizen */}
{res.notes && (
<div style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
<div style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{res.notes}</div>
</div>
)}
{/* Dateien */}
{(() => {
const resFiles = (tripStore.files || []).filter(f =>
!f.deleted_at && (
f.reservation_id === res.id ||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(res.id))
)
)
if (resFiles.length === 0) return null
return (
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 6 }}>{t('files.title')}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{resFiles.map(f => (
<div key={f.id}
onClick={() => { setTransportDetail(null); onNavigateToFiles?.() }}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px',
background: 'var(--bg-tertiary)', borderRadius: 8, cursor: 'pointer',
transition: 'background 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
>
<FileText size={14} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{f.original_name}
</span>
<ExternalLink size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
</div>
))}
</div>
</div>
)
})()}
{/* Schließen */}
<div style={{ textAlign: 'right' }}>
<button onClick={() => setTransportDetail(null)} style={{
fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)',
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
}}>
{t('common.close')}
</button>
</div>
</>
)
})()}
</div>
</div>,
document.body
)}
{/* Budget-Fußzeile */}
{totalCost > 0 && (
<div style={{ flexShrink: 0, padding: '10px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{t('dayplan.totalCost')}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{totalCost.toFixed(2)} {currency}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{totalCost.toFixed(currencyDecimals(currency))} {currency}</span>
</div>
)}
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
@@ -42,6 +42,7 @@ interface PlaceFormModalProps {
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
@@ -50,7 +51,7 @@ interface PlaceFormModalProps {
}
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)
@@ -81,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 }))
@@ -95,6 +104,24 @@ 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: unknown) {
@@ -112,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('')
@@ -269,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"
/>
@@ -20,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
}
@@ -122,12 +120,15 @@ interface PlaceInspectorProps {
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, onUpdatePlace,
leftWidth = 0, rightWidth = 0,
}: PlaceInspectorProps) {
const { t, locale, language } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
@@ -138,7 +139,7 @@ export default function PlaceInspector({
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
@@ -171,7 +172,7 @@ 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 as HTMLInputElement).files || [])
@@ -198,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",
}}
@@ -314,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)"
@@ -327,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>
)}
@@ -391,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>
)
})()}
@@ -502,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 }} />
+119 -23
View File
@@ -1,15 +1,20 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { useState } from 'react'
import { useState, useRef, useMemo, useCallback } from 'react'
import DOM from 'react-dom'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation } from 'lucide-react'
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
@@ -22,31 +27,59 @@ interface PlacesSidebarProps {
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)
@@ -66,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 }}>
@@ -100,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 */}
@@ -172,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,4 +1,7 @@
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'
@@ -6,7 +9,7 @@ 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 } from '../../types'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
@@ -58,21 +61,31 @@ interface ReservationModalProps {
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 }: ReservationModalProps) {
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: '', location: '', confirmation_number: '',
notes: '', assignment_id: '',
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),
@@ -81,6 +94,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
useEffect(() => {
if (reservation) {
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
setForm({
title: reservation.title || '',
type: reservation.type || 'other',
@@ -91,12 +105,28 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
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: '',
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([])
}
@@ -109,10 +139,41 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (!form.title.trim()) return
setIsSaving(true)
try {
const saved = await onSave({
...form,
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()
@@ -151,7 +212,13 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
}
}
const attachedFiles = reservation?.id ? files.filter(f => f.reservation_id === reservation.id) : []
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,
@@ -190,7 +257,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
</div>
{/* Assignment Picker + Date */}
{/* Assignment Picker + Date (hidden for hotels) */}
{form.type !== 'hotel' && (
<div style={{ display: 'flex', gap: 8 }}>
{assignmentOptions.length > 0 && (
<div style={{ flex: 1, minWidth: 0 }}>
@@ -231,24 +299,29 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
/>
</div>
</div>
)}
{/* Start Time + End Time + Status */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.startTime')}</label>
<CustomTimePicker
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
onChange={t => {
const [d] = (form.reservation_time || '').split('T')
const date = d || new Date().toISOString().split('T')[0]
set('reservation_time', t ? `${date}T${t}` : date)
}}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.endTime')}</label>
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
</div>
{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
@@ -277,6 +350,112 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</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>
@@ -294,11 +473,23 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<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>
)}
<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) => (
@@ -312,14 +503,56 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</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 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>
@@ -340,5 +573,5 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
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' })
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short' })
}
@@ -1,4 +1,5 @@
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'
@@ -62,18 +63,21 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
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')) }
}
@@ -104,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} />
@@ -112,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) && (
@@ -134,13 +138,51 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{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>
@@ -151,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>
@@ -192,6 +243,46 @@ 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>
)
}
+65 -3
View File
@@ -1,7 +1,9 @@
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'
@@ -20,6 +22,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const fileRef = useRef(null)
const toast = useToast()
const { t } = useTranslation()
const currentUser = useAuthStore(s => s.user)
const [formData, setFormData] = useState({
title: '',
@@ -32,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) {
@@ -47,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) => {
@@ -65,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 {
@@ -212,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')}
@@ -250,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,9 +1,9 @@
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'
@@ -32,6 +32,129 @@ function Avatar({ username, avatarUrl, size = 32 }: AvatarProps) {
)
}
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
@@ -123,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)' }}>
@@ -228,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>
@@ -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>
+19 -12
View File
@@ -3,10 +3,14 @@ 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
@@ -18,16 +22,18 @@ interface VacayMonthCardProps {
onCellClick: (date: string) => void
companyMode: boolean
blockWeekends: boolean
weekendDays?: number[]
}
export default function VacayMonthCard({
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
onCellClick, companyMode, blockWeekends
onCellClick, companyMode, blockWeekends, weekendDays = [0, 6]
}: VacayMonthCardProps) {
const { language } = useTranslation()
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN
const monthNames = language === 'de' ? MONTHS_DE : MONTHS_EN
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()
@@ -47,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)' }}>
@@ -65,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] || []
@@ -86,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 && (
@@ -115,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}
+273 -72
View File
@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react'
import { MapPin, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe } from 'lucide-react'
import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2 } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n'
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
@@ -13,10 +14,9 @@ interface VacaySettingsProps {
export default function VacaySettings({ onClose }: VacaySettingsProps) {
const { t } = useTranslation()
const toast = useToast()
const { plan, updatePlan, isFused, dissolve, users } = useVacayStore()
const [countries, setCountries] = useState([])
const [regions, setRegions] = useState([])
const [loadingRegions, setLoadingRegions] = useState(false)
const { 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()
@@ -24,7 +24,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
useEffect(() => {
apiClient.get('/addons/vacay/holidays/countries').then(r => {
let displayNames
try { displayNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) } catch { /* */ }
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,
@@ -34,57 +34,9 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
}).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 })
}
const toggle = (key: string) => updatePlan({ [key]: !plan[key] })
return (
<div className="space-y-5">
@@ -97,6 +49,42 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
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}
@@ -136,21 +124,35 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
/>
{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
{(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>
)}
@@ -197,11 +199,11 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
}
interface SettingToggleProps {
icon: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>
icon: LucideIcon
label: string
hint: string
value: boolean
onChange: (value: boolean) => void
onChange: () => void
}
function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingToggleProps) {
@@ -223,3 +225,202 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
</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>
)
}
+4 -5
View File
@@ -103,10 +103,9 @@ export function getHolidays(year: number, bundesland: string = 'NW'): Record<str
return holidays
}
export function isWeekend(dateStr: string): boolean {
export function isWeekend(dateStr: string, weekendDays: number[] = [0, 6]): boolean {
const d = new Date(dateStr + 'T00:00:00')
const day = d.getDay()
return day === 0 || day === 6
return weekendDays.includes(d.getDay())
}
export function getWeekday(dateStr: string): string {
@@ -123,9 +122,9 @@ export function daysInMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate()
}
export function formatDate(dateStr: string): string {
export function formatDate(dateStr: string, locale?: string): string {
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
}
export { BUNDESLAENDER }
@@ -59,9 +59,43 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
const today = new Date()
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,
@@ -75,6 +109,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
<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={{
@@ -107,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%)',
+1
View File
@@ -7,6 +7,7 @@ const sizeClasses: Record<string, string> = {
lg: 'max-w-lg',
xl: 'max-w-2xl',
'2xl': 'max-w-4xl',
'3xl': 'max-w-5xl',
}
interface ModalProps {
+43 -14
View File
@@ -9,34 +9,62 @@ interface Category {
}
interface PlaceAvatarProps {
place: Pick<Place, 'id' | 'name' | 'image_url' | 'google_place_id'>
place: Pick<Place, 'id' | 'name' | 'image_url' | 'google_place_id' | 'osm_id' | 'lat' | 'lng'>
size?: number
category?: Category | null
}
const googlePhotoCache = new Map<string, string>()
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>>()
export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
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 }
if (!place.google_place_id) { setPhotoSrc(null); return }
const photoId = place.google_place_id || place.osm_id
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
if (googlePhotoCache.has(place.google_place_id)) {
setPhotoSrc(googlePhotoCache.get(place.google_place_id)!)
const cacheKey = photoId || `${place.lat},${place.lng}`
if (photoCache.has(cacheKey)) {
const cached = photoCache.get(cacheKey)
if (cached) setPhotoSrc(cached)
return
}
mapsApi.placePhoto(place.google_place_id)
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 }) => {
if (data.photoUrl) {
googlePhotoCache.set(place.google_place_id!, data.photoUrl)
setPhotoSrc(data.photoUrl)
}
const url = data.photoUrl || null
photoCache.set(cacheKey, url)
if (url) setPhotoSrc(url)
notifyListeners(cacheKey, url)
photoInFlight.delete(cacheKey)
})
.catch(() => {})
}, [place.id, place.image_url, place.google_place_id])
.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)
@@ -57,6 +85,7 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
<img
src={photoSrc}
alt={place.name}
loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => setPhotoSrc(null)}
/>
@@ -69,4 +98,4 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
</div>
)
}
})
+82 -34
View File
@@ -19,6 +19,13 @@ declare global {
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[]>([])
@@ -31,7 +38,7 @@ export function ToastContainer() {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 300)
}, 400)
}, duration)
}
@@ -42,7 +49,7 @@ export function ToastContainer() {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 300)
}, 400)
}, [])
useEffect(() => {
@@ -51,42 +58,83 @@ export function ToastContainer() {
}, [addToast])
const icons: Record<ToastType, React.ReactNode> = {
success: <CheckCircle className="w-5 h-5 text-emerald-500 flex-shrink-0" />,
error: <XCircle className="w-5 h-5 text-red-500 flex-shrink-0" />,
warning: <AlertCircle className="w-5 h-5 text-amber-500 flex-shrink-0" />,
info: <Info className="w-5 h-5 text-blue-500 flex-shrink-0" />,
}
const bgColors: Record<ToastType, string> = {
success: 'bg-white border-l-4 border-emerald-500',
error: 'bg-white border-l-4 border-red-500',
warning: 'bg-white border-l-4 border-amber-500',
info: 'bg-white border-l-4 border-blue-500',
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 (
<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"
<>
<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',
}}
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
{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>
</>
)
}
+54 -9
View File
@@ -1,11 +1,51 @@
import React, { createContext, useContext, useMemo, ReactNode } from 'react'
import React, { createContext, useContext, useEffect, useMemo, ReactNode } from 'react'
import { useSettingsStore } from '../store/settingsStore'
import de from './translations/de'
import en from './translations/en'
import es from './translations/es'
import fr from './translations/fr'
import hu from './translations/hu'
import it from './translations/it'
import ru from './translations/ru'
import zh from './translations/zh'
import nl from './translations/nl'
import ar from './translations/ar'
import br from './translations/br'
import cs from './translations/cs'
type TranslationStrings = Record<string, string>
type TranslationStrings = Record<string, string | { name: string; category: string }[]>
const translations: Record<string, TranslationStrings> = { de, en }
export const SUPPORTED_LANGUAGES = [
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Español' },
{ value: 'fr', label: 'Français' },
{ value: 'hu', label: 'Magyar' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'br', label: 'Português (Brasil)' },
{ value: 'cs', label: 'Česky' },
{ value: 'ru', label: 'Русский' },
{ value: 'zh', label: '中文' },
{ value: 'it', label: 'Italiano' },
{ value: 'ar', label: 'العربية' },
] as const
const translations: Record<string, TranslationStrings> = { de, en, es, fr, hu, it, ru, zh, nl, ar, br, cs }
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', hu: 'hu-HU', it: 'it-IT', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA', br: 'pt-BR', cs: 'cs-CZ' }
const RTL_LANGUAGES = new Set(['ar'])
export function getLocaleForLanguage(language: string): string {
return LOCALES[language] || LOCALES.en
}
export function getIntlLanguage(language: string): string {
if (language === 'br') return 'pt-BR'
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'nl', 'ar', 'cs'].includes(language) ? language : 'en'
}
export function isRtlLanguage(language: string): boolean {
return RTL_LANGUAGES.has(language)
}
interface TranslationContextValue {
t: (key: string, params?: Record<string, string | number>) => string
@@ -13,21 +53,26 @@ interface TranslationContextValue {
locale: string
}
const TranslationContext = createContext<TranslationContextValue>({ t: (k: string) => k, language: 'de', locale: 'de-DE' })
const TranslationContext = createContext<TranslationContextValue>({ t: (k: string) => k, language: 'en', locale: 'en-US' })
interface TranslationProviderProps {
children: ReactNode
}
export function TranslationProvider({ children }: TranslationProviderProps) {
const language = useSettingsStore((s) => s.settings.language) || 'de'
const language = useSettingsStore((s) => s.settings.language) || 'en'
useEffect(() => {
document.documentElement.lang = language
document.documentElement.dir = isRtlLanguage(language) ? 'rtl' : 'ltr'
}, [language])
const value = useMemo((): TranslationContextValue => {
const strings = translations[language] || translations.de
const fallback = translations.de
const strings = translations[language] || translations.en
const fallback = translations.en
function t(key: string, params?: Record<string, string | number>): string {
let val: string = strings[key] ?? fallback[key] ?? key
let val: string = (strings[key] ?? fallback[key] ?? key) as string
if (params) {
Object.entries(params).forEach(([k, v]) => {
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
@@ -36,7 +81,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
return val
}
return { t, language, locale: language === 'en' ? 'en-US' : 'de-DE' }
return { t, language, locale: getLocaleForLanguage(language) }
}, [language])
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
+8 -1
View File
@@ -1 +1,8 @@
export { TranslationProvider, useTranslation } from './TranslationContext'
export {
TranslationProvider,
useTranslation,
getLocaleForLanguage,
getIntlLanguage,
isRtlLanguage,
SUPPORTED_LANGUAGES,
} from './TranslationContext'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+335 -14
View File
@@ -1,4 +1,4 @@
const de: Record<string, string> = {
const de: Record<string, string | { name: string; category: string }[]> = {
// Allgemein
'common.save': 'Speichern',
'common.cancel': 'Abbrechen',
@@ -51,9 +51,18 @@ const de: Record<string, string> = {
'dashboard.subtitle.activeMany': '{count} aktive Reisen',
'dashboard.subtitle.archivedSuffix': ' · {count} archiviert',
'dashboard.newTrip': 'Neue Reise',
'dashboard.gridView': 'Kachelansicht',
'dashboard.listView': 'Listenansicht',
'dashboard.currency': 'Währung',
'dashboard.timezone': 'Zeitzonen',
'dashboard.localTime': 'Lokal',
'dashboard.timezoneCustomTitle': 'Eigene Zeitzone',
'dashboard.timezoneCustomLabelPlaceholder': 'Bezeichnung (optional)',
'dashboard.timezoneCustomTzPlaceholder': 'z.B. America/New_York',
'dashboard.timezoneCustomAdd': 'Hinzufügen',
'dashboard.timezoneCustomErrorEmpty': 'Zeitzone eingeben',
'dashboard.timezoneCustomErrorInvalid': 'Ungültige Zeitzone. Format: Europe/Berlin',
'dashboard.timezoneCustomErrorDuplicate': 'Bereits hinzugefügt',
'dashboard.emptyTitle': 'Noch keine Reisen',
'dashboard.emptyText': 'Erstelle deine erste Reise und beginne mit der Planung von Orten, Tagesabläufen und Packlisten.',
'dashboard.emptyButton': 'Erste Reise erstellen',
@@ -92,7 +101,9 @@ const de: Record<string, string> = {
'dashboard.endDate': 'Enddatum',
'dashboard.noDateHint': 'Kein Datum gesetzt — es werden 7 Standardtage erstellt. Du kannst das jederzeit ändern.',
'dashboard.coverImage': 'Titelbild',
'dashboard.addCoverImage': 'Titelbild hinzufügen',
'dashboard.addCoverImage': 'Titelbild hinzufügen (oder per Drag & Drop)',
'dashboard.addMembers': 'Reisebegleiter',
'dashboard.addMember': 'Mitglied hinzufügen',
'dashboard.coverSaved': 'Titelbild gespeichert',
'dashboard.coverUploadError': 'Fehler beim Hochladen',
'dashboard.coverRemoveError': 'Fehler beim Entfernen',
@@ -128,6 +139,50 @@ const de: Record<string, string> = {
'settings.temperature': 'Temperatureinheit',
'settings.timeFormat': 'Zeitformat',
'settings.routeCalculation': 'Routenberechnung',
'settings.blurBookingCodes': 'Buchungscodes verbergen',
'settings.notifications': 'Benachrichtigungen',
'settings.notifyTripInvite': 'Trip-Einladungen',
'settings.notifyBookingChange': 'Buchungsänderungen',
'settings.notifyTripReminder': 'Trip-Erinnerungen',
'settings.notifyVacayInvite': 'Vacay Fusion-Einladungen',
'settings.notifyPhotosShared': 'Geteilte Fotos (Immich)',
'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)',
'settings.notifyPackingTagged': 'Packliste: Zuweisungen',
'settings.notifyWebhook': 'Webhook-Benachrichtigungen',
'admin.smtp.title': 'E-Mail & Benachrichtigungen',
'admin.smtp.hint': 'SMTP-Konfiguration für E-Mail-Benachrichtigungen. Optional: Webhook-URL für Discord, Slack, etc.',
'admin.smtp.testButton': 'Test-E-Mail senden',
'admin.smtp.testSuccess': 'Test-E-Mail erfolgreich gesendet',
'admin.smtp.testFailed': 'Test-E-Mail fehlgeschlagen',
'dayplan.icsTooltip': 'Kalender exportieren (ICS)',
'share.linkTitle': 'Öffentlicher Link',
'share.linkHint': 'Erstelle einen Link den jeder ohne Login nutzen kann, um diese Reise anzuschauen. Nur lesen — keine Bearbeitung möglich.',
'share.createLink': 'Link erstellen',
'share.deleteLink': 'Link löschen',
'share.createError': 'Link konnte nicht erstellt werden',
'common.copy': 'Kopieren',
'common.copied': 'Kopiert',
'share.permMap': 'Karte & Plan',
'share.permBookings': 'Buchungen',
'share.permPacking': 'Packliste',
'shared.expired': 'Link abgelaufen oder ungültig',
'shared.expiredHint': 'Dieser geteilte Reise-Link ist nicht mehr aktiv.',
'shared.readOnly': 'Nur-Lesen Ansicht',
'shared.tabPlan': 'Plan',
'shared.tabBookings': 'Buchungen',
'shared.tabPacking': 'Packliste',
'shared.tabBudget': 'Budget',
'shared.tabChat': 'Chat',
'shared.days': 'Tage',
'shared.places': 'Orte',
'shared.other': 'Sonstige',
'shared.totalBudget': 'Gesamtbudget',
'shared.messages': 'Nachrichten',
'shared.sharedVia': 'Geteilt über',
'shared.confirmed': 'Bestätigt',
'shared.pending': 'Ausstehend',
'share.permBudget': 'Budget',
'share.permCollab': 'Chat',
'settings.on': 'An',
'settings.off': 'Aus',
'settings.account': 'Konto',
@@ -164,6 +219,22 @@ const de: Record<string, string> = {
'settings.avatarUploaded': 'Profilbild aktualisiert',
'settings.avatarRemoved': 'Profilbild entfernt',
'settings.avatarError': 'Fehler beim Hochladen',
'settings.mfa.title': 'Zwei-Faktor-Authentifizierung (2FA)',
'settings.mfa.description': 'Zusätzlicher Schritt bei der Anmeldung mit E-Mail und Passwort. Nutze eine Authenticator-App (Google Authenticator, Authy, …).',
'settings.mfa.enabled': '2FA ist für dein Konto aktiv.',
'settings.mfa.disabled': '2FA ist nicht aktiviert.',
'settings.mfa.setup': 'Authenticator einrichten',
'settings.mfa.scanQr': 'QR-Code mit der App scannen oder den Schlüssel manuell eingeben.',
'settings.mfa.secretLabel': 'Geheimer Schlüssel (manuell)',
'settings.mfa.codePlaceholder': '6-stelliger Code',
'settings.mfa.enable': '2FA aktivieren',
'settings.mfa.cancelSetup': 'Abbrechen',
'settings.mfa.disableTitle': '2FA deaktivieren',
'settings.mfa.disableHint': 'Passwort und einen aktuellen Code aus der Authenticator-App eingeben.',
'settings.mfa.disable': '2FA deaktivieren',
'settings.mfa.toastEnabled': 'Zwei-Faktor-Authentifizierung aktiviert',
'settings.mfa.toastDisabled': 'Zwei-Faktor-Authentifizierung deaktiviert',
'settings.mfa.demoBlocked': 'In der Demo nicht verfügbar',
// Login
'login.error': 'Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen.',
@@ -191,7 +262,7 @@ const de: Record<string, string> = {
'login.signingIn': 'Anmelden…',
'login.signIn': 'Anmelden',
'login.createAdmin': 'Admin-Konto erstellen',
'login.createAdminHint': 'Erstelle das erste Admin-Konto für NOMAD.',
'login.createAdminHint': 'Erstelle das erste Admin-Konto für TREK.',
'login.createAccount': 'Konto erstellen',
'login.createAccountHint': 'Neues Konto registrieren.',
'login.creating': 'Erstelle…',
@@ -206,7 +277,15 @@ const de: Record<string, string> = {
'login.oidc.invalidState': 'Ungültige Sitzung. Bitte erneut versuchen.',
'login.demoFailed': 'Demo-Login fehlgeschlagen',
'login.oidcSignIn': 'Anmelden mit {name}',
'login.oidcOnly': 'Passwort-Authentifizierung ist deaktiviert. Bitte melde dich über deinen SSO-Anbieter an.',
'login.demoHint': 'Demo ausprobieren — ohne Registrierung',
'login.mfaTitle': 'Zwei-Faktor-Authentifizierung',
'login.mfaSubtitle': 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.',
'login.mfaCodeLabel': 'Bestätigungscode',
'login.mfaCodeRequired': 'Bitte den Code aus der Authenticator-App eingeben.',
'login.mfaHint': 'Google Authenticator, Authy oder eine andere TOTP-App öffnen.',
'login.mfaBack': '← Zurück zur Anmeldung',
'login.mfaVerify': 'Bestätigen',
// Register
'register.passwordMismatch': 'Passwörter stimmen nicht überein',
@@ -236,6 +315,7 @@ const de: Record<string, string> = {
'admin.tabs.users': 'Benutzer',
'admin.tabs.categories': 'Kategorien',
'admin.tabs.backup': 'Backup',
'admin.tabs.audit': 'Audit-Protokoll',
'admin.stats.users': 'Benutzer',
'admin.stats.trips': 'Reisen',
'admin.stats.places': 'Orte',
@@ -264,6 +344,24 @@ const de: Record<string, string> = {
'admin.toast.createError': 'Fehler beim Erstellen des Benutzers',
'admin.toast.fieldsRequired': 'Benutzername, E-Mail und Passwort sind erforderlich',
'admin.createUser': 'Benutzer anlegen',
'admin.invite.title': 'Einladungslinks',
'admin.invite.subtitle': 'Einmal-Links für die Registrierung erstellen',
'admin.invite.create': 'Link erstellen',
'admin.invite.createAndCopy': 'Erstellen & kopieren',
'admin.invite.empty': 'Noch keine Einladungslinks erstellt',
'admin.invite.maxUses': 'Max. Nutzungen',
'admin.invite.expiry': 'Gültig für',
'admin.invite.uses': 'genutzt',
'admin.invite.expiresAt': 'läuft ab am',
'admin.invite.createdBy': 'von',
'admin.invite.active': 'Aktiv',
'admin.invite.expired': 'Abgelaufen',
'admin.invite.usedUp': 'Aufgebraucht',
'admin.invite.copied': 'Einladungslink in Zwischenablage kopiert',
'admin.invite.copyLink': 'Link kopieren',
'admin.invite.deleted': 'Einladungslink gelöscht',
'admin.invite.createError': 'Fehler beim Erstellen des Einladungslinks',
'admin.invite.deleteError': 'Fehler beim Löschen des Einladungslinks',
'admin.tabs.settings': 'Einstellungen',
'admin.allowRegistration': 'Registrierung erlauben',
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
@@ -285,6 +383,8 @@ const de: Record<string, string> = {
'admin.oidcIssuer': 'Issuer URL',
'admin.oidcIssuerHint': 'Die OpenID Connect Issuer URL des Anbieters. z.B. https://accounts.google.com',
'admin.oidcSaved': 'OIDC-Konfiguration gespeichert',
'admin.oidcOnlyMode': 'Passwort-Authentifizierung deaktivieren',
'admin.oidcOnlyModeHint': 'Wenn aktiviert, ist nur SSO-Login erlaubt. Passwort-Login und Registrierung werden blockiert.',
// File Types
'admin.fileTypes': 'Erlaubte Dateitypen',
@@ -292,10 +392,47 @@ const de: Record<string, string> = {
'admin.fileTypesFormat': 'Kommagetrennte Endungen (z.B. jpg,png,pdf,doc). Verwende * um alle Typen zu erlauben.',
'admin.fileTypesSaved': 'Dateityp-Einstellungen gespeichert',
// Packing Templates & Bag Tracking
'admin.bagTracking.title': 'Gepäck-Tracking',
'admin.bagTracking.subtitle': 'Gewicht und Gepäckstück-Zuordnung für Packlisteneinträge aktivieren',
'admin.tabs.config': 'Konfiguration',
'admin.tabs.templates': 'Packvorlagen',
'admin.packingTemplates.title': 'Packvorlagen',
'admin.packingTemplates.subtitle': 'Wiederverwendbare Packlisten für deine Reisen erstellen',
'admin.packingTemplates.create': 'Neue Vorlage',
'admin.packingTemplates.namePlaceholder': 'Vorlagenname (z.B. Strandurlaub)',
'admin.packingTemplates.empty': 'Noch keine Vorlagen erstellt',
'admin.packingTemplates.items': 'Einträge',
'admin.packingTemplates.categories': 'Kategorien',
'admin.packingTemplates.itemName': 'Artikelname',
'admin.packingTemplates.itemCategory': 'Kategorie',
'admin.packingTemplates.categoryName': 'Kategoriename (z.B. Kleidung)',
'admin.packingTemplates.addCategory': 'Kategorie hinzufügen',
'admin.packingTemplates.created': 'Vorlage erstellt',
'admin.packingTemplates.deleted': 'Vorlage gelöscht',
'admin.packingTemplates.loadError': 'Vorlagen konnten nicht geladen werden',
'admin.packingTemplates.createError': 'Vorlage konnte nicht erstellt werden',
'admin.packingTemplates.deleteError': 'Vorlage konnte nicht gelöscht werden',
'admin.packingTemplates.saveError': 'Fehler beim Speichern',
// Addons
'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um NOMAD nach deinen Wünschen anzupassen.',
'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um TREK nach deinen Wünschen anzupassen.',
'admin.addons.catalog.packing.name': 'Packliste',
'admin.addons.catalog.packing.description': 'Checklisten zum Kofferpacken für jede Reise',
'admin.addons.catalog.budget.name': 'Budget',
'admin.addons.catalog.budget.description': 'Ausgaben verfolgen und Reisebudget planen',
'admin.addons.catalog.documents.name': 'Dokumente',
'admin.addons.catalog.documents.description': 'Reisedokumente speichern und verwalten',
'admin.addons.catalog.vacay.name': 'Vacay',
'admin.addons.catalog.vacay.description': 'Persönlicher Urlaubsplaner mit Kalenderansicht',
'admin.addons.catalog.atlas.name': 'Atlas',
'admin.addons.catalog.atlas.description': 'Weltkarte mit besuchten Ländern und Reisestatistiken',
'admin.addons.catalog.collab.name': 'Collab',
'admin.addons.catalog.collab.description': 'Echtzeit-Notizen, Umfragen und Chat für die Reiseplanung',
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
'admin.addons.catalog.memories.description': 'Reisefotos über deine Immich-Instanz teilen',
'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ',
'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.',
'admin.addons.enabled': 'Aktiviert',
@@ -310,7 +447,7 @@ const de: Record<string, string> = {
// Weather info
'admin.weather.title': 'Wetterdaten',
'admin.weather.badge': 'Seit 24. März 2026',
'admin.weather.description': 'NOMAD nutzt Open-Meteo als Wetterdatenquelle. Open-Meteo ist ein kostenloser, quelloffener Wetterdienst — es wird kein API-Schlüssel benötigt.',
'admin.weather.description': 'TREK nutzt Open-Meteo als Wetterdatenquelle. Open-Meteo ist ein kostenloser, quelloffener Wetterdienst — es wird kein API-Schlüssel benötigt.',
'admin.weather.forecast': '16-Tage-Vorhersage',
'admin.weather.forecastDesc': 'Statt bisher 5 Tage (OpenWeatherMap)',
'admin.weather.climate': 'Historische Klimadaten',
@@ -321,6 +458,19 @@ const de: Record<string, string> = {
// GitHub
'admin.tabs.github': 'GitHub',
'admin.audit.subtitle': 'Sicherheitsrelevante und administrative Ereignisse (Backups, Benutzer, MFA, Einstellungen).',
'admin.audit.empty': 'Noch keine Audit-Einträge.',
'admin.audit.refresh': 'Aktualisieren',
'admin.audit.loadMore': 'Mehr laden',
'admin.audit.showing': '{count} geladen · {total} gesamt',
'admin.audit.col.time': 'Zeit',
'admin.audit.col.user': 'Benutzer',
'admin.audit.col.action': 'Aktion',
'admin.audit.col.resource': 'Ressource',
'admin.audit.col.ip': 'IP',
'admin.audit.col.details': 'Details',
'admin.github.title': 'Update-Verlauf',
'admin.github.subtitle': 'Neueste Updates von {repo}',
'admin.github.latest': 'Aktuell',
@@ -331,13 +481,14 @@ const de: Record<string, string> = {
'admin.github.loading': 'Wird geladen...',
'admin.github.error': 'Releases konnten nicht geladen werden',
'admin.github.by': 'von',
'admin.github.support': 'Hilft mir, TREK weiterzuentwickeln',
'admin.update.available': 'Update verfügbar',
'admin.update.text': 'NOMAD {version} ist verfügbar. Du verwendest {current}.',
'admin.update.text': 'TREK {version} ist verfügbar. Du verwendest {current}.',
'admin.update.button': 'Auf GitHub ansehen',
'admin.update.install': 'Update installieren',
'admin.update.confirmTitle': 'Update installieren?',
'admin.update.confirmText': 'NOMAD wird von {current} auf {version} aktualisiert. Der Server startet danach automatisch neu.',
'admin.update.confirmText': 'TREK wird von {current} auf {version} aktualisiert. Der Server startet danach automatisch neu.',
'admin.update.dataInfo': 'Alle Daten (Reisen, Benutzer, API-Schlüssel, Uploads, Vacay, Atlas, Budgets) bleiben erhalten.',
'admin.update.warning': 'Die App ist während des Neustarts kurz nicht erreichbar.',
'admin.update.confirm': 'Jetzt aktualisieren',
@@ -347,7 +498,7 @@ const de: Record<string, string> = {
'admin.update.backupHint': 'Wir empfehlen, vor dem Update ein Backup zu erstellen und herunterzuladen.',
'admin.update.backupLink': 'Zum Backup',
'admin.update.howTo': 'Update-Anleitung',
'admin.update.dockerText': 'Deine NOMAD-Instanz läuft in Docker. Um auf {version} zu aktualisieren, führe folgende Befehle auf deinem Server aus:',
'admin.update.dockerText': 'Deine TREK-Instanz läuft in Docker. Um auf {version} zu aktualisieren, führe folgende Befehle auf deinem Server aus:',
'admin.update.reloadHint': 'Bitte lade die Seite in wenigen Sekunden neu.',
// Vacay addon
@@ -382,20 +533,32 @@ const de: Record<string, string> = {
'vacay.remaining': 'Rest',
'vacay.carriedOver': 'aus {year}',
'vacay.blockWeekends': 'Wochenenden sperren',
'vacay.blockWeekendsHint': 'Verhindert Urlaubseinträge an Samstagen und Sonntagen',
'vacay.blockWeekendsHint': 'Verhindert Urlaubseinträge an Wochenendtagen',
'vacay.weekendDays': 'Wochenendtage',
'vacay.mon': 'Mo',
'vacay.tue': 'Di',
'vacay.wed': 'Mi',
'vacay.thu': 'Do',
'vacay.fri': 'Fr',
'vacay.sat': 'Sa',
'vacay.sun': 'So',
'vacay.publicHolidays': 'Feiertage',
'vacay.publicHolidaysHint': 'Feiertage im Kalender markieren',
'vacay.selectCountry': 'Land wählen',
'vacay.selectRegion': 'Region wählen (optional)',
'vacay.addCalendar': 'Kalender hinzufügen',
'vacay.calendarLabel': 'Bezeichnung (optional)',
'vacay.calendarColor': 'Farbe',
'vacay.noCalendars': 'Noch keine Feiertagskalender angelegt',
'vacay.companyHolidays': 'Betriebsferien',
'vacay.companyHolidaysHint': 'Erlaubt das Markieren von unternehmensweiten Feiertagen',
'vacay.companyHolidaysNoDeduct': 'Betriebsferien werden nicht vom Urlaubskontingent abgezogen.',
'vacay.carryOver': 'Urlaubsmitnahme',
'vacay.carryOverHint': 'Resturlaub automatisch ins Folgejahr übertragen',
'vacay.sharing': 'Teilen',
'vacay.sharingHint': 'Teile deinen Urlaubsplan mit anderen NOMAD-Benutzern',
'vacay.sharingHint': 'Teile deinen Urlaubsplan mit anderen TREK-Benutzern',
'vacay.owner': 'Besitzer',
'vacay.shareEmailPlaceholder': 'E-Mail des NOMAD-Benutzers',
'vacay.shareEmailPlaceholder': 'E-Mail des TREK-Benutzers',
'vacay.shareSuccess': 'Plan erfolgreich geteilt',
'vacay.shareError': 'Plan konnte nicht geteilt werden',
'vacay.dissolve': 'Fusion auflösen',
@@ -407,7 +570,7 @@ const de: Record<string, string> = {
'vacay.noData': 'Keine Daten',
'vacay.changeColor': 'Farbe ändern',
'vacay.inviteUser': 'Benutzer einladen',
'vacay.inviteHint': 'Lade einen anderen NOMAD-Benutzer ein, um einen gemeinsamen Urlaubskalender zu teilen.',
'vacay.inviteHint': 'Lade einen anderen TREK-Benutzer ein, um einen gemeinsamen Urlaubskalender zu teilen.',
'vacay.selectUser': 'Benutzer wählen',
'vacay.sendInvite': 'Einladung senden',
'vacay.inviteSent': 'Einladung gesendet',
@@ -431,6 +594,25 @@ const de: Record<string, string> = {
'atlas.countries': 'Länder',
'atlas.trips': 'Reisen',
'atlas.places': 'Orte',
'atlas.unmark': 'Entfernen',
'atlas.confirmMark': 'Dieses Land als besucht markieren?',
'atlas.confirmUnmark': 'Dieses Land von der Liste entfernen?',
'atlas.markVisited': 'Als besucht markieren',
'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen',
'atlas.addToBucket': 'Zur Bucket List',
'atlas.addPoi': 'Ort hinzufügen',
'atlas.bucketNamePlaceholder': 'Name (Land, Stadt, Ort...)',
'atlas.month': 'Monat',
'atlas.year': 'Jahr',
'atlas.addToBucketHint': 'Als Wunschziel speichern',
'atlas.bucketWhen': 'Wann möchtest du dorthin reisen?',
'atlas.statsTab': 'Statistik',
'atlas.bucketTab': 'Bucket List',
'atlas.addBucket': 'Zur Bucket List hinzufügen',
'atlas.bucketNamePlaceholder': 'Ort oder Reiseziel...',
'atlas.bucketNotesPlaceholder': 'Notizen (optional)',
'atlas.bucketEmpty': 'Deine Bucket List ist leer',
'atlas.bucketEmptyHint': 'Füge Orte hinzu, die du besuchen möchtest',
'atlas.days': 'Tage',
'atlas.visitedCountries': 'Besuchte Länder',
'atlas.cities': 'Städte',
@@ -485,6 +667,12 @@ const de: Record<string, string> = {
// Day Plan Sidebar
'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant',
'dayplan.cannotReorderTransport': 'Buchungen mit fester Uhrzeit können nicht verschoben werden',
'dayplan.confirmRemoveTimeTitle': 'Uhrzeit entfernen?',
'dayplan.confirmRemoveTimeBody': 'Dieser Ort hat eine feste Uhrzeit ({time}). Durch das Verschieben wird die Uhrzeit entfernt und der Ort kann frei sortiert werden.',
'dayplan.confirmRemoveTimeAction': 'Uhrzeit entfernen & verschieben',
'dayplan.cannotDropOnTimed': 'Orte können nicht zwischen zeitgebundene Einträge geschoben werden',
'dayplan.cannotBreakChronology': 'Die zeitliche Reihenfolge von Uhrzeiten und Buchungen darf nicht verletzt werden',
'dayplan.addNote': 'Notiz hinzufügen',
'dayplan.editNote': 'Notiz bearbeiten',
'dayplan.noteAdd': 'Notiz hinzufügen',
@@ -510,11 +698,17 @@ const de: Record<string, string> = {
// Places Sidebar
'places.addPlace': 'Ort/Aktivität hinzufügen',
'places.importGpx': 'GPX importieren',
'places.gpxImported': '{count} Orte aus GPX importiert',
'places.urlResolved': 'Ort aus URL importiert',
'places.gpxError': 'GPX-Import fehlgeschlagen',
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
'places.all': 'Alle',
'places.unplanned': 'Ungeplant',
'places.search': 'Orte suchen...',
'places.allCategories': 'Alle Kategorien',
'places.categoriesSelected': 'Kategorien',
'places.clearFilter': 'Filter zurücksetzen',
'places.count': '{count} Orte',
'places.countSingular': '1 Ort',
'places.allPlanned': 'Alle Orte sind eingeplant',
@@ -586,8 +780,25 @@ const de: Record<string, string> = {
'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)',
'reservations.notes': 'Notizen',
'reservations.notesPlaceholder': 'Zusätzliche Notizen...',
'reservations.meta.airline': 'Airline',
'reservations.meta.flightNumber': 'Flugnr.',
'reservations.meta.from': 'Von',
'reservations.meta.to': 'Nach',
'reservations.meta.trainNumber': 'Zugnr.',
'reservations.meta.platform': 'Gleis',
'reservations.meta.seat': 'Sitzplatz',
'reservations.meta.checkIn': 'Check-in',
'reservations.meta.checkOut': 'Check-out',
'reservations.meta.linkAccommodation': 'Unterkunft',
'reservations.meta.pickAccommodation': 'Mit Unterkunft verknüpfen',
'reservations.meta.noAccommodation': 'Keine',
'reservations.meta.hotelPlace': 'Unterkunft',
'reservations.meta.pickHotel': 'Unterkunft auswählen',
'reservations.meta.fromDay': 'Von',
'reservations.meta.toDay': 'Bis',
'reservations.meta.selectDay': 'Tag wählen',
'reservations.type.flight': 'Flug',
'reservations.type.hotel': 'Hotel',
'reservations.type.hotel': 'Unterkunft',
'reservations.type.restaurant': 'Restaurant',
'reservations.type.train': 'Zug',
'reservations.type.car': 'Mietwagen',
@@ -596,6 +807,8 @@ const de: Record<string, string> = {
'reservations.type.tour': 'Tour',
'reservations.type.other': 'Sonstiges',
'reservations.confirm.delete': 'Möchtest du die Reservierung "{name}" wirklich löschen?',
'reservations.confirm.deleteTitle': 'Buchung löschen?',
'reservations.confirm.deleteBody': '"{name}" wird unwiderruflich gelöscht.',
'reservations.toast.updated': 'Reservierung aktualisiert',
'reservations.toast.removed': 'Reservierung gelöscht',
'reservations.toast.saveError': 'Fehler beim Speichern',
@@ -619,6 +832,7 @@ const de: Record<string, string> = {
'reservations.pendingSave': 'wird gespeichert…',
'reservations.uploading': 'Wird hochgeladen...',
'reservations.attachFile': 'Datei anhängen',
'reservations.linkExisting': 'Vorhandene verknüpfen',
'reservations.linkAssignment': 'Mit Tagesplanung verknüpfen',
'reservations.pickAssignment': 'Zuordnung aus dem Plan wählen...',
'reservations.noAssignment': 'Keine Verknüpfung',
@@ -652,6 +866,9 @@ const de: Record<string, string> = {
'budget.paid': 'Bezahlt',
'budget.open': 'Offen',
'budget.noMembers': 'Keine Teilnehmer zugewiesen',
'budget.settlement': 'Ausgleich',
'budget.settlementInfo': 'Klicke auf ein Mitglied-Bild bei einem Eintrag, um es grün zu markieren — das bedeutet, diese Person hat bezahlt. Der Ausgleich zeigt dann, wer wem wie viel schuldet.',
'budget.netBalances': 'Netto-Salden',
// Files
'files.title': 'Dateien',
@@ -679,10 +896,41 @@ const de: Record<string, string> = {
'files.sourceBooking': 'Buchung',
'files.attach': 'Anhängen',
'files.pasteHint': 'Du kannst auch Bilder aus der Zwischenablage einfügen (Strg+V)',
'files.trash': 'Papierkorb',
'files.trashEmpty': 'Papierkorb ist leer',
'files.emptyTrash': 'Papierkorb leeren',
'files.restore': 'Wiederherstellen',
'files.star': 'Markieren',
'files.unstar': 'Markierung entfernen',
'files.assign': 'Zuweisen',
'files.assignTitle': 'Datei zuweisen',
'files.assignPlace': 'Ort',
'files.assignBooking': 'Buchung',
'files.unassigned': 'Nicht zugewiesen',
'files.unlink': 'Verknüpfung entfernen',
'files.toast.trashed': 'In den Papierkorb verschoben',
'files.toast.restored': 'Datei wiederhergestellt',
'files.toast.trashEmptied': 'Papierkorb geleert',
'files.toast.assigned': 'Datei zugewiesen',
'files.toast.assignError': 'Zuweisung fehlgeschlagen',
'files.toast.restoreError': 'Wiederherstellung fehlgeschlagen',
'files.confirm.permanentDelete': 'Diese Datei endgültig löschen? Das kann nicht rückgängig gemacht werden.',
'files.confirm.emptyTrash': 'Alle Dateien im Papierkorb endgültig löschen? Das kann nicht rückgängig gemacht werden.',
'files.noteLabel': 'Notiz',
'files.notePlaceholder': 'Notiz hinzufügen...',
// Packing
'packing.title': 'Packliste',
'packing.empty': 'Packliste ist leer',
'packing.import': 'Importieren',
'packing.importTitle': 'Packliste importieren',
'packing.importHint': 'Ein Eintrag pro Zeile. Format: Kategorie, Name, Gewicht in g (optional), Tasche (optional), checked/unchecked (optional)',
'packing.importPlaceholder': 'Hygiene, Zahnbürste\nKleidung, T-Shirts, 200\nDokumente, Reisepass, , Handgepäck\nElektronik, Ladekabel, 50, Koffer, checked',
'packing.importCsv': 'CSV/TXT laden',
'packing.importAction': '{count} importieren',
'packing.importSuccess': '{count} Einträge importiert',
'packing.importError': 'Import fehlgeschlagen',
'packing.importEmpty': 'Keine Einträge zum Importieren',
'packing.progress': '{packed} von {total} gepackt ({percent}%)',
'packing.clearChecked': '{count} abgehakte entfernen',
'packing.clearCheckedShort': '{count} entfernen',
@@ -702,6 +950,21 @@ const de: Record<string, string> = {
'packing.menuCheckAll': 'Alle abhaken',
'packing.menuUncheckAll': 'Alle Haken entfernen',
'packing.menuDeleteCat': 'Kategorie löschen',
'packing.assignUser': 'Benutzer zuweisen',
'packing.noMembers': 'Keine Mitglieder',
'packing.addItem': 'Eintrag hinzufügen',
'packing.addItemPlaceholder': 'Artikelname...',
'packing.addCategory': 'Kategorie hinzufügen',
'packing.newCategoryPlaceholder': 'Kategoriename (z.B. Kleidung)',
'packing.applyTemplate': 'Vorlage anwenden',
'packing.template': 'Vorlage',
'packing.templateApplied': '{count} Einträge aus Vorlage hinzugefügt',
'packing.templateError': 'Vorlage konnte nicht angewendet werden',
'packing.bags': 'Gepäck',
'packing.noBag': 'Nicht zugeordnet',
'packing.totalWeight': 'Gesamtgewicht',
'packing.bagName': 'Name...',
'packing.addBag': 'Gepäck hinzufügen',
'packing.changeCategory': 'Kategorie ändern',
'packing.confirm.clearChecked': 'Möchtest du {count} abgehakte Gegenstände wirklich entfernen?',
'packing.confirm.deleteCat': 'Möchtest du die Kategorie "{name}" mit {count} Gegenständen wirklich löschen?',
@@ -819,7 +1082,27 @@ const de: Record<string, string> = {
'backup.auto.enable': 'Auto-Backup aktivieren',
'backup.auto.enableHint': 'Backups werden automatisch nach dem gewählten Zeitplan erstellt',
'backup.auto.interval': 'Intervall',
'backup.auto.hour': 'Ausführung um',
'backup.auto.hourHint': 'Lokale Serverzeit ({format}-Format)',
'backup.auto.dayOfWeek': 'Wochentag',
'backup.auto.dayOfMonth': 'Tag des Monats',
'backup.auto.dayOfMonthHint': 'Auf 128 beschränkt, um mit allen Monaten kompatibel zu sein',
'backup.auto.scheduleSummary': 'Zeitplan',
'backup.auto.summaryDaily': 'Täglich um {hour}:00',
'backup.auto.summaryWeekly': 'Jeden {day} um {hour}:00',
'backup.auto.summaryMonthly': 'Am {day}. jedes Monats um {hour}:00',
'backup.auto.envLocked': 'Docker',
'backup.auto.envLockedHint': 'Auto-Backup wird über Docker-Umgebungsvariablen konfiguriert. Ändern Sie Ihre docker-compose.yml und starten Sie den Container neu.',
'backup.auto.copyEnv': 'Docker-Umgebungsvariablen kopieren',
'backup.auto.envCopied': 'Docker-Umgebungsvariablen in die Zwischenablage kopiert',
'backup.auto.keepLabel': 'Alte Backups löschen nach',
'backup.dow.sunday': 'So',
'backup.dow.monday': 'Mo',
'backup.dow.tuesday': 'Di',
'backup.dow.wednesday': 'Mi',
'backup.dow.thursday': 'Do',
'backup.dow.friday': 'Fr',
'backup.dow.saturday': 'Sa',
'backup.interval.hourly': 'Stündlich',
'backup.interval.daily': 'Täglich',
'backup.interval.weekly': 'Wöchentlich',
@@ -946,6 +1229,45 @@ const de: Record<string, string> = {
'day.editAccommodation': 'Unterkunft bearbeiten',
'day.reservations': 'Reservierungen',
// Photos / Immich
'memories.title': 'Fotos',
'memories.notConnected': 'Immich nicht verbunden',
'memories.notConnectedHint': 'Verbinde deine Immich-Instanz in den Einstellungen, um deine Reisefotos hier zu sehen.',
'memories.noDates': 'Füge Daten zu deiner Reise hinzu, um Fotos zu laden.',
'memories.noPhotos': 'Keine Fotos gefunden',
'memories.noPhotosHint': 'Keine Fotos in Immich für den Zeitraum dieser Reise gefunden.',
'memories.photosFound': 'Fotos',
'memories.fromOthers': 'von anderen',
'memories.sharePhotos': 'Fotos teilen',
'memories.sharing': 'Wird geteilt',
'memories.reviewTitle': 'Deine Fotos prüfen',
'memories.reviewHint': 'Klicke auf Fotos, um sie vom Teilen auszuschließen.',
'memories.shareCount': '{count} Fotos teilen',
'memories.immichUrl': 'Immich Server URL',
'memories.immichApiKey': 'API-Schlüssel',
'memories.testConnection': 'Verbindung testen',
'memories.connected': 'Verbunden',
'memories.disconnected': 'Nicht verbunden',
'memories.connectionSuccess': 'Verbindung zu Immich hergestellt',
'memories.connectionError': 'Verbindung zu Immich fehlgeschlagen',
'memories.saved': 'Immich-Einstellungen gespeichert',
'memories.addPhotos': 'Fotos hinzufügen',
'memories.selectPhotos': 'Fotos aus Immich auswählen',
'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.',
'memories.selected': 'ausgewählt',
'memories.addSelected': '{count} Fotos hinzufügen',
'memories.alreadyAdded': 'Hinzugefügt',
'memories.private': 'Privat',
'memories.stopSharing': 'Nicht mehr teilen',
'memories.oldest': 'Älteste zuerst',
'memories.newest': 'Neueste zuerst',
'memories.allLocations': 'Alle Orte',
'memories.tripDates': 'Trip-Zeitraum',
'memories.allPhotos': 'Alle Fotos',
'memories.confirmShareTitle': 'Mit Reisebegleitern teilen?',
'memories.confirmShareHint': '{count} Fotos werden für alle Mitglieder dieses Trips sichtbar. Du kannst einzelne Fotos nachträglich auf privat setzen.',
'memories.confirmShareButton': 'Fotos teilen',
// Collab Addon
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notizen',
@@ -968,7 +1290,6 @@ const de: Record<string, string> = {
'collab.chat.justNow': 'gerade eben',
'collab.chat.minutesAgo': 'vor {n} Min.',
'collab.chat.hoursAgo': 'vor {n} Std.',
'collab.chat.yesterday': 'gestern',
'collab.notes.title': 'Notizen',
'collab.notes.new': 'Neue Notiz',
'collab.notes.empty': 'Noch keine Notizen',
+334 -14
View File
@@ -1,4 +1,4 @@
const en: Record<string, string> = {
const en: Record<string, string | { name: string; category: string }[]> = {
// Common
'common.save': 'Save',
'common.cancel': 'Cancel',
@@ -51,9 +51,18 @@ const en: Record<string, string> = {
'dashboard.subtitle.activeMany': '{count} active trips',
'dashboard.subtitle.archivedSuffix': ' · {count} archived',
'dashboard.newTrip': 'New Trip',
'dashboard.gridView': 'Grid view',
'dashboard.listView': 'List view',
'dashboard.currency': 'Currency',
'dashboard.timezone': 'Timezones',
'dashboard.localTime': 'Local',
'dashboard.timezoneCustomTitle': 'Custom Timezone',
'dashboard.timezoneCustomLabelPlaceholder': 'Label (optional)',
'dashboard.timezoneCustomTzPlaceholder': 'e.g. America/New_York',
'dashboard.timezoneCustomAdd': 'Add',
'dashboard.timezoneCustomErrorEmpty': 'Enter a timezone identifier',
'dashboard.timezoneCustomErrorInvalid': 'Invalid timezone. Use format like Europe/Berlin',
'dashboard.timezoneCustomErrorDuplicate': 'Already added',
'dashboard.emptyTitle': 'No trips yet',
'dashboard.emptyText': 'Create your first trip and start planning!',
'dashboard.emptyButton': 'Create First Trip',
@@ -92,7 +101,9 @@ const en: Record<string, string> = {
'dashboard.endDate': 'End Date',
'dashboard.noDateHint': 'No date set — 7 default days will be created. You can change this anytime.',
'dashboard.coverImage': 'Cover Image',
'dashboard.addCoverImage': 'Add cover image',
'dashboard.addCoverImage': 'Add cover image (or drag & drop)',
'dashboard.addMembers': 'Travel buddies',
'dashboard.addMember': 'Add member',
'dashboard.coverSaved': 'Cover image saved',
'dashboard.coverUploadError': 'Failed to upload',
'dashboard.coverRemoveError': 'Failed to remove',
@@ -128,6 +139,50 @@ const en: Record<string, string> = {
'settings.temperature': 'Temperature Unit',
'settings.timeFormat': 'Time Format',
'settings.routeCalculation': 'Route Calculation',
'settings.blurBookingCodes': 'Blur Booking Codes',
'settings.notifications': 'Notifications',
'settings.notifyTripInvite': 'Trip invitations',
'settings.notifyBookingChange': 'Booking changes',
'settings.notifyTripReminder': 'Trip reminders',
'settings.notifyVacayInvite': 'Vacay fusion invitations',
'settings.notifyPhotosShared': 'Shared photos (Immich)',
'settings.notifyCollabMessage': 'Chat messages (Collab)',
'settings.notifyPackingTagged': 'Packing list: assignments',
'settings.notifyWebhook': 'Webhook notifications',
'admin.smtp.title': 'Email & Notifications',
'admin.smtp.hint': 'SMTP configuration for email notifications. Optional: Webhook URL for Discord, Slack, etc.',
'admin.smtp.testButton': 'Send test email',
'admin.smtp.testSuccess': 'Test email sent successfully',
'admin.smtp.testFailed': 'Test email failed',
'dayplan.icsTooltip': 'Export calendar (ICS)',
'share.linkTitle': 'Public Link',
'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.',
'share.createLink': 'Create link',
'share.deleteLink': 'Delete link',
'share.createError': 'Could not create link',
'common.copy': 'Copy',
'common.copied': 'Copied',
'share.permMap': 'Map & Plan',
'share.permBookings': 'Bookings',
'share.permPacking': 'Packing',
'shared.expired': 'Link expired or invalid',
'shared.expiredHint': 'This shared trip link is no longer active.',
'shared.readOnly': 'Read-only shared view',
'shared.tabPlan': 'Plan',
'shared.tabBookings': 'Bookings',
'shared.tabPacking': 'Packing',
'shared.tabBudget': 'Budget',
'shared.tabChat': 'Chat',
'shared.days': 'days',
'shared.places': 'places',
'shared.other': 'Other',
'shared.totalBudget': 'Total Budget',
'shared.messages': 'messages',
'shared.sharedVia': 'Shared via',
'shared.confirmed': 'Confirmed',
'shared.pending': 'Pending',
'share.permBudget': 'Budget',
'share.permCollab': 'Chat',
'settings.on': 'On',
'settings.off': 'Off',
'settings.account': 'Account',
@@ -164,6 +219,22 @@ const en: Record<string, string> = {
'settings.avatarUploaded': 'Profile picture updated',
'settings.avatarRemoved': 'Profile picture removed',
'settings.avatarError': 'Upload failed',
'settings.mfa.title': 'Two-factor authentication (2FA)',
'settings.mfa.description': 'Adds a second step when you sign in with email and password. Use an authenticator app (Google Authenticator, Authy, etc.).',
'settings.mfa.enabled': '2FA is enabled on your account.',
'settings.mfa.disabled': '2FA is not enabled.',
'settings.mfa.setup': 'Set up authenticator',
'settings.mfa.scanQr': 'Scan this QR code with your app, or enter the secret manually.',
'settings.mfa.secretLabel': 'Secret key (manual entry)',
'settings.mfa.codePlaceholder': '6-digit code',
'settings.mfa.enable': 'Enable 2FA',
'settings.mfa.cancelSetup': 'Cancel',
'settings.mfa.disableTitle': 'Disable 2FA',
'settings.mfa.disableHint': 'Enter your account password and a current code from your authenticator.',
'settings.mfa.disable': 'Disable 2FA',
'settings.mfa.toastEnabled': 'Two-factor authentication enabled',
'settings.mfa.toastDisabled': 'Two-factor authentication disabled',
'settings.mfa.demoBlocked': 'Not available in demo mode',
// Login
'login.error': 'Login failed. Please check your credentials.',
@@ -191,7 +262,7 @@ const en: Record<string, string> = {
'login.signingIn': 'Signing in…',
'login.signIn': 'Sign In',
'login.createAdmin': 'Create Admin Account',
'login.createAdminHint': 'Set up the first admin account for NOMAD.',
'login.createAdminHint': 'Set up the first admin account for TREK.',
'login.createAccount': 'Create Account',
'login.createAccountHint': 'Register a new account.',
'login.creating': 'Creating…',
@@ -206,7 +277,15 @@ const en: Record<string, string> = {
'login.oidc.invalidState': 'Invalid session. Please try again.',
'login.demoFailed': 'Demo login failed',
'login.oidcSignIn': 'Sign in with {name}',
'login.oidcOnly': 'Password authentication is disabled. Please sign in using your SSO provider.',
'login.demoHint': 'Try the demo — no registration needed',
'login.mfaTitle': 'Two-factor authentication',
'login.mfaSubtitle': 'Enter the 6-digit code from your authenticator app.',
'login.mfaCodeLabel': 'Verification code',
'login.mfaCodeRequired': 'Enter the code from your authenticator app.',
'login.mfaHint': 'Open Google Authenticator, Authy, or another TOTP app.',
'login.mfaBack': '← Back to sign in',
'login.mfaVerify': 'Verify',
// Register
'register.passwordMismatch': 'Passwords do not match',
@@ -236,6 +315,7 @@ const en: Record<string, string> = {
'admin.tabs.users': 'Users',
'admin.tabs.categories': 'Categories',
'admin.tabs.backup': 'Backup',
'admin.tabs.audit': 'Audit log',
'admin.stats.users': 'Users',
'admin.stats.trips': 'Trips',
'admin.stats.places': 'Places',
@@ -264,6 +344,24 @@ const en: Record<string, string> = {
'admin.toast.createError': 'Failed to create user',
'admin.toast.fieldsRequired': 'Username, email and password are required',
'admin.createUser': 'Create User',
'admin.invite.title': 'Invite Links',
'admin.invite.subtitle': 'Create one-time registration links',
'admin.invite.create': 'Create Link',
'admin.invite.createAndCopy': 'Create & Copy',
'admin.invite.empty': 'No invite links created yet',
'admin.invite.maxUses': 'Max. Uses',
'admin.invite.expiry': 'Expires after',
'admin.invite.uses': 'used',
'admin.invite.expiresAt': 'expires',
'admin.invite.createdBy': 'by',
'admin.invite.active': 'Active',
'admin.invite.expired': 'Expired',
'admin.invite.usedUp': 'Used up',
'admin.invite.copied': 'Invite link copied to clipboard',
'admin.invite.copyLink': 'Copy link',
'admin.invite.deleted': 'Invite link deleted',
'admin.invite.createError': 'Failed to create invite link',
'admin.invite.deleteError': 'Failed to delete invite link',
'admin.tabs.settings': 'Settings',
'admin.allowRegistration': 'Allow Registration',
'admin.allowRegistrationHint': 'New users can register themselves',
@@ -285,6 +383,8 @@ const en: Record<string, string> = {
'admin.oidcIssuer': 'Issuer URL',
'admin.oidcIssuerHint': 'The OpenID Connect Issuer URL of the provider. e.g. https://accounts.google.com',
'admin.oidcSaved': 'OIDC configuration saved',
'admin.oidcOnlyMode': 'Disable password authentication',
'admin.oidcOnlyModeHint': 'When enabled, only SSO login is permitted. Password-based login and registration are blocked.',
// File Types
'admin.fileTypes': 'Allowed File Types',
@@ -292,10 +392,47 @@ const en: Record<string, string> = {
'admin.fileTypesFormat': 'Comma-separated extensions (e.g. jpg,png,pdf,doc). Use * to allow all types.',
'admin.fileTypesSaved': 'File type settings saved',
// Packing Templates & Bag Tracking
'admin.bagTracking.title': 'Bag Tracking',
'admin.bagTracking.subtitle': 'Enable weight and bag assignment for packing items',
'admin.tabs.config': 'Configuration',
'admin.tabs.templates': 'Packing Templates',
'admin.packingTemplates.title': 'Packing Templates',
'admin.packingTemplates.subtitle': 'Create reusable packing lists for your trips',
'admin.packingTemplates.create': 'New Template',
'admin.packingTemplates.namePlaceholder': 'Template name (e.g. Beach Holiday)',
'admin.packingTemplates.empty': 'No templates created yet',
'admin.packingTemplates.items': 'items',
'admin.packingTemplates.categories': 'categories',
'admin.packingTemplates.itemName': 'Item name',
'admin.packingTemplates.itemCategory': 'Category',
'admin.packingTemplates.categoryName': 'Category name (e.g. Clothing)',
'admin.packingTemplates.addCategory': 'Add category',
'admin.packingTemplates.created': 'Template created',
'admin.packingTemplates.deleted': 'Template deleted',
'admin.packingTemplates.loadError': 'Failed to load templates',
'admin.packingTemplates.createError': 'Failed to create template',
'admin.packingTemplates.deleteError': 'Failed to delete template',
'admin.packingTemplates.saveError': 'Failed to save',
// Addons
'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Enable or disable features to customize your NOMAD experience.',
'admin.addons.subtitle': 'Enable or disable features to customize your TREK experience.',
'admin.addons.catalog.packing.name': 'Packing',
'admin.addons.catalog.packing.description': 'Checklists to prepare your luggage for each trip',
'admin.addons.catalog.budget.name': 'Budget',
'admin.addons.catalog.budget.description': 'Track expenses and plan your trip budget',
'admin.addons.catalog.documents.name': 'Documents',
'admin.addons.catalog.documents.description': 'Store and manage travel documents',
'admin.addons.catalog.vacay.name': 'Vacay',
'admin.addons.catalog.vacay.description': 'Personal vacation planner with calendar view',
'admin.addons.catalog.atlas.name': 'Atlas',
'admin.addons.catalog.atlas.description': 'World map with visited countries and travel stats',
'admin.addons.catalog.collab.name': 'Collab',
'admin.addons.catalog.collab.description': 'Real-time notes, polls, and chat for trip planning',
'admin.addons.catalog.memories.name': 'Photos (Immich)',
'admin.addons.catalog.memories.description': 'Share trip photos via your Immich instance',
'admin.addons.subtitleBefore': 'Enable or disable features to customize your ',
'admin.addons.subtitleAfter': ' experience.',
'admin.addons.enabled': 'Enabled',
@@ -310,7 +447,7 @@ const en: Record<string, string> = {
// Weather info
'admin.weather.title': 'Weather Data',
'admin.weather.badge': 'Since March 24, 2026',
'admin.weather.description': 'NOMAD uses Open-Meteo as its weather data source. Open-Meteo is a free, open-source weather service — no API key required.',
'admin.weather.description': 'TREK uses Open-Meteo as its weather data source. Open-Meteo is a free, open-source weather service — no API key required.',
'admin.weather.forecast': '16-day forecast',
'admin.weather.forecastDesc': 'Previously 5 days (OpenWeatherMap)',
'admin.weather.climate': 'Historical climate data',
@@ -321,6 +458,18 @@ const en: Record<string, string> = {
// GitHub
'admin.tabs.github': 'GitHub',
'admin.audit.subtitle': 'Security-sensitive and administration events (backups, users, MFA, settings).',
'admin.audit.empty': 'No audit entries yet.',
'admin.audit.refresh': 'Refresh',
'admin.audit.loadMore': 'Load more',
'admin.audit.showing': '{count} loaded · {total} total',
'admin.audit.col.time': 'Time',
'admin.audit.col.user': 'User',
'admin.audit.col.action': 'Action',
'admin.audit.col.resource': 'Resource',
'admin.audit.col.ip': 'IP',
'admin.audit.col.details': 'Details',
'admin.github.title': 'Release History',
'admin.github.subtitle': 'Latest updates from {repo}',
'admin.github.latest': 'Latest',
@@ -331,13 +480,14 @@ const en: Record<string, string> = {
'admin.github.loading': 'Loading...',
'admin.github.error': 'Failed to load releases',
'admin.github.by': 'by',
'admin.github.support': 'Helps me keep building TREK',
'admin.update.available': 'Update available',
'admin.update.text': 'NOMAD {version} is available. You are running {current}.',
'admin.update.text': 'TREK {version} is available. You are running {current}.',
'admin.update.button': 'View on GitHub',
'admin.update.install': 'Install Update',
'admin.update.confirmTitle': 'Install Update?',
'admin.update.confirmText': 'NOMAD will be updated from {current} to {version}. The server will restart automatically afterwards.',
'admin.update.confirmText': 'TREK will be updated from {current} to {version}. The server will restart automatically afterwards.',
'admin.update.dataInfo': 'All your data (trips, users, API keys, uploads, Vacay, Atlas, budgets) will be preserved.',
'admin.update.warning': 'The app will be briefly unavailable during the restart.',
'admin.update.confirm': 'Update Now',
@@ -347,7 +497,7 @@ const en: Record<string, string> = {
'admin.update.backupHint': 'We recommend creating a backup before updating.',
'admin.update.backupLink': 'Go to Backup',
'admin.update.howTo': 'How to Update',
'admin.update.dockerText': 'Your NOMAD instance runs in Docker. To update to {version}, run the following commands on your server:',
'admin.update.dockerText': 'Your TREK instance runs in Docker. To update to {version}, run the following commands on your server:',
'admin.update.reloadHint': 'Please reload the page in a few seconds.',
// Vacay addon
@@ -382,20 +532,32 @@ const en: Record<string, string> = {
'vacay.remaining': 'Left',
'vacay.carriedOver': 'from {year}',
'vacay.blockWeekends': 'Block Weekends',
'vacay.blockWeekendsHint': 'Prevent vacation entries on Saturdays and Sundays',
'vacay.blockWeekendsHint': 'Prevent vacation entries on weekend days',
'vacay.weekendDays': 'Weekend days',
'vacay.mon': 'Mon',
'vacay.tue': 'Tue',
'vacay.wed': 'Wed',
'vacay.thu': 'Thu',
'vacay.fri': 'Fri',
'vacay.sat': 'Sat',
'vacay.sun': 'Sun',
'vacay.publicHolidays': 'Public Holidays',
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.selectCountry': 'Select country',
'vacay.selectRegion': 'Select region (optional)',
'vacay.addCalendar': 'Add calendar',
'vacay.calendarLabel': 'Label (optional)',
'vacay.calendarColor': 'Color',
'vacay.noCalendars': 'No holiday calendars added yet',
'vacay.companyHolidays': 'Company Holidays',
'vacay.companyHolidaysHint': 'Allow marking company-wide holiday days',
'vacay.companyHolidaysNoDeduct': 'Company holidays do not count towards vacation days.',
'vacay.carryOver': 'Carry Over',
'vacay.carryOverHint': 'Automatically carry remaining vacation days into the next year',
'vacay.sharing': 'Sharing',
'vacay.sharingHint': 'Share your vacation plan with other NOMAD users',
'vacay.sharingHint': 'Share your vacation plan with other TREK users',
'vacay.owner': 'Owner',
'vacay.shareEmailPlaceholder': 'Email of NOMAD user',
'vacay.shareEmailPlaceholder': 'Email of TREK user',
'vacay.shareSuccess': 'Plan shared successfully',
'vacay.shareError': 'Could not share plan',
'vacay.dissolve': 'Dissolve Fusion',
@@ -407,7 +569,7 @@ const en: Record<string, string> = {
'vacay.noData': 'No data',
'vacay.changeColor': 'Change color',
'vacay.inviteUser': 'Invite User',
'vacay.inviteHint': 'Invite another NOMAD user to share a combined vacation calendar.',
'vacay.inviteHint': 'Invite another TREK user to share a combined vacation calendar.',
'vacay.selectUser': 'Select user',
'vacay.sendInvite': 'Send Invite',
'vacay.inviteSent': 'Invite sent',
@@ -431,6 +593,25 @@ const en: Record<string, string> = {
'atlas.countries': 'Countries',
'atlas.trips': 'Trips',
'atlas.places': 'Places',
'atlas.unmark': 'Remove',
'atlas.confirmMark': 'Mark this country as visited?',
'atlas.confirmUnmark': 'Remove this country from your visited list?',
'atlas.markVisited': 'Mark as visited',
'atlas.markVisitedHint': 'Add this country to your visited list',
'atlas.addToBucket': 'Add to bucket list',
'atlas.addPoi': 'Add place',
'atlas.bucketNamePlaceholder': 'Name (country, city, place...)',
'atlas.month': 'Month',
'atlas.year': 'Year',
'atlas.addToBucketHint': 'Save as a place you want to visit',
'atlas.bucketWhen': 'When do you plan to visit?',
'atlas.statsTab': 'Stats',
'atlas.bucketTab': 'Bucket List',
'atlas.addBucket': 'Add to bucket list',
'atlas.bucketNamePlaceholder': 'Place or destination...',
'atlas.bucketNotesPlaceholder': 'Notes (optional)',
'atlas.bucketEmpty': 'Your bucket list is empty',
'atlas.bucketEmptyHint': 'Add places you dream of visiting',
'atlas.days': 'Days',
'atlas.visitedCountries': 'Visited Countries',
'atlas.cities': 'Cities',
@@ -485,6 +666,12 @@ const en: Record<string, string> = {
// Day Plan Sidebar
'dayplan.emptyDay': 'No places planned for this day',
'dayplan.cannotReorderTransport': 'Bookings with a fixed time cannot be reordered',
'dayplan.confirmRemoveTimeTitle': 'Remove time?',
'dayplan.confirmRemoveTimeBody': 'This place has a fixed time ({time}). Moving it will remove the time and allow free sorting.',
'dayplan.confirmRemoveTimeAction': 'Remove time & move',
'dayplan.cannotDropOnTimed': 'Items cannot be placed between time-bound entries',
'dayplan.cannotBreakChronology': 'This would break the chronological order of timed items and bookings',
'dayplan.addNote': 'Add Note',
'dayplan.editNote': 'Edit Note',
'dayplan.noteAdd': 'Add Note',
@@ -510,11 +697,17 @@ const en: Record<string, string> = {
// Places Sidebar
'places.addPlace': 'Add Place/Activity',
'places.importGpx': 'Import GPX',
'places.gpxImported': '{count} places imported from GPX',
'places.urlResolved': 'Place imported from URL',
'places.gpxError': 'GPX import failed',
'places.assignToDay': 'Add to which day?',
'places.all': 'All',
'places.unplanned': 'Unplanned',
'places.search': 'Search places...',
'places.allCategories': 'All Categories',
'places.categoriesSelected': 'categories',
'places.clearFilter': 'Clear filter',
'places.count': '{count} places',
'places.countSingular': '1 place',
'places.allPlanned': 'All places are planned',
@@ -586,8 +779,25 @@ const en: Record<string, string> = {
'reservations.timeAlt': 'Time (alternative, e.g. 19:30)',
'reservations.notes': 'Notes',
'reservations.notesPlaceholder': 'Additional notes...',
'reservations.meta.airline': 'Airline',
'reservations.meta.flightNumber': 'Flight No.',
'reservations.meta.from': 'From',
'reservations.meta.to': 'To',
'reservations.meta.trainNumber': 'Train No.',
'reservations.meta.platform': 'Platform',
'reservations.meta.seat': 'Seat',
'reservations.meta.checkIn': 'Check-in',
'reservations.meta.checkOut': 'Check-out',
'reservations.meta.linkAccommodation': 'Accommodation',
'reservations.meta.pickAccommodation': 'Link to accommodation',
'reservations.meta.noAccommodation': 'None',
'reservations.meta.hotelPlace': 'Accommodation',
'reservations.meta.pickHotel': 'Select accommodation',
'reservations.meta.fromDay': 'From',
'reservations.meta.toDay': 'To',
'reservations.meta.selectDay': 'Select day',
'reservations.type.flight': 'Flight',
'reservations.type.hotel': 'Hotel',
'reservations.type.hotel': 'Accommodation',
'reservations.type.restaurant': 'Restaurant',
'reservations.type.train': 'Train',
'reservations.type.car': 'Rental Car',
@@ -596,6 +806,8 @@ const en: Record<string, string> = {
'reservations.type.tour': 'Tour',
'reservations.type.other': 'Other',
'reservations.confirm.delete': 'Are you sure you want to delete the reservation "{name}"?',
'reservations.confirm.deleteTitle': 'Delete booking?',
'reservations.confirm.deleteBody': '"{name}" will be permanently deleted.',
'reservations.toast.updated': 'Reservation updated',
'reservations.toast.removed': 'Reservation deleted',
'reservations.toast.fileUploaded': 'File uploaded',
@@ -615,6 +827,7 @@ const en: Record<string, string> = {
'reservations.pendingSave': 'will be saved…',
'reservations.uploading': 'Uploading...',
'reservations.attachFile': 'Attach file',
'reservations.linkExisting': 'Link existing file',
'reservations.toast.saveError': 'Failed to save',
'reservations.toast.updateError': 'Failed to update',
'reservations.toast.deleteError': 'Failed to delete',
@@ -652,6 +865,9 @@ const en: Record<string, string> = {
'budget.paid': 'Paid',
'budget.open': 'Open',
'budget.noMembers': 'No members assigned',
'budget.settlement': 'Settlement',
'budget.settlementInfo': 'Click a member avatar on a budget item to mark them green — this means they paid. The settlement then shows who owes whom and how much.',
'budget.netBalances': 'Net Balances',
// Files
'files.title': 'Files',
@@ -679,10 +895,41 @@ const en: Record<string, string> = {
'files.sourceBooking': 'Booking',
'files.attach': 'Attach',
'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)',
'files.trash': 'Trash',
'files.trashEmpty': 'Trash is empty',
'files.emptyTrash': 'Empty Trash',
'files.restore': 'Restore',
'files.star': 'Star',
'files.unstar': 'Unstar',
'files.assign': 'Assign',
'files.assignTitle': 'Assign File',
'files.assignPlace': 'Place',
'files.assignBooking': 'Booking',
'files.unassigned': 'Unassigned',
'files.unlink': 'Remove link',
'files.toast.trashed': 'Moved to trash',
'files.toast.restored': 'File restored',
'files.toast.trashEmptied': 'Trash emptied',
'files.toast.assigned': 'File assigned',
'files.toast.assignError': 'Assignment failed',
'files.toast.restoreError': 'Restore failed',
'files.confirm.permanentDelete': 'Permanently delete this file? This cannot be undone.',
'files.confirm.emptyTrash': 'Permanently delete all trashed files? This cannot be undone.',
'files.noteLabel': 'Note',
'files.notePlaceholder': 'Add a note...',
// Packing
'packing.title': 'Packing List',
'packing.empty': 'Packing list is empty',
'packing.import': 'Import',
'packing.importTitle': 'Import Packing List',
'packing.importHint': 'One item per line. Format: Category, Name, Weight in g (optional), Bag (optional), checked/unchecked (optional)',
'packing.importPlaceholder': 'Hygiene, Toothbrush\nClothing, T-Shirts, 200\nDocuments, Passport, , Carry-on\nElectronics, Charger, 50, Suitcase, checked',
'packing.importCsv': 'Load CSV/TXT',
'packing.importAction': 'Import {count}',
'packing.importSuccess': '{count} items imported',
'packing.importError': 'Import failed',
'packing.importEmpty': 'No items to import',
'packing.progress': '{packed} of {total} packed ({percent}%)',
'packing.clearChecked': 'Remove {count} checked',
'packing.clearCheckedShort': 'Remove {count}',
@@ -702,6 +949,21 @@ const en: Record<string, string> = {
'packing.menuCheckAll': 'Check All',
'packing.menuUncheckAll': 'Uncheck All',
'packing.menuDeleteCat': 'Delete Category',
'packing.assignUser': 'Assign user',
'packing.noMembers': 'No trip members',
'packing.addItem': 'Add item',
'packing.addItemPlaceholder': 'Item name...',
'packing.addCategory': 'Add category',
'packing.newCategoryPlaceholder': 'Category name (e.g. Clothing)',
'packing.applyTemplate': 'Apply template',
'packing.template': 'Template',
'packing.templateApplied': '{count} items added from template',
'packing.templateError': 'Failed to apply template',
'packing.bags': 'Bags',
'packing.noBag': 'Unassigned',
'packing.totalWeight': 'Total weight',
'packing.bagName': 'Bag name...',
'packing.addBag': 'Add bag',
'packing.changeCategory': 'Change Category',
'packing.confirm.clearChecked': 'Are you sure you want to remove {count} checked items?',
'packing.confirm.deleteCat': 'Are you sure you want to delete the category "{name}" with {count} items?',
@@ -819,7 +1081,27 @@ const en: Record<string, string> = {
'backup.auto.enable': 'Enable auto-backup',
'backup.auto.enableHint': 'Backups will be created automatically on the chosen schedule',
'backup.auto.interval': 'Interval',
'backup.auto.hour': 'Run at hour',
'backup.auto.hourHint': 'Server local time ({format} format)',
'backup.auto.dayOfWeek': 'Day of week',
'backup.auto.dayOfMonth': 'Day of month',
'backup.auto.dayOfMonthHint': 'Limited to 128 for compatibility with all months',
'backup.auto.scheduleSummary': 'Schedule',
'backup.auto.summaryDaily': 'Every day at {hour}:00',
'backup.auto.summaryWeekly': 'Every {day} at {hour}:00',
'backup.auto.summaryMonthly': 'Day {day} of every month at {hour}:00',
'backup.auto.envLocked': 'Docker',
'backup.auto.envLockedHint': 'Auto-backup is configured via Docker environment variables. To change these settings, update your docker-compose.yml and restart the container.',
'backup.auto.copyEnv': 'Copy Docker env vars',
'backup.auto.envCopied': 'Docker env vars copied to clipboard',
'backup.auto.keepLabel': 'Delete old backups after',
'backup.dow.sunday': 'Sun',
'backup.dow.monday': 'Mon',
'backup.dow.tuesday': 'Tue',
'backup.dow.wednesday': 'Wed',
'backup.dow.thursday': 'Thu',
'backup.dow.friday': 'Fri',
'backup.dow.saturday': 'Sat',
'backup.interval.hourly': 'Hourly',
'backup.interval.daily': 'Daily',
'backup.interval.weekly': 'Weekly',
@@ -946,6 +1228,45 @@ const en: Record<string, string> = {
'day.editAccommodation': 'Edit accommodation',
'day.reservations': 'Reservations',
// Photos / Immich
'memories.title': 'Photos',
'memories.notConnected': 'Immich not connected',
'memories.notConnectedHint': 'Connect your Immich instance in Settings to see your trip photos here.',
'memories.noDates': 'Add dates to your trip to load photos.',
'memories.noPhotos': 'No photos found',
'memories.noPhotosHint': 'No photos found in Immich for this trip\'s date range.',
'memories.photosFound': 'photos',
'memories.fromOthers': 'from others',
'memories.sharePhotos': 'Share photos',
'memories.sharing': 'Sharing',
'memories.reviewTitle': 'Review your photos',
'memories.reviewHint': 'Click photos to exclude them from sharing.',
'memories.shareCount': 'Share {count} photos',
'memories.immichUrl': 'Immich Server URL',
'memories.immichApiKey': 'API Key',
'memories.testConnection': 'Test connection',
'memories.connected': 'Connected',
'memories.disconnected': 'Not connected',
'memories.connectionSuccess': 'Connected to Immich',
'memories.connectionError': 'Could not connect to Immich',
'memories.saved': 'Immich settings saved',
'memories.addPhotos': 'Add photos',
'memories.selectPhotos': 'Select photos from Immich',
'memories.selectHint': 'Tap photos to select them.',
'memories.selected': 'selected',
'memories.addSelected': 'Add {count} photos',
'memories.alreadyAdded': 'Added',
'memories.private': 'Private',
'memories.stopSharing': 'Stop sharing',
'memories.oldest': 'Oldest first',
'memories.newest': 'Newest first',
'memories.allLocations': 'All locations',
'memories.tripDates': 'Trip dates',
'memories.allPhotos': 'All photos',
'memories.confirmShareTitle': 'Share with trip members?',
'memories.confirmShareHint': '{count} photos will be visible to all members of this trip. You can make individual photos private later.',
'memories.confirmShareButton': 'Share photos',
// Collab Addon
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notes',
@@ -968,7 +1289,6 @@ const en: Record<string, string> = {
'collab.chat.justNow': 'just now',
'collab.chat.minutesAgo': '{n}m ago',
'collab.chat.hoursAgo': '{n}h ago',
'collab.chat.yesterday': 'yesterday',
'collab.notes.title': 'Notes',
'collab.notes.new': 'New Note',
'collab.notes.empty': 'No notes yet',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21 -1
View File
@@ -337,7 +337,7 @@ body {
}
/* Brand images: no save/copy/drag */
img[alt="NOMAD"] {
img[alt="TREK"] {
pointer-events: none;
user-select: none;
-webkit-user-select: none;
@@ -460,3 +460,23 @@ img[alt="NOMAD"] {
align-items: center;
justify-content: center;
}
/* Markdown in Collab Notes */
.collab-note-md strong, .collab-note-md-full strong { font-weight: 700 !important; }
.collab-note-md em, .collab-note-md-full em { font-style: italic !important; }
.collab-note-md h1, .collab-note-md h2, .collab-note-md h3 { font-weight: 700 !important; margin: 0; }
.collab-note-md-full h1 { font-size: 1.3em !important; font-weight: 700 !important; margin: 0.8em 0 0.3em; }
.collab-note-md-full h2 { font-size: 1.15em !important; font-weight: 700 !important; margin: 0.8em 0 0.3em; }
.collab-note-md-full h3 { font-size: 1em !important; font-weight: 700 !important; margin: 0.8em 0 0.3em; }
.collab-note-md p, .collab-note-md-full p { margin: 0 0 0.3em; }
.collab-note-md ul, .collab-note-md-full ul { list-style-type: disc !important; padding-left: 1.4em !important; margin: 0.2em 0; }
.collab-note-md ol, .collab-note-md-full ol { list-style-type: decimal !important; padding-left: 1.4em !important; margin: 0.2em 0; }
.collab-note-md li, .collab-note-md-full li { display: list-item !important; margin: 0.1em 0; }
.collab-note-md code, .collab-note-md-full code { font-size: 0.9em; padding: 1px 5px; border-radius: 4px; background: var(--bg-secondary); }
.collab-note-md-full pre { padding: 10px 12px; border-radius: 8px; background: var(--bg-secondary); overflow-x: auto; margin: 0.5em 0; }
.collab-note-md-full pre code { padding: 0; background: none; }
.collab-note-md a, .collab-note-md-full a { color: #3b82f6; text-decoration: underline; }
.collab-note-md blockquote, .collab-note-md-full blockquote { border-left: 3px solid var(--border-primary); padding-left: 12px; margin: 0.5em 0; color: var(--text-muted); }
.collab-note-md-full table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
.collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; }
.collab-note-md-full hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.8em 0; }
+253 -13
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { adminApi, authApi } from '../api/client'
import apiClient, { adminApi, authApi, notificationsApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { useTranslation } from '../i18n'
@@ -12,7 +12,9 @@ import CategoryManager from '../components/Admin/CategoryManager'
import BackupPanel from '../components/Admin/BackupPanel'
import GitHubPanel from '../components/Admin/GitHubPanel'
import AddonManager from '../components/Admin/AddonManager'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun } from 'lucide-react'
import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
import AuditLogPanel from '../components/Admin/AuditLogPanel'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react'
import CustomSelect from '../components/shared/CustomSelect'
interface AdminUser {
@@ -39,6 +41,7 @@ interface OidcConfig {
client_secret: string
client_secret_set: boolean
display_name: string
oidc_only: boolean
}
interface UpdateInfo {
@@ -50,15 +53,16 @@ interface UpdateInfo {
}
export default function AdminPage(): React.ReactElement {
const { demoMode } = useAuthStore()
const { demoMode, serverTimezone } = useAuthStore()
const { t, locale } = useTranslation()
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
const TABS = [
{ id: 'users', label: t('admin.tabs.users') },
{ id: 'categories', label: t('admin.tabs.categories') },
{ id: 'config', label: t('admin.tabs.config') },
{ id: 'addons', label: t('admin.tabs.addons') },
{ id: 'settings', label: t('admin.tabs.settings') },
{ id: 'backup', label: t('admin.tabs.backup') },
{ id: 'audit', label: t('admin.tabs.audit') },
{ id: 'github', label: t('admin.tabs.github') },
]
@@ -71,17 +75,36 @@ export default function AdminPage(): React.ReactElement {
const [showCreateUser, setShowCreateUser] = useState<boolean>(false)
const [createForm, setCreateForm] = useState<{ username: string; email: string; password: string; role: string }>({ username: '', email: '', password: '', role: 'user' })
// Bag tracking
const [bagTrackingEnabled, setBagTrackingEnabled] = useState<boolean>(false)
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
// OIDC config
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '' })
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false })
const [savingOidc, setSavingOidc] = useState<boolean>(false)
// Registration toggle
const [allowRegistration, setAllowRegistration] = useState<boolean>(true)
// Invite links
const [invites, setInvites] = useState<any[]>([])
const [showCreateInvite, setShowCreateInvite] = useState<boolean>(false)
const [inviteForm, setInviteForm] = useState<{ max_uses: number; expires_in_days: number | '' }>({ max_uses: 1, expires_in_days: 7 })
// File types
const [allowedFileTypes, setAllowedFileTypes] = useState<string>('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
const [savingFileTypes, setSavingFileTypes] = useState<boolean>(false)
// SMTP settings
const [smtpValues, setSmtpValues] = useState<Record<string, string>>({})
const [smtpLoaded, setSmtpLoaded] = useState(false)
useEffect(() => {
apiClient.get('/auth/app-settings').then(r => {
setSmtpValues(r.data || {})
setSmtpLoaded(true)
}).catch(() => setSmtpLoaded(true))
}, [])
// API Keys
const [mapsKey, setMapsKey] = useState<string>('')
const [weatherKey, setWeatherKey] = useState<string>('')
@@ -113,12 +136,14 @@ export default function AdminPage(): React.ReactElement {
const loadData = async () => {
setIsLoading(true)
try {
const [usersData, statsData] = await Promise.all([
const [usersData, statsData, invitesData] = await Promise.all([
adminApi.users(),
adminApi.stats(),
adminApi.listInvites().catch(() => ({ invites: [] })),
])
setUsers(usersData.users)
setStats(statsData)
setInvites(invitesData.invites || [])
} catch (err: unknown) {
toast.error(t('admin.toast.loadError'))
} finally {
@@ -239,6 +264,38 @@ export default function AdminPage(): React.ReactElement {
}
}
const handleCreateInvite = async () => {
try {
const data = await adminApi.createInvite({
max_uses: inviteForm.max_uses,
expires_in_days: inviteForm.expires_in_days || undefined,
})
setInvites(prev => [data.invite, ...prev])
setShowCreateInvite(false)
setInviteForm({ max_uses: 1, expires_in_days: 7 })
// Copy link to clipboard
const link = `${window.location.origin}/register?invite=${data.invite.token}`
navigator.clipboard.writeText(link).then(() => toast.success(t('admin.invite.copied')))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('admin.invite.createError')))
}
}
const handleDeleteInvite = async (id: number) => {
try {
await adminApi.deleteInvite(id)
setInvites(prev => prev.filter(i => i.id !== id))
toast.success(t('admin.invite.deleted'))
} catch {
toast.error(t('admin.invite.deleteError'))
}
}
const copyInviteLink = (token: string) => {
const link = `${window.location.origin}/register?invite=${token}`
navigator.clipboard.writeText(link).then(() => toast.success(t('admin.invite.copied')))
}
const handleEditUser = (user) => {
setEditingUser(user)
setEditForm({ username: user.username, email: user.email, role: user.role, password: '' })
@@ -246,7 +303,7 @@ export default function AdminPage(): React.ReactElement {
const handleSaveUser = async () => {
try {
const payload = {
const payload: { username?: string; email?: string; role: string; password?: string } = {
username: editForm.username.trim() || undefined,
email: editForm.email.trim() || undefined,
role: editForm.role,
@@ -288,7 +345,7 @@ export default function AdminPage(): React.ReactElement {
<Shield className="w-5 h-5 text-slate-700" />
</div>
<div>
<h1 className="text-2xl font-bold text-slate-900">Administration</h1>
<h1 className="text-2xl font-bold text-slate-900">{t('admin.title')}</h1>
<p className="text-slate-500 text-sm">{t('admin.subtitle')}</p>
</div>
</div>
@@ -467,10 +524,10 @@ export default function AdminPage(): React.ReactElement {
</span>
</td>
<td className="px-5 py-3 text-sm text-slate-500">
{new Date(u.created_at).toLocaleDateString(locale)}
{new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })}
</td>
<td className="px-5 py-3 text-sm text-slate-500">
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12, timeZone: serverTimezone }) : '—'}
</td>
<td className="px-5 py-3">
<div className="flex items-center gap-2 justify-end">
@@ -500,9 +557,125 @@ export default function AdminPage(): React.ReactElement {
</div>
)}
{activeTab === 'categories' && <CategoryManager />}
{/* Invite Links (inside users tab) */}
{activeTab === 'users' && (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden mt-6">
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
<div>
<h2 className="font-semibold text-slate-900">{t('admin.invite.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.invite.subtitle')}</p>
</div>
<button
onClick={() => setShowCreateInvite(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" />
{t('admin.invite.create')}
</button>
</div>
{activeTab === 'addons' && <AddonManager />}
{invites.length === 0 ? (
<div className="p-8 text-center text-sm text-slate-400">{t('admin.invite.empty')}</div>
) : (
<div className="divide-y divide-slate-100">
{invites.map(inv => {
const isExpired = inv.expires_at && new Date(inv.expires_at) < new Date()
const isUsedUp = inv.max_uses > 0 && inv.used_count >= inv.max_uses
const isActive = !isExpired && !isUsedUp
return (
<div key={inv.id} className="px-5 py-3 flex items-center gap-4">
<Link2 className="w-4 h-4 flex-shrink-0" style={{ color: isActive ? 'var(--text-primary)' : '#d1d5db' }} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-slate-600 truncate">{inv.token.slice(0, 12)}...</code>
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${
isActive ? 'bg-green-50 text-green-700' : 'bg-slate-100 text-slate-400'
}`}>
{isUsedUp ? t('admin.invite.usedUp') : isExpired ? t('admin.invite.expired') : t('admin.invite.active')}
</span>
</div>
<div className="text-xs text-slate-400 mt-0.5">
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale, { timeZone: serverTimezone })}`}
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
</div>
</div>
{isActive && (
<button onClick={() => copyInviteLink(inv.token)} title={t('admin.invite.copyLink')}
className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-400 hover:text-slate-700 transition-colors">
<Copy className="w-3.5 h-3.5" />
</button>
)}
<button onClick={() => handleDeleteInvite(inv.id)} title={t('common.delete')}
className="p-1.5 rounded-lg hover:bg-red-50 text-slate-400 hover:text-red-500 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)
})}
</div>
)}
</div>
)}
{/* Create Invite Modal */}
<Modal isOpen={showCreateInvite} onClose={() => setShowCreateInvite(false)} title={t('admin.invite.create')} size="sm">
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">{t('admin.invite.maxUses')}</label>
<div className="flex gap-2">
{[1, 2, 3, 4, 5, 0].map(n => (
<button key={n} type="button" onClick={() => setInviteForm(f => ({ ...f, max_uses: n }))}
className={`flex-1 py-2 rounded-lg text-sm font-semibold border transition-colors ${
inviteForm.max_uses === n ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-600 border-slate-200 hover:border-slate-400'
}`}>
{n === 0 ? '∞' : `${n}×`}
</button>
))}
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">{t('admin.invite.expiry')}</label>
<div className="flex gap-2">
{[
{ value: 1, label: '1d' },
{ value: 3, label: '3d' },
{ value: 7, label: '7d' },
{ value: 14, label: '14d' },
{ value: '', label: '∞' },
].map(opt => (
<button key={String(opt.value)} type="button" onClick={() => setInviteForm(f => ({ ...f, expires_in_days: opt.value as number | '' }))}
className={`flex-1 py-2 rounded-lg text-sm font-semibold border transition-colors ${
inviteForm.expires_in_days === opt.value ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-600 border-slate-200 hover:border-slate-400'
}`}>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-slate-100">
<button onClick={() => setShowCreateInvite(false)} className="px-4 py-2 text-sm text-slate-500 hover:text-slate-700">{t('common.cancel')}</button>
<button onClick={handleCreateInvite} className="px-4 py-2 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700">{t('admin.invite.createAndCopy')}</button>
</div>
</div>
</Modal>
{activeTab === 'config' && (
<div className="space-y-6">
<PackingTemplateManager />
<CategoryManager />
</div>
)}
{activeTab === 'addons' && (
<div className="space-y-6">
<AddonManager bagTrackingEnabled={bagTrackingEnabled} onToggleBagTracking={async () => {
const next = !bagTrackingEnabled
setBagTrackingEnabled(next)
try { await adminApi.updateBagTracking(next) } catch { setBagTrackingEnabled(!next) }
}} />
</div>
)}
{activeTab === 'settings' && (
<div className="space-y-6">
@@ -715,11 +888,31 @@ export default function AdminPage(): React.ReactElement {
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
{/* OIDC-only mode toggle */}
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.oidcOnlyMode')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.oidcOnlyModeHint')}</p>
</div>
<button
onClick={() => setOidcConfig(c => ({ ...c, oidc_only: !c.oidc_only }))}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ml-4 ${
oidcConfig.oidc_only ? 'bg-slate-900' : 'bg-slate-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
oidcConfig.oidc_only ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<button
onClick={async () => {
setSavingOidc(true)
try {
const payload = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name }
const payload: Record<string, unknown> = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, oidc_only: oidcConfig.oidc_only }
if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret
await adminApi.updateOidc(payload)
toast.success(t('admin.oidcSaved'))
@@ -737,11 +930,58 @@ export default function AdminPage(): React.ReactElement {
</button>
</div>
</div>
{/* SMTP / Notifications */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">{t('admin.smtp.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
</div>
<div className="p-6 space-y-3">
{smtpLoaded && [
{ key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' },
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587' },
{ key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' },
{ key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
{ key: 'notification_webhook_url', label: 'Webhook URL (optional)', placeholder: 'https://discord.com/api/webhooks/...' },
{ key: 'app_url', label: 'App URL (for email links)', placeholder: 'https://trek.example.com' },
].map(field => (
<div key={field.key}>
<label className="block text-xs font-medium text-slate-500 mb-1">{field.label}</label>
<input
type={field.type || 'text'}
value={smtpValues[field.key] || ''}
onChange={e => setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder}
onBlur={e => { if (e.target.value !== '') authApi.updateAppSettings({ [field.key]: e.target.value }).then(() => toast.success(t('common.saved'))).catch(() => {}) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
))}
<button
onClick={async () => {
for (const k of ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from']) {
if (smtpValues[k]) await authApi.updateAppSettings({ [k]: smtpValues[k] }).catch(() => {})
}
try {
const result = await notificationsApi.testSmtp()
if (result.success) toast.success(t('admin.smtp.testSuccess'))
else toast.error(result.error || t('admin.smtp.testFailed'))
} catch { toast.error(t('admin.smtp.testFailed')) }
}}
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
>
{t('admin.smtp.testButton')}
</button>
</div>
</div>
</div>
)}
{activeTab === 'backup' && <BackupPanel />}
{activeTab === 'audit' && <AuditLogPanel />}
{activeTab === 'github' && <GitHubPanel />}
</div>
</div>
+508 -24
View File
@@ -1,10 +1,11 @@
import React, { useEffect, useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from '../i18n'
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../i18n'
import { useSettingsStore } from '../store/settingsStore'
import Navbar from '../components/Layout/Navbar'
import apiClient from '../api/client'
import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X } from 'lucide-react'
import apiClient, { mapsApi } from '../api/client'
import CustomSelect from '../components/shared/CustomSelect'
import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
import L from 'leaflet'
import type { AtlasPlace, GeoJsonFeatureCollection, TranslationFn } from '../types'
@@ -40,6 +41,7 @@ interface AtlasData {
interface CountryDetail {
places: AtlasPlace[]
trips: { id: number; title: string }[]
manually_marked?: boolean
}
function MobileStats({ data, stats, countries, resolveName, t, dark }: { data: AtlasData | null; stats: AtlasStats; countries: AtlasCountry[]; resolveName: (code: string) => string; t: TranslationFn; dark: boolean }): React.ReactElement {
@@ -100,7 +102,7 @@ function useCountryNames(language: string): (code: string) => string {
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
useEffect(() => {
try {
const dn = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' })
const dn = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' })
setResolver(() => (code: string) => { try { return dn.of(code) || code } catch { return code } })
} catch { /* */ }
}, [language])
@@ -108,7 +110,9 @@ function useCountryNames(language: string): (code: string) => string {
}
// Map visited country codes to ISO-3166 alpha3 (GeoJSON uses alpha3)
const A2_TO_A3: Record<string, string> = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BR":"BRA","BE":"BEL","BG":"BGR","CA":"CAN","CL":"CHL","CN":"CHN","CO":"COL","HR":"HRV","CZ":"CZE","DK":"DNK","EG":"EGY","EE":"EST","FI":"FIN","FR":"FRA","DE":"DEU","GR":"GRC","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JP":"JPN","KE":"KEN","KR":"KOR","LV":"LVA","LT":"LTU","LU":"LUX","MY":"MYS","MX":"MEX","MA":"MAR","NL":"NLD","NZ":"NZL","NO":"NOR","PK":"PAK","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","RO":"ROU","RU":"RUS","SA":"SAU","RS":"SRB","SK":"SVK","SI":"SVN","ZA":"ZAF","ES":"ESP","SE":"SWE","CH":"CHE","TH":"THA","TR":"TUR","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","VN":"VNM","NG":"NGA"}
// Built dynamically from GeoJSON + hardcoded fallbacks
const A2_TO_A3_BASE: Record<string, string> = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AG":"ATG","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BS":"BHS","BH":"BHR","BD":"BGD","BB":"BRB","BY":"BLR","BE":"BEL","BZ":"BLZ","BJ":"BEN","BT":"BTN","BO":"BOL","BA":"BIH","BW":"BWA","BR":"BRA","BN":"BRN","BG":"BGR","BF":"BFA","BI":"BDI","CV":"CPV","KH":"KHM","CM":"CMR","CA":"CAN","CF":"CAF","TD":"TCD","CL":"CHL","CN":"CHN","CO":"COL","KM":"COM","CG":"COG","CD":"COD","CR":"CRI","CI":"CIV","HR":"HRV","CU":"CUB","CY":"CYP","CZ":"CZE","DK":"DNK","DJ":"DJI","DM":"DMA","DO":"DOM","EC":"ECU","EG":"EGY","SV":"SLV","GQ":"GNQ","ER":"ERI","EE":"EST","SZ":"SWZ","ET":"ETH","FJ":"FJI","FI":"FIN","FR":"FRA","GA":"GAB","GM":"GMB","GE":"GEO","DE":"DEU","GH":"GHA","GR":"GRC","GD":"GRD","GT":"GTM","GN":"GIN","GW":"GNB","GY":"GUY","HT":"HTI","HN":"HND","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JM":"JAM","JP":"JPN","JO":"JOR","KZ":"KAZ","KE":"KEN","KI":"KIR","KP":"PRK","KR":"KOR","KW":"KWT","KG":"KGZ","LA":"LAO","LV":"LVA","LB":"LBN","LS":"LSO","LR":"LBR","LY":"LBY","LI":"LIE","LT":"LTU","LU":"LUX","MG":"MDG","MW":"MWI","MY":"MYS","MV":"MDV","ML":"MLI","MT":"MLT","MR":"MRT","MU":"MUS","MX":"MEX","MD":"MDA","MN":"MNG","ME":"MNE","MA":"MAR","MZ":"MOZ","MM":"MMR","NA":"NAM","NP":"NPL","NL":"NLD","NZ":"NZL","NI":"NIC","NE":"NER","NG":"NGA","MK":"MKD","NO":"NOR","OM":"OMN","PK":"PAK","PA":"PAN","PG":"PNG","PY":"PRY","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","QA":"QAT","RO":"ROU","RU":"RUS","RW":"RWA","SA":"SAU","SN":"SEN","RS":"SRB","SL":"SLE","SG":"SGP","SK":"SVK","SI":"SVN","SB":"SLB","SO":"SOM","ZA":"ZAF","SS":"SSD","ES":"ESP","LK":"LKA","SD":"SDN","SR":"SUR","SE":"SWE","CH":"CHE","SY":"SYR","TW":"TWN","TJ":"TJK","TZ":"TZA","TH":"THA","TL":"TLS","TG":"TGO","TT":"TTO","TN":"TUN","TR":"TUR","TM":"TKM","UG":"UGA","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","UY":"URY","UZ":"UZB","VU":"VUT","VE":"VEN","VN":"VNM","YE":"YEM","ZM":"ZMB","ZW":"ZWE"}
let A2_TO_A3: Record<string, string> = { ...A2_TO_A3_BASE }
export default function AtlasPage(): React.ReactElement {
const { t, language } = useTranslation()
@@ -149,20 +153,50 @@ export default function AtlasPage(): React.ReactElement {
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket'; code: string; name: string } | null>(null)
const [bucketMonth, setBucketMonth] = useState(0)
const [bucketYear, setBucketYear] = useState(0)
// Load atlas data
// Bucket list
interface BucketItem { id: number; name: string; lat: number | null; lng: number | null; country_code: string | null; notes: string | null; target_date: string | null }
const [bucketList, setBucketList] = useState<BucketItem[]>([])
const [showBucketAdd, setShowBucketAdd] = useState(false)
const [bucketForm, setBucketForm] = useState({ name: '', notes: '', lat: '', lng: '', target_date: '' })
const [bucketSearch, setBucketSearch] = useState('')
const [bucketSearchResults, setBucketSearchResults] = useState<any[]>([])
const [bucketSearching, setBucketSearching] = useState(false)
const [bucketPoiMonth, setBucketPoiMonth] = useState(0)
const [bucketPoiYear, setBucketPoiYear] = useState(0)
const [bucketTab, setBucketTab] = useState<'stats' | 'bucket'>('stats')
const bucketMarkersRef = useRef<any>(null)
// Load atlas data + bucket list
useEffect(() => {
apiClient.get('/addons/atlas/stats').then(r => {
setData(r.data)
Promise.all([
apiClient.get('/addons/atlas/stats'),
apiClient.get('/addons/atlas/bucket-list'),
]).then(([statsRes, bucketRes]) => {
setData(statsRes.data)
setBucketList(bucketRes.data.items || [])
setLoading(false)
}).catch(() => setLoading(false))
}, [])
// Load GeoJSON world data (direct GeoJSON, no conversion needed)
useEffect(() => {
fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson')
fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson')
.then(r => r.json())
.then(geo => setGeoData(geo))
.then(geo => {
// Dynamically build A2→A3 mapping from GeoJSON
for (const f of geo.features) {
const a2 = f.properties?.ISO_A2
const a3 = f.properties?.ADM0_A3 || f.properties?.ISO_A3
if (a2 && a3 && a2 !== '-99' && a3 !== '-99' && !A2_TO_A3[a2]) {
A2_TO_A3[a2] = a3
}
}
setGeoData(geo)
})
.catch(() => {})
}, [])
@@ -222,6 +256,10 @@ export default function AtlasPage(): React.ReactElement {
const countryMap = {}
data.countries.forEach(c => { if (A2_TO_A3[c.code]) countryMap[A2_TO_A3[c.code]] = c })
// Preserve current map view
const currentCenter = mapInstance.current.getCenter()
const currentZoom = mapInstance.current.getZoom()
if (geoLayerRef.current) {
mapInstance.current.removeLayer(geoLayerRef.current)
}
@@ -241,7 +279,7 @@ export default function AtlasPage(): React.ReactElement {
interactive: true,
bubblingMouseEvents: false,
style: (feature) => {
const a3 = feature.properties?.ISO_A3 || feature.properties?.ADM0_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
const visited = visitedA3.has(a3)
return {
fillColor: visited ? colorForCode(a3) : (dark ? '#1e1e2e' : '#e2e8f0'),
@@ -251,11 +289,11 @@ export default function AtlasPage(): React.ReactElement {
}
},
onEachFeature: (feature, layer) => {
const a3 = feature.properties?.ISO_A3 || feature.properties?.ADM0_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
const c = countryMap[a3]
if (c) {
const name = resolveName(c.code)
const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { month: 'short', year: 'numeric' }) }
const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(getLocaleForLanguage(language), { month: 'short', year: 'numeric' }) }
const tooltipHtml = `
<div style="display:flex;flex-direction:column;gap:8px;min-width:160px">
<div style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;padding-bottom:6px;border-bottom:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}">${name}</div>
@@ -278,18 +316,156 @@ export default function AtlasPage(): React.ReactElement {
layer.bindTooltip(tooltipHtml, {
sticky: false, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
})
layer.on('click', () => loadCountryDetail(c.code))
layer.on('click', () => {
if (c.placeCount === 0 && c.tripCount === 0) {
// Manually marked only — show unmark popup
handleUnmarkCountry(c.code)
} else {
loadCountryDetail(c.code)
}
})
layer.on('mouseover', (e) => {
e.target.setStyle({ fillOpacity: 0.9, weight: 2, color: dark ? '#818cf8' : '#4f46e5' })
})
layer.on('mouseout', (e) => {
geoLayerRef.current.resetStyle(e.target)
})
} else {
// Unvisited country — allow clicking to mark as visited
// Reverse lookup: find A2 code from A3, or use A3 directly
const a3ToA2Entry = Object.entries(A2_TO_A3).find(([, v]) => v === a3)
const isoA2 = feature.properties?.ISO_A2
const countryCode = a3ToA2Entry ? a3ToA2Entry[0] : (isoA2 && isoA2 !== '-99' ? isoA2 : null)
if (countryCode && countryCode !== '-99') {
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
})
layer.on('click', () => handleMarkCountry(countryCode, name))
layer.on('mouseover', (e) => {
e.target.setStyle({ fillOpacity: 0.5, weight: 1.5, color: dark ? '#555' : '#94a3b8' })
})
layer.on('mouseout', (e) => {
geoLayerRef.current.resetStyle(e.target)
})
}
}
}
}).addTo(mapInstance.current)
// Restore map view after re-render
mapInstance.current.setView(currentCenter, currentZoom, { animate: false })
}, [geoData, data, dark])
const handleMarkCountry = (code: string, name: string): void => {
setConfirmAction({ type: 'choose', code, name })
}
const handleUnmarkCountry = (code: string): void => {
const country = data?.countries.find(c => c.code === code)
setConfirmAction({ type: 'unmark', code, name: resolveName(code) })
}
const executeConfirmAction = async (): Promise<void> => {
if (!confirmAction) return
const { type, code } = confirmAction
setConfirmAction(null)
// Update local state immediately (no API reload = no map re-render flash)
if (type === 'mark') {
apiClient.post(`/addons/atlas/country/${code}/mark`).catch(() => {})
setData(prev => {
if (!prev || prev.countries.find(c => c.code === code)) return prev
return {
...prev,
countries: [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 },
}
})
} else {
apiClient.delete(`/addons/atlas/country/${code}/mark`).catch(() => {})
setSelectedCountry(null)
setCountryDetail(null)
setData(prev => {
if (!prev) return prev
const c = prev.countries.find(c => c.code === code)
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
return {
...prev,
countries: prev.countries.filter(c => c.code !== code),
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
}
})
}
}
const handleAddBucketItem = async (): Promise<void> => {
if (!bucketForm.name.trim()) return
try {
const data: Record<string, unknown> = { name: bucketForm.name.trim() }
if (bucketForm.notes.trim()) data.notes = bucketForm.notes.trim()
if (bucketForm.lat && bucketForm.lng) { data.lat = parseFloat(bucketForm.lat); data.lng = parseFloat(bucketForm.lng) }
const targetDate = bucketForm.target_date || (bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null)
if (targetDate) data.target_date = targetDate
const r = await apiClient.post('/addons/atlas/bucket-list', data)
setBucketList(prev => [r.data.item, ...prev])
setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' })
setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0)
setShowBucketAdd(false)
} catch { /* */ }
}
const handleDeleteBucketItem = async (id: number): Promise<void> => {
try {
await apiClient.delete(`/addons/atlas/bucket-list/${id}`)
setBucketList(prev => prev.filter(i => i.id !== id))
} catch { /* */ }
}
const handleBucketPoiSearch = async () => {
if (!bucketSearch.trim()) return
setBucketSearching(true)
try {
const result = await mapsApi.search(bucketSearch, language)
setBucketSearchResults(result.places || [])
} catch {} finally { setBucketSearching(false) }
}
const handleSelectBucketPoi = (result: any) => {
const targetDate = bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null
setBucketForm({
name: result.name || bucketSearch,
notes: '',
lat: String(result.lat || ''),
lng: String(result.lng || ''),
target_date: targetDate || '',
})
setBucketSearchResults([])
setBucketSearch('')
}
// Render bucket list markers on map
useEffect(() => {
if (!mapInstance.current) return
if (bucketMarkersRef.current) {
mapInstance.current.removeLayer(bucketMarkersRef.current)
}
if (bucketList.length === 0) return
const markers = bucketList.filter(b => b.lat && b.lng).map(b => {
const icon = L.divIcon({
className: '',
html: `<div style="width:28px;height:28px;border-radius:50%;background:rgba(251,191,36,0.9);display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.3);border:2px solid white"><svg width="14" height="14" viewBox="0 0 24 24" fill="white" stroke="none"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></div>`,
iconSize: [28, 28],
iconAnchor: [14, 14],
})
return L.marker([b.lat!, b.lng!], { icon }).bindTooltip(
`<div style="font-size:12px;font-weight:600">${b.name}</div>${b.notes ? `<div style="font-size:10px;opacity:0.7;margin-top:2px">${b.notes}</div>` : ''}`,
{ className: 'atlas-tooltip', direction: 'top', offset: [0, -14] }
)
})
bucketMarkersRef.current = L.layerGroup(markers).addTo(mapInstance.current)
}, [bucketList])
const loadCountryDetail = async (code: string): Promise<void> => {
setSelectedCountry(code)
try {
@@ -348,6 +524,7 @@ export default function AtlasPage(): React.ReactElement {
left: '50%',
transform: 'translateX(-50%)',
width: 'fit-content',
maxWidth: 'calc(100vw - 40px)',
background: dark ? 'rgba(10,10,15,0.55)' : 'rgba(255,255,255,0.2)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
@@ -368,13 +545,152 @@ export default function AtlasPage(): React.ReactElement {
<SidebarContent
data={data} stats={stats} countries={countries} selectedCountry={selectedCountry}
countryDetail={countryDetail} resolveName={resolveName}
onCountryClick={loadCountryDetail} onTripClick={(id) => navigate(`/trips/${id}`)}
onCountryClick={loadCountryDetail} onTripClick={(id) => navigate(`/trips/${id}`)} onUnmarkCountry={handleUnmarkCountry}
bucketList={bucketList} bucketTab={bucketTab} setBucketTab={setBucketTab}
showBucketAdd={showBucketAdd} setShowBucketAdd={setShowBucketAdd}
bucketForm={bucketForm} setBucketForm={setBucketForm}
onAddBucket={handleAddBucketItem} onDeleteBucket={handleDeleteBucketItem}
onSearchBucket={handleBucketPoiSearch} onSelectBucketPoi={handleSelectBucketPoi}
bucketSearchResults={bucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth}
bucketPoiYear={bucketPoiYear} setBucketPoiYear={setBucketPoiYear} bucketSearching={bucketSearching}
bucketSearch={bucketSearch} setBucketSearch={setBucketSearch}
t={t} dark={dark}
/>
</div>
</div>
{/* Country action popup */}
{confirmAction && (
<div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}
onClick={() => setConfirmAction(null)}>
<div style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 340, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}
onClick={e => e.stopPropagation()}>
{confirmAction.code.length === 2 ? (
<img src={`https://flagcdn.com/w80/${confirmAction.code.toLowerCase()}.png`} alt={confirmAction.code} style={{ width: 48, height: 34, borderRadius: 6, objectFit: 'cover', marginBottom: 12, display: 'inline-block' }} />
) : (
<div style={{ fontSize: 36, marginBottom: 12 }}>{countryCodeToFlag(confirmAction.code)}</div>
)}
<h3 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{confirmAction.name}</h3>
{confirmAction.type === 'choose' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button onClick={async () => {
try {
await apiClient.post(`/addons/atlas/country/${confirmAction.code}/mark`)
setData(prev => {
if (!prev || prev.countries.find(c => c.code === confirmAction.code)) return prev
return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
})
} catch {}
setConfirmAction(null)
}}
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<MapPin size={18} style={{ color: 'var(--text-primary)', flexShrink: 0 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.markVisited')}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.markVisitedHint')}</div>
</div>
</button>
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' as any })}
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<Star size={18} style={{ color: '#fbbf24', flexShrink: 0 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.addToBucket')}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
</div>
</button>
</div>
)}
{confirmAction.type === 'unmark' && (
<>
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmark')}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button onClick={() => setConfirmAction(null)}
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={executeConfirmAction}
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: '#ef4444', color: 'white' }}>
{t('atlas.unmark')}
</button>
</div>
</>
)}
{confirmAction.type === 'bucket' && (
<>
<p style={{ margin: '0 0 14px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.bucketWhen')}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<CustomSelect
value={bucketMonth}
onChange={v => setBucketMonth(Number(v))}
placeholder={t('atlas.month')}
options={[
{ value: 0, label: '—' },
...Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })),
]}
size="sm"
/>
</div>
<div style={{ flex: 1 }}>
<CustomSelect
value={bucketYear}
onChange={v => setBucketYear(Number(v))}
placeholder={t('atlas.year')}
options={[
{ value: 0, label: '—' },
...Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) })),
]}
size="sm"
/>
</div>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'choose' })}
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.back')}
</button>
<button onClick={async () => {
const targetDate = bucketMonth > 0 && bucketYear > 0 ? `${bucketYear}-${String(bucketMonth).padStart(2, '0')}` : null
try {
const r = await apiClient.post('/addons/atlas/bucket-list', { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate })
setBucketList(prev => [r.data.item, ...prev])
} catch {}
setBucketMonth(0); setBucketYear(0)
setConfirmAction(null)
}}
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: '#fbbf24', color: '#1a1a1a' }}>
{t('atlas.addToBucket')}
</button>
</div>
</>
)}
{confirmAction.type === 'mark' && (
<>
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmMark')}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button onClick={() => setConfirmAction(null)}
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={executeConfirmAction}
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: 'var(--text-primary)', color: 'white' }}>
{t('atlas.markVisited')}
</button>
</div>
</>
)}
</div>
</div>
)}
</div>
)
}
@@ -388,11 +704,32 @@ interface SidebarContentProps {
resolveName: (code: string) => string
onCountryClick: (code: string) => void
onTripClick: (id: number) => void
onUnmarkCountry?: (code: string) => void
bucketList: any[]
bucketTab: 'stats' | 'bucket'
setBucketTab: (tab: 'stats' | 'bucket') => void
showBucketAdd: boolean
setShowBucketAdd: (v: boolean) => void
bucketForm: { name: string; notes: string; lat: string; lng: string; target_date: string }
setBucketForm: (f: { name: string; notes: string; lat: string; lng: string; target_date: string }) => void
onAddBucket: () => Promise<void>
onDeleteBucket: (id: number) => Promise<void>
onSearchBucket: () => Promise<void>
onSelectBucketPoi: (result: any) => void
bucketSearchResults: any[]
bucketPoiMonth: number
setBucketPoiMonth: (v: number) => void
bucketPoiYear: number
setBucketPoiYear: (v: number) => void
bucketSearching: boolean
bucketSearch: string
setBucketSearch: (v: string) => void
t: TranslationFn
dark: boolean
}
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, t, dark }: SidebarContentProps): React.ReactElement {
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
const { language } = useTranslation()
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
const tp = dark ? '#f1f5f9' : '#0f172a'
const tm = dark ? '#94a3b8' : '#64748b'
@@ -405,20 +742,154 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
const CL = { 'Europe': t('atlas.europe'), 'Asia': t('atlas.asia'), 'North America': t('atlas.northAmerica'), 'South America': t('atlas.southAmerica'), 'Africa': t('atlas.africa'), 'Oceania': t('atlas.oceania') }
const contColors = ['#818cf8', '#f472b6', '#34d399', '#fbbf24', '#fb923c', '#22d3ee']
if (countries.length === 0 && !lastTrip) {
// Tab switcher
const tabBar = (
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', marginBottom: 4 }}>
{[{ id: 'stats', label: t('atlas.statsTab'), icon: Globe }, { id: 'bucket', label: t('atlas.bucketTab'), icon: Star }].map(tab => (
<button key={tab.id} onClick={() => setBucketTab(tab.id as any)}
style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '7px 0', borderRadius: 10, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 12, fontWeight: 600, transition: 'all 0.15s',
background: bucketTab === tab.id ? bg(0.1) : 'transparent',
color: bucketTab === tab.id ? tp : tf,
}}>
<tab.icon size={13} />
{tab.label}
</button>
))}
</div>
)
if (countries.length === 0 && !lastTrip && bucketTab !== 'bucket') {
return (
<div className="p-8 text-center">
<Globe size={28} className="mx-auto mb-2" style={{ color: tf, opacity: 0.4 }} />
<p className="text-sm font-medium" style={{ color: tm }}>{t('atlas.noData')}</p>
<p className="text-xs mt-1" style={{ color: tf }}>{t('atlas.noDataHint')}</p>
</div>
<>
{tabBar}
<div className="p-8 text-center">
<Globe size={28} className="mx-auto mb-2" style={{ color: tf, opacity: 0.4 }} />
<p className="text-sm font-medium" style={{ color: tm }}>{t('atlas.noData')}</p>
<p className="text-xs mt-1" style={{ color: tf }}>{t('atlas.noDataHint')}</p>
</div>
</>
)
}
const thisYear = new Date().getFullYear()
const divider = `2px solid ${bg(0.08)}`
// Bucket list content
const bucketContent = (
<>
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px' }}>
{bucketList.map(item => (
<div key={item.id} className="group flex flex-col items-center justify-center shrink-0" style={{ padding: '8px 14px', position: 'relative', minWidth: 80 }}>
{(() => {
const code = item.country_code?.length === 2 ? item.country_code : (Object.entries(A2_TO_A3).find(([, v]) => v === item.country_code)?.[0] || '')
return code ? (
<img src={`https://flagcdn.com/w40/${code.toLowerCase()}.png`} alt={code} style={{ width: 28, height: 20, borderRadius: 4, objectFit: 'cover', marginBottom: 4 }} />
) : <Star size={16} style={{ color: '#fbbf24', marginBottom: 4 }} fill="#fbbf24" />
})()}
<span className="text-xs font-semibold text-center leading-tight" style={{ color: tp, maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.name}</span>
{item.target_date && (() => {
const [y, m] = item.target_date.split('-')
const label = m ? new Date(Number(y), Number(m) - 1).toLocaleString(language, { month: 'short', year: 'numeric' }) : y
return <span className="text-[9px] mt-0.5 text-center" style={{ color: tf }}>{label}</span>
})()}
{!item.target_date && item.notes && <span className="text-[9px] mt-0.5 text-center" style={{ color: tf, maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.notes}</span>}
<button onClick={() => onDeleteBucket(item.id)}
className="opacity-0 group-hover:opacity-100"
style={{ position: 'absolute', top: 4, right: 4, background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: tf, display: 'flex', transition: 'opacity 0.15s' }}>
<X size={10} />
</button>
</div>
))}
{bucketList.length === 0 && !showBucketAdd && (
<div className="flex items-center justify-center py-4 px-6" style={{ color: tf, fontSize: 12 }}>
{t('atlas.bucketEmptyHint')}
</div>
)}
</div>
{showBucketAdd ? (
<div style={{ padding: '8px 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{/* Search or manual name */}
<div style={{ position: 'relative' }}>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={bucketForm.name || bucketSearch}
onChange={e => { const v = e.target.value; if (bucketForm.name) setBucketForm({ ...bucketForm, name: v }); else setBucketSearch(v) }}
onKeyDown={e => { if (e.key === 'Enter' && !bucketForm.name) onSearchBucket(); else if (e.key === 'Enter') onAddBucket(); if (e.key === 'Escape') setShowBucketAdd(false) }}
placeholder={t('atlas.bucketNamePlaceholder')}
autoFocus
style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 12, fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)' }}
/>
{!bucketForm.name && (
<button onClick={onSearchBucket} disabled={bucketSearching}
style={{ padding: '6px 10px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Search size={12} />
</button>
)}
{bucketForm.name && (
<button onClick={() => { setBucketForm({ ...bucketForm, name: '', lat: '', lng: '' }); setBucketSearch('') }}
style={{ padding: '6px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}>
<X size={12} />
</button>
)}
</div>
{bucketSearchResults.length > 0 && (
<div style={{ position: 'absolute', bottom: '100%', left: 0, right: 0, zIndex: 50, marginBottom: 4, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.12)', maxHeight: 160, overflowY: 'auto' }}>
{bucketSearchResults.slice(0, 6).map((r, i) => (
<button key={i} onClick={() => onSelectBucketPoi(r)} style={{ display: 'flex', flexDirection: 'column', gap: 1, width: '100%', padding: '6px 10px', border: 'none', background: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit', borderBottom: '1px solid var(--border-faint)' }}>
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-primary)' }}>{r.name}</span>
{r.address && <span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{r.address}</span>}
</button>
))}
</div>
)}
</div>
{/* Selected place indicator */}
{bucketForm.lat && bucketForm.lng && (
<div style={{ fontSize: 10, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', gap: 4 }}>
<MapPin size={10} /> {Number(bucketForm.lat).toFixed(4)}, {Number(bucketForm.lng).toFixed(4)}
</div>
)}
{/* Month / Year with CustomSelect */}
<div style={{ display: 'flex', gap: 6 }}>
<div style={{ flex: 1 }}>
<CustomSelect value={bucketPoiMonth} onChange={v => setBucketPoiMonth(Number(v))} placeholder={t('atlas.month')} size="sm"
options={[{ value: 0, label: '—' }, ...Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'short' }) }))]} />
</div>
<div style={{ flex: 1 }}>
<CustomSelect value={bucketPoiYear} onChange={v => setBucketPoiYear(Number(v))} placeholder={t('atlas.year')} size="sm"
options={[{ value: 0, label: '—' }, ...Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) }))]} />
</div>
</div>
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button onClick={() => { setShowBucketAdd(false); setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' }); setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0) }}
style={{ fontSize: 11, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button onClick={onAddBucket} disabled={!bucketForm.name.trim()}
style={{ fontSize: 11, padding: '4px 12px', borderRadius: 6, border: 'none', background: '#fbbf24', color: '#1a1a1a', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: bucketForm.name.trim() ? 1 : 0.5 }}>
{t('common.add')}
</button>
</div>
</div>
) : (
<div style={{ padding: '4px 16px 8px' }}>
<button onClick={() => setShowBucketAdd(true)}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, width: '100%', padding: '5px 0', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', fontSize: 11, color: tf, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={11} /> {t('atlas.addPoi')}
</button>
</div>
)}
</>
)
return (
<>
{tabBar}
{/* Both tabs always rendered so the wider one sets the panel width */}
<div style={{ display: 'grid' }}>
<div style={bucketTab === 'bucket' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
<div className="flex items-stretch justify-center">
{/* ═══ SECTION 1: Numbers ═══ */}
@@ -507,12 +978,25 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
{trip.title}
</button>
))}
{countryDetail.manually_marked && onUnmarkCountry && (
<button onClick={() => onUnmarkCountry(selectedCountry!)}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-semibold transition-opacity hover:opacity-75"
style={{ background: 'rgba(239,68,68,0.1)', color: '#ef4444' }}>
<X size={9} />
{t('atlas.unmark')}
</button>
)}
</div>
</div>
</div>
</>
)}
</div>
</div>
<div style={bucketTab === 'stats' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
{bucketContent}
</div>
</div>
</>
)
}
+157 -19
View File
@@ -14,6 +14,7 @@ import { useToast } from '../components/shared/Toast'
import {
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft,
LayoutGrid, List,
} from 'lucide-react'
interface DashboardTrip {
@@ -53,12 +54,12 @@ function getTripStatus(trip: DashboardTrip): string | null {
return 'past'
}
function formatDate(dateStr: string | null | undefined, locale: string = 'de-DE'): string | null {
function formatDate(dateStr: string | null | undefined, locale: string = 'en-US'): string | null {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })
}
function formatDateShort(dateStr: string | null | undefined, locale: string = 'de-DE'): string | null {
function formatDateShort(dateStr: string | null | undefined, locale: string = 'en-US'): string | null {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
}
@@ -315,6 +316,102 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omi
)
}
// ── List View Item ──────────────────────────────────────────────────────────
function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
const status = getTripStatus(trip)
const [hovered, setHovered] = useState(false)
const coverBg = trip.cover_image
? `url(${trip.cover_image}) center/cover no-repeat`
: tripGradient(trip.id)
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => onClick(trip)}
style={{
display: 'flex', alignItems: 'center', gap: 14, padding: '10px 16px',
background: hovered ? 'var(--bg-tertiary)' : 'var(--bg-card)', borderRadius: 14,
border: `1px solid ${hovered ? 'var(--text-faint)' : 'var(--border-primary)'}`,
cursor: 'pointer', transition: 'all 0.15s',
boxShadow: hovered ? '0 4px 16px rgba(0,0,0,0.08)' : '0 1px 3px rgba(0,0,0,0.03)',
}}
>
{/* Cover thumbnail */}
<div style={{
width: 52, height: 52, borderRadius: 12, flexShrink: 0,
background: coverBg, position: 'relative', overflow: 'hidden',
}}>
{status === 'ongoing' && (
<span style={{
position: 'absolute', top: 4, left: 4,
width: 7, height: 7, borderRadius: '50%', background: '#ef4444',
animation: 'blink 1s ease-in-out infinite',
}} />
)}
</div>
{/* Title & description */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{trip.title}
</span>
{!trip.is_owner && (
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 99, whiteSpace: 'nowrap', flexShrink: 0 }}>
{t('dashboard.shared')}
</span>
)}
{status && (
<span style={{
fontSize: 10, fontWeight: 700, padding: '1px 8px', borderRadius: 99,
background: status === 'ongoing' ? 'rgba(239,68,68,0.1)' : 'var(--bg-tertiary)',
color: status === 'ongoing' ? '#ef4444' : 'var(--text-muted)',
whiteSpace: 'nowrap', flexShrink: 0,
}}>
{status === 'ongoing' ? t('dashboard.status.ongoing')
: status === 'today' ? t('dashboard.status.today')
: status === 'tomorrow' ? t('dashboard.status.tomorrow')
: status === 'future' ? t('dashboard.status.daysLeft', { count: daysUntil(trip.start_date) })
: t('dashboard.status.past')}
</span>
)}
</div>
{trip.description && (
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: '2px 0 0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{trip.description}
</p>
)}
</div>
{/* Date & stats */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexShrink: 0 }}>
{trip.start_date && (
<div className="hidden sm:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
<Calendar size={11} />
{formatDateShort(trip.start_date, locale)}
{trip.end_date && <> {formatDateShort(trip.end_date, locale)}</>}
</div>
)}
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
<Clock size={11} /> {trip.day_count || 0}
</div>
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
<MapPin size={11} /> {trip.place_count || 0}
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label="" />
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label="" />
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label="" danger />
</div>
</div>
)
}
// ── Archived Trip Row ────────────────────────────────────────────────────────
interface ArchivedRowProps {
trip: DashboardTrip
@@ -429,6 +526,15 @@ export default function DashboardPage(): React.ReactElement {
const [editingTrip, setEditingTrip] = useState<DashboardTrip | null>(null)
const [showArchived, setShowArchived] = useState<boolean>(false)
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile'>(false)
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
const toggleViewMode = () => {
setViewMode(prev => {
const next = prev === 'grid' ? 'list' : 'grid'
localStorage.setItem('trek_dashboard_view', next)
return next
})
}
const navigate = useNavigate()
const toast = useToast()
@@ -554,6 +660,22 @@ export default function DashboardPage(): React.ReactElement {
</p>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
{/* View mode toggle */}
<button
onClick={toggleViewMode}
title={viewMode === 'grid' ? t('dashboard.listView') : t('dashboard.gridView')}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '0 14px',
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
transition: 'background 0.15s, border-color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.borderColor = 'var(--text-faint)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.borderColor = 'var(--border-primary)' }}
>
{viewMode === 'grid' ? <List size={15} /> : <LayoutGrid size={15} />}
</button>
{/* Widget settings */}
<button
onClick={() => setShowWidgetSettings(s => s ? false : true)}
@@ -655,8 +777,8 @@ export default function DashboardPage(): React.ReactElement {
</div>
)}
{/* Spotlight */}
{!isLoading && spotlight && (
{/* Spotlight (grid mode only) */}
{!isLoading && spotlight && viewMode === 'grid' && (
<SpotlightCard
trip={spotlight}
t={t} locale={locale} dark={dark}
@@ -667,21 +789,37 @@ export default function DashboardPage(): React.ReactElement {
/>
)}
{/* Rest grid */}
{!isLoading && rest.length > 0 && (
<div className="trip-grid" style={{ display: 'grid', gap: 16, marginBottom: 40 }}>
{rest.map(trip => (
<TripCard
key={trip.id}
trip={trip}
t={t} locale={locale}
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
onDelete={handleDelete}
onArchive={handleArchive}
onClick={tr => navigate(`/trips/${tr.id}`)}
/>
))}
</div>
{/* Trips — grid or list */}
{!isLoading && (viewMode === 'grid' ? rest : trips).length > 0 && (
viewMode === 'grid' ? (
<div className="trip-grid" style={{ display: 'grid', gap: 16, marginBottom: 40 }}>
{rest.map(trip => (
<TripCard
key={trip.id}
trip={trip}
t={t} locale={locale}
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
onDelete={handleDelete}
onArchive={handleArchive}
onClick={tr => navigate(`/trips/${tr.id}`)}
/>
))}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 40 }}>
{trips.map(trip => (
<TripListItem
key={trip.id}
trip={trip}
t={t} locale={locale}
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
onDelete={handleDelete}
onArchive={handleArchive}
onClick={tr => navigate(`/trips/${tr.id}`)}
/>
))}
</div>
)
)}
{/* Archived section */}
+154 -31
View File
@@ -2,9 +2,9 @@ import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { useTranslation } from '../i18n'
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
import { authApi } from '../api/client'
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield } from 'lucide-react'
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound } from 'lucide-react'
interface AppConfig {
has_users: boolean
@@ -12,6 +12,7 @@ interface AppConfig {
demo_mode: boolean
oidc_configured: boolean
oidc_display_name?: string
oidc_only_mode: boolean
}
export default function LoginPage(): React.ReactElement {
@@ -24,38 +25,50 @@ export default function LoginPage(): React.ReactElement {
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
const [inviteToken, setInviteToken] = useState<string>('')
const [inviteValid, setInviteValid] = useState<boolean>(false)
const { login, register, demoLogin } = useAuthStore()
const { login, register, demoLogin, completeMfaLogin } = useAuthStore()
const { setLanguageLocal } = useSettingsStore()
const navigate = useNavigate()
useEffect(() => {
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
if (config) {
setAppConfig(config)
if (!config.has_users) setMode('register')
}
})
// Handle OIDC callback via short-lived auth code (secure exchange)
const params = new URLSearchParams(window.location.search)
const invite = params.get('invite')
const oidcCode = params.get('oidc_code')
const oidcError = params.get('oidc_error')
if (invite) {
setInviteToken(invite)
setMode('register')
authApi.validateInvite(invite).then(() => {
setInviteValid(true)
}).catch(() => {
setError('Invalid or expired invite link')
})
window.history.replaceState({}, '', window.location.pathname)
return
}
if (oidcCode) {
setIsLoading(true)
window.history.replaceState({}, '', '/login')
fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode))
.then(r => r.json())
.then(data => {
if (data.token) {
localStorage.setItem('auth_token', data.token)
navigate('/dashboard')
window.location.reload()
navigate('/dashboard', { replace: true })
} else {
setError(data.error || 'OIDC login failed')
}
})
.catch(() => setError('OIDC login failed'))
.finally(() => setIsLoading(false))
return
}
if (oidcError) {
const errorMessages: Record<string, string> = {
registration_disabled: t('login.oidc.registrationDisabled'),
@@ -65,8 +78,19 @@ export default function LoginPage(): React.ReactElement {
}
setError(errorMessages[oidcError] || oidcError)
window.history.replaceState({}, '', '/login')
return
}
}, [])
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
if (config) {
setAppConfig(config)
if (!config.has_users) setMode('register')
if (config.oidc_only_mode && config.oidc_configured && config.has_users) {
window.location.href = '/api/auth/oidc/login'
}
}
})
}, [navigate, t])
const handleDemoLogin = async (): Promise<void> => {
setError('')
@@ -83,18 +107,39 @@ export default function LoginPage(): React.ReactElement {
}
const [showTakeoff, setShowTakeoff] = useState<boolean>(false)
const [mfaStep, setMfaStep] = useState(false)
const [mfaToken, setMfaToken] = useState('')
const [mfaCode, setMfaCode] = useState('')
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault()
setError('')
setIsLoading(true)
try {
if (mode === 'login' && mfaStep) {
if (!mfaCode.trim()) {
setError(t('login.mfaCodeRequired'))
setIsLoading(false)
return
}
await completeMfaLogin(mfaToken, mfaCode)
setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
return
}
if (mode === 'register') {
if (!username.trim()) { setError('Username is required'); setIsLoading(false); return }
if (password.length < 6) { setError('Password must be at least 6 characters'); setIsLoading(false); return }
await register(username, email, password)
await register(username, email, password, inviteToken || undefined)
} else {
await login(email, password)
const result = await login(email, password)
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
setMfaToken(result.mfa_token)
setMfaStep(true)
setMfaCode('')
setIsLoading(false)
return
}
}
setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
@@ -104,7 +149,10 @@ export default function LoginPage(): React.ReactElement {
}
}
const showRegisterOption = appConfig?.allow_registration || !appConfig?.has_users
const showRegisterOption = (appConfig?.allow_registration || !appConfig?.has_users || inviteValid) && !appConfig?.oidc_only_mode
// In OIDC-only mode, show a minimal page that redirects directly to the IdP
const oidcOnly = appConfig?.oidc_only_mode && appConfig?.oidc_configured
const inputBase: React.CSSProperties = {
width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb',
@@ -186,7 +234,7 @@ export default function LoginPage(): React.ReactElement {
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
}}>
<img src="/logo-light.svg" alt="NOMAD" style={{ height: 72 }} />
<img src="/logo-light.svg" alt="TREK" style={{ height: 72 }} />
<p style={{ margin: 0, fontSize: 20, color: 'rgba(255,255,255,0.6)', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase', whiteSpace: 'nowrap' }}>{t('login.tagline')}</p>
</div>
@@ -266,9 +314,14 @@ export default function LoginPage(): React.ReactElement {
return (
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}>
{/* Sprach-Toggle oben rechts */}
{/* Language toggle */}
<button
onClick={() => setLanguageLocal(language === 'en' ? 'de' : 'en')}
onClick={() => {
const languages = SUPPORTED_LANGUAGES.map(({ value }) => value)
const currentIndex = languages.findIndex(code => code === language)
const nextLanguage = languages[(currentIndex + 1) % languages.length]
setLanguageLocal(nextLanguage)
}}
style={{
position: 'absolute', top: 16, right: 16, zIndex: 10,
display: 'flex', alignItems: 'center', gap: 6,
@@ -282,7 +335,7 @@ export default function LoginPage(): React.ReactElement {
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'}
>
<Globe size={14} />
{language === 'en' ? 'EN' : 'DE'}
{language.toUpperCase()}
</button>
{/* Left — branding */}
@@ -384,7 +437,7 @@ export default function LoginPage(): React.ReactElement {
<div style={{ position: 'relative', zIndex: 1, maxWidth: 560, textAlign: 'center' }}>
{/* Logo */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 48 }}>
<img src="/logo-light.svg" alt="NOMAD" style={{ height: 64 }} />
<img src="/logo-light.svg" alt="TREK" style={{ height: 64 }} />
</div>
<h2 style={{ margin: '0 0 12px', fontSize: 36, fontWeight: 700, color: 'white', lineHeight: 1.15, letterSpacing: '-0.02em', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase' }}>
@@ -429,16 +482,52 @@ export default function LoginPage(): React.ReactElement {
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, marginBottom: 36 }}
className="mobile-logo">
<style>{`@media(min-width:1024px){.mobile-logo{display:none!important}}`}</style>
<img src="/logo-dark.svg" alt="NOMAD" style={{ height: 48 }} />
<img src="/logo-dark.svg" alt="TREK" style={{ height: 48 }} />
<p style={{ margin: 0, fontSize: 16, color: '#9ca3af', fontFamily: "'MuseoModerno', sans-serif", textTransform: 'lowercase', whiteSpace: 'nowrap' }}>{t('login.tagline')}</p>
</div>
<div style={{ background: 'white', borderRadius: 20, border: '1px solid #e5e7eb', padding: '36px 32px', boxShadow: '0 2px 16px rgba(0,0,0,0.06)' }}>
{oidcOnly ? (
<>
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>{t('login.title')}</h2>
<p style={{ margin: '0 0 24px', fontSize: 13.5, color: '#9ca3af' }}>{t('login.oidcOnly')}</p>
{error && (
<div style={{ padding: '10px 14px', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 10, fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
{error}
</div>
)}
<a href={`/api/auth/oidc/login${inviteToken ? '?invite=' + encodeURIComponent(inviteToken) : ''}`}
style={{
width: '100%', padding: '12px',
background: '#111827', color: 'white',
border: 'none', borderRadius: 12,
fontSize: 14, fontWeight: 700, cursor: 'pointer',
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
textDecoration: 'none', transition: 'all 0.15s',
boxSizing: 'border-box',
}}
onMouseEnter={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#1f2937' }}
onMouseLeave={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#111827' }}
>
<Shield size={16} />
{t('login.oidcSignIn', { name: appConfig?.oidc_display_name || 'SSO' })}
</a>
</>
) : (
<>
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>
{mode === 'register' ? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount')) : t('login.title')}
{mode === 'login' && mfaStep
? t('login.mfaTitle')
: mode === 'register'
? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount'))
: t('login.title')}
</h2>
<p style={{ margin: '0 0 28px', fontSize: 13.5, color: '#9ca3af' }}>
{mode === 'register' ? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint')) : t('login.subtitle')}
{mode === 'login' && mfaStep
? t('login.mfaSubtitle')
: mode === 'register'
? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint'))
: t('login.subtitle')}
</p>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
@@ -448,6 +537,35 @@ export default function LoginPage(): React.ReactElement {
</div>
)}
{mode === 'login' && mfaStep && (
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.mfaCodeLabel')}</label>
<div style={{ position: 'relative' }}>
<KeyRound size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={mfaCode}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder="000000"
required
style={inputBase}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
/>
</div>
<p style={{ fontSize: 12, color: '#9ca3af', marginTop: 8 }}>{t('login.mfaHint')}</p>
<button
type="button"
onClick={() => { setMfaStep(false); setMfaToken(''); setMfaCode(''); setError('') }}
style={{ marginTop: 8, background: 'none', border: 'none', color: '#6b7280', fontSize: 13, cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}
>
{t('login.mfaBack')}
</button>
</div>
)}
{/* Username (register only) */}
{mode === 'register' && (
<div>
@@ -465,6 +583,7 @@ export default function LoginPage(): React.ReactElement {
)}
{/* Email */}
{!(mode === 'login' && mfaStep) && (
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.email')}</label>
<div style={{ position: 'relative' }}>
@@ -477,8 +596,10 @@ export default function LoginPage(): React.ReactElement {
/>
</div>
</div>
)}
{/* Password */}
{!(mode === 'login' && mfaStep) && (
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.password')}</label>
<div style={{ position: 'relative' }}>
@@ -497,6 +618,7 @@ export default function LoginPage(): React.ReactElement {
</button>
</div>
</div>
)}
<button type="submit" disabled={isLoading} style={{
marginTop: 4, width: '100%', padding: '12px', background: '#111827', color: 'white',
@@ -508,8 +630,8 @@ export default function LoginPage(): React.ReactElement {
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = '#111827'}
>
{isLoading
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : t('login.signingIn')}</>
: <><Plane size={16} />{mode === 'register' ? t('login.createAccount') : t('login.signIn')}</>
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signingIn'))}</>
: <><Plane size={16} />{mode === 'register' ? t('login.createAccount') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signIn'))}</>
}
</button>
</form>
@@ -518,23 +640,24 @@ export default function LoginPage(): React.ReactElement {
{showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && (
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: '#9ca3af' }}>
{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '}
<button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError('') }}
<button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError(''); setMfaStep(false); setMfaToken(''); setMfaCode('') }}
style={{ background: 'none', border: 'none', color: '#111827', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13 }}>
{mode === 'login' ? t('login.register') : t('login.signIn')}
</button>
</p>
)}
</>)}
</div>
{/* OIDC / SSO login button */}
{appConfig?.oidc_configured && (
{/* OIDC / SSO login button (only when OIDC is configured but not in oidc-only mode) */}
{appConfig?.oidc_configured && !oidcOnly && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}>
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
<span style={{ fontSize: 12, color: '#9ca3af' }}>{t('common.or')}</span>
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
</div>
<a href="/api/auth/oidc/login"
<a href={`/api/auth/oidc/login${inviteToken ? '?invite=' + encodeURIComponent(inviteToken) : ''}`}
style={{
marginTop: 12, width: '100%', padding: '12px',
background: 'white', color: '#374151',
+1 -1
View File
@@ -75,7 +75,7 @@ export default function RegisterPage(): React.ReactElement {
<div className="w-full max-w-md">
<div className="lg:hidden flex items-center gap-2 mb-8 justify-center">
<Map className="w-8 h-8 text-slate-900" />
<span className="text-2xl font-bold text-slate-900">NOMAD</span>
<span className="text-2xl font-bold text-slate-900">TREK</span>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
+351 -13
View File
@@ -2,12 +2,13 @@ import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { useTranslation } from '../i18n'
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar'
import CustomSelect from '../components/shared/CustomSelect'
import { useToast } from '../components/shared/Toast'
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock } from 'lucide-react'
import { authApi, adminApi } from '../api/client'
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound } from 'lucide-react'
import { authApi, adminApi, notificationsApi } from '../api/client'
import apiClient from '../api/client'
import type { LucideIcon } from 'lucide-react'
import type { UserWithOidc } from '../types'
import { getApiErrorMessage } from '../types'
@@ -33,7 +34,7 @@ interface SectionProps {
function Section({ title, icon: Icon, children }: SectionProps): React.ReactElement {
return (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', breakInside: 'avoid', marginBottom: 24 }}>
<div className="px-6 py-4 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-secondary)' }}>
<Icon className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
@@ -45,8 +46,62 @@ function Section({ title, icon: Icon, children }: SectionProps): React.ReactElem
)
}
function NotificationPreferences({ t, memoriesEnabled }: { t: any; memoriesEnabled: boolean }) {
const [prefs, setPrefs] = useState<Record<string, number> | null>(null)
const [addons, setAddons] = useState<Record<string, boolean>>({})
useEffect(() => { notificationsApi.getPreferences().then(d => setPrefs(d.preferences)).catch(() => {}) }, [])
useEffect(() => {
apiClient.get('/addons').then(r => {
const map: Record<string, boolean> = {}
for (const a of (r.data.addons || [])) map[a.id] = !!a.enabled
setAddons(map)
}).catch(() => {})
}, [])
const toggle = async (key: string) => {
if (!prefs) return
const newVal = prefs[key] ? 0 : 1
setPrefs(prev => prev ? { ...prev, [key]: newVal } : prev)
try { await notificationsApi.updatePreferences({ [key]: !!newVal }) } catch {}
}
if (!prefs) return <p style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('common.loading')}</p>
const options = [
{ key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
{ key: 'notify_booking_change', label: t('settings.notifyBookingChange') },
...(addons.vacay ? [{ key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') }] : []),
...(memoriesEnabled ? [{ key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') }] : []),
...(addons.collab ? [{ key: 'notify_collab_message', label: t('settings.notifyCollabMessage') }] : []),
...(addons.documents ? [{ key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') }] : []),
{ key: 'notify_webhook', label: t('settings.notifyWebhook') },
]
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{options.map(opt => (
<div key={opt.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{opt.label}</span>
<button onClick={() => toggle(opt.key)}
style={{
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
background: prefs[opt.key] ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
transition: 'background 0.2s',
}}>
<span style={{
position: 'absolute', top: 2, left: prefs[opt.key] ? 22 : 2,
width: 20, height: 20, borderRadius: '50%', background: 'white',
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
</div>
))}
</div>
)
}
export default function SettingsPage(): React.ReactElement {
const { user, updateProfile, uploadAvatar, deleteAvatar, logout } = useAuthStore()
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode } = useAuthStore()
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
const avatarInputRef = React.useRef<HTMLInputElement>(null)
const { settings, updateSetting, updateSettings } = useSettingsStore()
@@ -56,6 +111,59 @@ export default function SettingsPage(): React.ReactElement {
const [saving, setSaving] = useState<Record<string, boolean>>({})
// Immich
const [memoriesEnabled, setMemoriesEnabled] = useState(false)
const [immichUrl, setImmichUrl] = useState('')
const [immichApiKey, setImmichApiKey] = useState('')
const [immichConnected, setImmichConnected] = useState(false)
const [immichTesting, setImmichTesting] = useState(false)
useEffect(() => {
apiClient.get('/addons').then(r => {
const mem = r.data.addons?.find((a: any) => a.id === 'memories' && a.enabled)
setMemoriesEnabled(!!mem)
if (mem) {
apiClient.get('/integrations/immich/settings').then(r2 => {
setImmichUrl(r2.data.immich_url || '')
setImmichConnected(r2.data.connected)
}).catch(() => {})
}
}).catch(() => {})
}, [])
const handleSaveImmich = async () => {
setSaving(s => ({ ...s, immich: true }))
try {
await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined })
toast.success(t('memories.saved'))
// Test connection
const res = await apiClient.get('/integrations/immich/status')
setImmichConnected(res.data.connected)
} catch {
toast.error(t('memories.connectionError'))
} finally {
setSaving(s => ({ ...s, immich: false }))
}
}
const handleTestImmich = async () => {
setImmichTesting(true)
try {
const res = await apiClient.get('/integrations/immich/status')
if (res.data.connected) {
toast.success(`${t('memories.connectionSuccess')}${res.data.user?.name || ''}`)
setImmichConnected(true)
} else {
toast.error(`${t('memories.connectionError')}: ${res.data.error}`)
setImmichConnected(false)
}
} catch {
toast.error(t('memories.connectionError'))
} finally {
setImmichTesting(false)
}
}
// Map settings
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
@@ -71,6 +179,20 @@ export default function SettingsPage(): React.ReactElement {
const [currentPassword, setCurrentPassword] = useState<string>('')
const [newPassword, setNewPassword] = useState<string>('')
const [confirmPassword, setConfirmPassword] = useState<string>('')
const [oidcOnlyMode, setOidcOnlyMode] = useState<boolean>(false)
useEffect(() => {
authApi.getAppConfig?.().then((config) => {
if (config?.oidc_only_mode) setOidcOnlyMode(true)
}).catch(() => {})
}, [])
const [mfaQr, setMfaQr] = useState<string | null>(null)
const [mfaSecret, setMfaSecret] = useState<string | null>(null)
const [mfaSetupCode, setMfaSetupCode] = useState('')
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
const [mfaDisableCode, setMfaDisableCode] = useState('')
const [mfaLoading, setMfaLoading] = useState(false)
useEffect(() => {
setMapTileUrl(settings.map_tile_url || '')
@@ -152,12 +274,15 @@ export default function SettingsPage(): React.ReactElement {
<Navbar />
<div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6">
<div>
<div className="max-w-5xl mx-auto px-4 py-8">
<style>{`@media (max-width: 900px) { .settings-columns { column-count: 1 !important; } }`}</style>
<div style={{ marginBottom: 24 }}>
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('settings.title')}</h1>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>{t('settings.subtitle')}</p>
</div>
<div className="settings-columns" style={{ columnCount: 2, columnGap: 24 }}>
{/* Map settings */}
<Section title={t('settings.map')} icon={Map}>
<div>
@@ -258,11 +383,8 @@ export default function SettingsPage(): React.ReactElement {
{/* Sprache */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
<div className="flex gap-3">
{[
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
].map(opt => (
<div className="flex flex-wrap gap-3">
{SUPPORTED_LANGUAGES.map(opt => (
<button
key={opt.value}
onClick={async () => {
@@ -374,8 +496,82 @@ export default function SettingsPage(): React.ReactElement {
))}
</div>
</div>
{/* Blur Booking Codes */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.blurBookingCodes')}</label>
<div className="flex gap-3">
{[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
].map(opt => (
<button
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('blur_booking_codes', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: (!!settings.blur_booking_codes) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: (!!settings.blur_booking_codes) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
</div>
</Section>
{/* Notifications */}
<Section title={t('settings.notifications')} icon={Lock}>
<NotificationPreferences t={t} memoriesEnabled={memoriesEnabled} />
</Section>
{/* Immich — only when Memories addon is enabled */}
{memoriesEnabled && (
<Section title="Immich" icon={Camera}>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichUrl')}</label>
<input type="url" value={immichUrl} onChange={e => setImmichUrl(e.target.value)}
placeholder="https://immich.example.com"
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichApiKey')}</label>
<input type="password" value={immichApiKey} onChange={e => setImmichApiKey(e.target.value)}
placeholder={immichConnected ? '••••••••' : 'API Key'}
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
</div>
<div className="flex items-center gap-3">
<button onClick={handleSaveImmich} disabled={saving.immich}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400">
<Save className="w-4 h-4" /> {t('common.save')}
</button>
<button onClick={handleTestImmich} disabled={immichTesting}
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50">
{immichTesting
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
: <Camera className="w-4 h-4" />}
{t('memories.testConnection')}
</button>
{immichConnected && (
<span className="text-xs font-medium text-green-600 flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full" />
{t('memories.connected')}
</span>
)}
</div>
</div>
</Section>
)}
{/* Account */}
<Section title={t('settings.account')} icon={User}>
<div>
@@ -398,7 +594,8 @@ export default function SettingsPage(): React.ReactElement {
</div>
{/* Change Password */}
<div style={{ paddingTop: 8, marginTop: 8, borderTop: '1px solid var(--border-secondary)' }}>
{!oidcOnlyMode && (
<div style={{ paddingTop: 16, marginTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
<label className="block text-sm font-medium text-slate-700 mb-3">{t('settings.changePassword')}</label>
<div className="space-y-3">
<input
@@ -446,6 +643,146 @@ export default function SettingsPage(): React.ReactElement {
</button>
</div>
</div>
)}
{/* MFA */}
<div style={{ paddingTop: 16, marginTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
<div className="flex items-center gap-2 mb-3">
<KeyRound className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
<h3 className="font-semibold text-base m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.title')}</h3>
</div>
<div className="space-y-3">
<p className="text-sm m-0" style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.mfa.description')}</p>
{demoMode ? (
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
) : (
<>
<p className="text-sm font-medium m-0" style={{ color: 'var(--text-secondary)' }}>
{user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
</p>
{!user?.mfa_enabled && !mfaQr && (
<button
type="button"
disabled={mfaLoading}
onClick={async () => {
setMfaLoading(true)
try {
const data = await authApi.mfaSetup() as { qr_data_url: string; secret: string }
setMfaQr(data.qr_data_url)
setMfaSecret(data.secret)
setMfaSetupCode('')
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setMfaLoading(false)
}
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{mfaLoading ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> : <KeyRound size={14} />}
{t('settings.mfa.setup')}
</button>
)}
{!user?.mfa_enabled && mfaQr && (
<div className="space-y-3">
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.scanQr')}</p>
<img src={mfaQr} alt="" className="rounded-lg border mx-auto block" style={{ maxWidth: 200, borderColor: 'var(--border-primary)' }} />
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.secretLabel')}</label>
<code className="block text-xs p-2 rounded break-all" style={{ background: 'var(--bg-hover)', color: 'var(--text-primary)' }}>{mfaSecret}</code>
</div>
<input
type="text"
inputMode="numeric"
value={mfaSetupCode}
onChange={(e) => setMfaSetupCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder={t('settings.mfa.codePlaceholder')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={mfaLoading || mfaSetupCode.length < 6}
onClick={async () => {
setMfaLoading(true)
try {
await authApi.mfaEnable({ code: mfaSetupCode })
toast.success(t('settings.mfa.toastEnabled'))
setMfaQr(null)
setMfaSecret(null)
setMfaSetupCode('')
await loadUser()
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setMfaLoading(false)
}
}}
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
>
{t('settings.mfa.enable')}
</button>
<button
type="button"
onClick={() => { setMfaQr(null); setMfaSecret(null); setMfaSetupCode('') }}
className="px-4 py-2 rounded-lg text-sm border"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}
>
{t('settings.mfa.cancelSetup')}
</button>
</div>
</div>
)}
{user?.mfa_enabled && (
<div className="space-y-3">
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.disableTitle')}</p>
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.disableHint')}</p>
<input
type="password"
value={mfaDisablePwd}
onChange={(e) => setMfaDisablePwd(e.target.value)}
placeholder={t('settings.currentPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
<input
type="text"
inputMode="numeric"
value={mfaDisableCode}
onChange={(e) => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder={t('settings.mfa.codePlaceholder')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
<button
type="button"
disabled={mfaLoading || !mfaDisablePwd || mfaDisableCode.length < 6}
onClick={async () => {
setMfaLoading(true)
try {
await authApi.mfaDisable({ password: mfaDisablePwd, code: mfaDisableCode })
toast.success(t('settings.mfa.toastDisabled'))
setMfaDisablePwd('')
setMfaDisableCode('')
await loadUser()
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setMfaLoading(false)
}
}}
className="px-4 py-2 rounded-lg text-sm font-medium text-red-600 border border-red-200 hover:bg-red-50 disabled:opacity-50"
>
{t('settings.mfa.disable')}
</button>
</div>
)}
</>
)}
</div>
</div>
<div className="flex items-center gap-4">
<div style={{ position: 'relative', flexShrink: 0 }}>
@@ -643,6 +980,7 @@ export default function SettingsPage(): React.ReactElement {
</div>
</div>
)}
</div>
</div>
</div>
</div>
+392
View File
@@ -0,0 +1,392 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { MapContainer, TileLayer, Marker, Tooltip, useMap } from 'react-leaflet'
import L from 'leaflet'
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
import { useSettingsStore } from '../store/settingsStore'
import { shareApi } from '../api/client'
import { getCategoryIcon } from '../components/shared/categoryIcons'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
function createMarkerIcon(place: any) {
const cat = place.category
const color = cat?.color || '#6366f1'
const CatIcon = getCategoryIcon(cat?.icon)
const iconSvg = renderToStaticMarkup(createElement(CatIcon, { size: 14, strokeWidth: 2, color: 'white' }))
return L.divIcon({
className: '',
iconSize: [28, 28],
iconAnchor: [14, 14],
html: `<div style="width:28px;height:28px;border-radius:50%;background:${color};display:flex;align-items:center;justify-content:center;box-shadow:0 2px 6px rgba(0,0,0,0.3);border:2px solid white;">${iconSvg}</div>`,
})
}
function FitBoundsToPlaces({ places }: { places: any[] }) {
const map = useMap()
useEffect(() => {
if (places.length === 0) return
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 })
}, [places, map])
return null
}
export default function SharedTripPage() {
const { token } = useParams<{ token: string }>()
const { t, locale } = useTranslation()
const [data, setData] = useState<any>(null)
const [error, setError] = useState(false)
const [selectedDay, setSelectedDay] = useState<number | null>(null)
const [activeTab, setActiveTab] = useState('plan')
const { updateSetting } = useSettingsStore()
const [showLangPicker, setShowLangPicker] = useState(false)
useEffect(() => {
if (!token) return
shareApi.getSharedTrip(token).then(setData).catch(() => setError(true))
}, [token])
if (error) return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: '#f3f4f6' }}>
<div style={{ textAlign: 'center', padding: 40 }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>🔒</div>
<h1 style={{ fontSize: 20, fontWeight: 700, color: '#111827' }}>{t('shared.expired')}</h1>
<p style={{ color: '#6b7280', marginTop: 8 }}>{t('shared.expiredHint')}</p>
</div>
</div>
)
if (!data) return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: '#f3f4f6' }}>
<div style={{ width: 32, height: 32, border: '3px solid #e5e7eb', borderTopColor: '#111827', borderRadius: '50%', animation: 'spin 0.6s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
const { trip, days, assignments, dayNotes, places, reservations, accommodations, packing, budget, categories, permissions, collab } = data
const sortedDays = [...(days || [])].sort((a: any, b: any) => a.day_number - b.day_number)
// Map places
const mapPlaces = selectedDay
? (assignments[String(selectedDay)] || []).map((a: any) => a.place).filter((p: any) => p?.lat && p?.lng)
: (places || []).filter((p: any) => p?.lat && p?.lng)
const center = mapPlaces.length > 0 ? [mapPlaces[0].lat, mapPlaces[0].lng] : [48.85, 2.35]
return (
<div style={{ minHeight: '100vh', background: 'var(--bg-secondary, #f3f4f6)', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Header */}
<div style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', color: 'white', padding: '32px 20px 28px', textAlign: 'center', position: 'relative' }}>
{/* Cover image background */}
{trip.cover_image && (
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(${trip.cover_image.startsWith('http') ? trip.cover_image : trip.cover_image.startsWith('/') ? trip.cover_image : '/uploads/' + trip.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
)}
{/* Background decoration */}
<div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: 'rgba(255,255,255,0.03)' }} />
<div style={{ position: 'absolute', bottom: -40, left: -40, width: 150, height: 150, borderRadius: '50%', background: 'rgba(255,255,255,0.02)' }} />
{/* Logo */}
<div style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 44, height: 44, borderRadius: 12, background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(8px)', marginBottom: 12, border: '1px solid rgba(255,255,255,0.1)' }}>
<img src="/icons/icon-white.svg" alt="TREK" width="26" height="26" />
</div>
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: 3, textTransform: 'uppercase', opacity: 0.35, marginBottom: 12 }}>Travel Resource & Exploration Kit</div>
<h1 style={{ margin: '0 0 4px', fontSize: 26, fontWeight: 700, letterSpacing: -0.5 }}>{trip.title}</h1>
{trip.description && (
<div style={{ fontSize: 13, opacity: 0.5, maxWidth: 400, margin: '0 auto', lineHeight: 1.5 }}>{trip.description}</div>
)}
{(trip.start_date || trip.end_date) && (
<div style={{ marginTop: 10, display: 'inline-flex', alignItems: 'center', gap: 8, padding: '6px 14px', borderRadius: 20, background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(4px)', border: '1px solid rgba(255,255,255,0.08)' }}>
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8 }}>
{[trip.start_date, trip.end_date].filter(Boolean).map((d: string) => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })).join(' — ')}
</span>
{days?.length > 0 && <span style={{ fontSize: 11, opacity: 0.4 }}>·</span>}
{days?.length > 0 && <span style={{ fontSize: 11, opacity: 0.5 }}>{days.length} {t('shared.days')}</span>}
</div>
)}
<div style={{ marginTop: 12, fontSize: 9, fontWeight: 500, letterSpacing: 1.5, textTransform: 'uppercase', opacity: 0.25 }}>{t('shared.readOnly')}</div>
{/* Language picker - top right */}
<div style={{ position: 'absolute', top: 12, right: 12, zIndex: 10 }}>
<button onClick={() => setShowLangPicker(v => !v)} style={{
padding: '5px 12px', borderRadius: 20, border: '1px solid rgba(255,255,255,0.15)',
background: 'rgba(255,255,255,0.1)', backdropFilter: 'blur(8px)',
color: 'rgba(255,255,255,0.7)', fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
}}>
{SUPPORTED_LANGUAGES.find(l => l.value === (locale?.split('-')[0] || 'en'))?.label || 'Language'}
</button>
{showLangPicker && (
<div style={{ position: 'absolute', top: '100%', right: 0, marginTop: 6, background: 'white', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.2)', padding: 4, zIndex: 50, minWidth: 150 }}>
{SUPPORTED_LANGUAGES.map(lang => (
<button key={lang.value} onClick={() => { updateSetting('language', lang.value); setShowLangPicker(false) }}
style={{ display: 'block', width: '100%', padding: '6px 12px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontSize: 12, color: '#374151', borderRadius: 6, fontFamily: 'inherit' }}
onMouseEnter={e => e.currentTarget.style.background = '#f3f4f6'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
>{lang.label}</button>
))}
</div>
)}
</div>
</div>
<div style={{ maxWidth: 900, margin: '0 auto', padding: '20px 16px' }}>
{/* Tabs */}
<div style={{ display: 'flex', gap: 6, marginBottom: 20, overflowX: 'auto', padding: '2px 0' }}>
{[
{ id: 'plan', label: t('shared.tabPlan'), Icon: Map },
...(permissions?.share_bookings ? [{ id: 'bookings', label: t('shared.tabBookings'), Icon: Ticket }] : []),
...(permissions?.share_packing ? [{ id: 'packing', label: t('shared.tabPacking'), Icon: Luggage }] : []),
...(permissions?.share_budget ? [{ id: 'budget', label: t('shared.tabBudget'), Icon: Wallet }] : []),
...(permissions?.share_collab ? [{ id: 'collab', label: t('shared.tabChat'), Icon: MessageCircle }] : []),
].map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)} style={{
padding: '8px 18px', borderRadius: 12, border: '1.5px solid', cursor: 'pointer',
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', transition: 'all 0.15s', whiteSpace: 'nowrap',
display: 'flex', alignItems: 'center', gap: 6,
background: activeTab === tab.id ? '#111827' : 'var(--bg-card, white)',
borderColor: activeTab === tab.id ? '#111827' : 'var(--border-faint, #e5e7eb)',
color: activeTab === tab.id ? 'white' : '#6b7280',
boxShadow: activeTab === tab.id ? '0 2px 8px rgba(0,0,0,0.15)' : '0 1px 3px rgba(0,0,0,0.04)',
}}><tab.Icon size={13} /><span className="hidden sm:inline">{tab.label}</span></button>
))}
</div>
{/* Map */}
{activeTab === 'plan' && (<>
<div style={{ borderRadius: 16, overflow: 'hidden', height: 300, marginBottom: 20, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<MapContainer center={center as [number, number]} zoom={11} zoomControl={false} style={{ width: '100%', height: '100%' }}>
<TileLayer url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png" />
<FitBoundsToPlaces places={mapPlaces} />
{mapPlaces.map((p: any) => (
<Marker key={p.id} position={[p.lat, p.lng]} icon={createMarkerIcon(p)}>
<Tooltip>{p.name}</Tooltip>
</Marker>
))}
</MapContainer>
</div>
{/* Day Plan */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{sortedDays.map((day: any, di: number) => {
const da = assignments[String(day.id)] || []
const notes = (dayNotes[String(day.id)] || [])
const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date)
const dayAccs = (accommodations || []).filter((a: any) => day.id >= a.start_day_id && day.id <= a.end_day_id)
const merged = [
...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
...notes.map((n: any) => ({ type: 'note', k: n.sort_order ?? 0, data: n })),
...dayTransport.map((r: any) => ({ type: 'transport', k: r.day_plan_position ?? 999, data: r })),
].sort((a, b) => a.k - b.k)
return (
<div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}>
<div onClick={() => setSelectedDay(selectedDay === day.id ? null : day.id)}
style={{ padding: '12px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 28, height: 28, borderRadius: '50%', background: selectedDay === day.id ? '#111827' : '#f3f4f6', color: selectedDay === day.id ? 'white' : '#6b7280', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, flexShrink: 0 }}>{di + 1}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: '#111827' }}>{day.title || `Day ${day.day_number}`}</div>
{day.date && <div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>{new Date(day.date + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>}
</div>
{dayAccs.map((acc: any) => (
<span key={acc.id} style={{ fontSize: 9, padding: '2px 6px', borderRadius: 4, background: '#f3f4f6', color: '#6b7280', display: 'flex', alignItems: 'center', gap: 3 }}>
<Hotel size={8} /> {acc.place_name}
</span>
))}
<span style={{ fontSize: 11, color: '#9ca3af' }}>{da.length} {t('shared.places')}</span>
</div>
{selectedDay === day.id && merged.length > 0 && (
<div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{merged.map((item: any, idx: number) => {
if (item.type === 'transport') {
const r = item.data
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
let sub = ''
if (r.type === 'flight') sub = [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') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
return (
<div key={`t-${r.id}`} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 6, background: 'rgba(59,130,246,0.06)', border: '1px solid rgba(59,130,246,0.15)' }}>
<div style={{ width: 24, height: 24, borderRadius: '50%', background: 'rgba(59,130,246,0.12)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<TIcon size={12} color="#3b82f6" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 500, color: '#111827' }}>{r.title}{time ? ` · ${time}` : ''}</div>
{sub && <div style={{ fontSize: 10, color: '#6b7280' }}>{sub}</div>}
</div>
</div>
)
}
if (item.type === 'note') {
return (
<div key={`n-${item.data.id}`} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 8px', borderRadius: 6, background: '#f9fafb', border: '1px solid #f3f4f6' }}>
<FileText size={12} color="#9ca3af" />
<div>
<div style={{ fontSize: 12, color: '#374151' }}>{item.data.text}</div>
{item.data.time && <div style={{ fontSize: 10, color: '#9ca3af' }}>{item.data.time}</div>}
</div>
</div>
)
}
const place = item.data.place
if (!place) return null
const cat = categories?.find((c: any) => c.id === place.category_id)
return (
<div key={`p-${item.data.id}`} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 8px', borderRadius: 6 }}>
<div style={{ width: 28, height: 28, borderRadius: '50%', background: cat?.color || '#6366f1', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{place.image_url ? <img src={place.image_url} style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} /> : <MapPin size={13} color="white" />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12.5, fontWeight: 500, color: '#111827' }}>{place.name}</div>
{(place.address || place.description) && <div style={{ fontSize: 10, color: '#9ca3af', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{place.address || place.description}</div>}
</div>
{place.place_time && <span style={{ fontSize: 10, color: '#6b7280', display: 'flex', alignItems: 'center', gap: 3, flexShrink: 0 }}><Clock size={9} />{place.place_time}{place.end_time ? ` ${place.end_time}` : ''}</span>}
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
</>)}
{/* Bookings */}
{activeTab === 'bookings' && (reservations || []).length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{(reservations || []).map((r: any) => {
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
const date = r.reservation_time ? new Date(r.reservation_time.includes('T') ? r.reservation_time : r.reservation_time + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' }) : ''
return (
<div key={r.id} style={{ background: 'var(--bg-card, white)', borderRadius: 10, padding: '12px 16px', border: '1px solid var(--border-faint, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<TIcon size={15} color="#6b7280" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#111827' }}>{r.title}</div>
<div style={{ fontSize: 11, color: '#9ca3af', display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 2 }}>
{date && <span>{date}</span>}
{time && <span>{time}</span>}
{r.location && <span>{r.location}</span>}
{meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
{meta.train_number && <span>{meta.train_number}</span>}
</div>
</div>
<span style={{ fontSize: 10, padding: '2px 8px', borderRadius: 20, fontWeight: 600, background: r.status === 'confirmed' ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)', color: r.status === 'confirmed' ? '#16a34a' : '#d97706' }}>
{r.status === 'confirmed' ? t('shared.confirmed') : t('shared.pending')}
</span>
</div>
)
})}
</div>
)}
{/* Packing */}
{activeTab === 'packing' && (packing || []).length > 0 && (
<div style={{ background: 'var(--bg-card, white)', borderRadius: 14, border: '1px solid var(--border-faint, #e5e7eb)', overflow: 'hidden' }}>
{Object.entries((packing || []).reduce((g: any, i: any) => { const c = i.category || t('shared.other'); (g[c] = g[c] || []).push(i); return g }, {})).map(([cat, items]: [string, any]) => (
<div key={cat}>
<div style={{ padding: '8px 16px', background: '#f9fafb', fontSize: 11, fontWeight: 700, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '1px solid #f3f4f6' }}>{cat}</div>
{items.map((item: any) => (
<div key={item.id} style={{ padding: '6px 16px', display: 'flex', alignItems: 'center', gap: 8, borderBottom: '1px solid #f9fafb' }}>
<span style={{ fontSize: 13, color: item.checked ? '#9ca3af' : '#111827', textDecoration: item.checked ? 'line-through' : 'none' }}>{item.name}</span>
</div>
))}
</div>
))}
</div>
)}
{/* Budget */}
{activeTab === 'budget' && (budget || []).length > 0 && (() => {
const grouped = (budget || []).reduce((g: any, i: any) => { const c = i.category || t('shared.other'); (g[c] = g[c] || []).push(i); return g }, {})
const total = (budget || []).reduce((s: number, i: any) => s + (parseFloat(i.total_price) || 0), 0)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Total card */}
<div style={{ background: 'linear-gradient(135deg, #000 0%, #1a1a2e 100%)', borderRadius: 14, padding: '20px 24px', color: 'white' }}>
<div style={{ fontSize: 10, fontWeight: 500, letterSpacing: 1, textTransform: 'uppercase', opacity: 0.5 }}>{t('shared.totalBudget')}</div>
<div style={{ fontSize: 28, fontWeight: 700, marginTop: 4 }}>{total.toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || 'EUR'}</div>
</div>
{/* By category */}
{Object.entries(grouped).map(([cat, items]: [string, any]) => (
<div key={cat} style={{ background: 'var(--bg-card, white)', borderRadius: 12, border: '1px solid var(--border-faint, #e5e7eb)', overflow: 'hidden' }}>
<div style={{ padding: '10px 16px', background: '#f9fafb', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #f3f4f6' }}>
<span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>{cat}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#6b7280' }}>{items.reduce((s: number, i: any) => s + (parseFloat(i.total_price) || 0), 0).toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || ''}</span>
</div>
{items.map((item: any) => (
<div key={item.id} style={{ padding: '8px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #fafafa' }}>
<span style={{ fontSize: 13, color: '#111827' }}>{item.name}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: '#111827' }}>{item.total_price ? Number(item.total_price).toLocaleString(locale, { minimumFractionDigits: 2 }) : '—'}</span>
</div>
))}
</div>
))}
</div>
)
})()}
{/* Collab Chat */}
{activeTab === 'collab' && (collab || []).length > 0 && (
<div style={{ background: 'var(--bg-card, white)', borderRadius: 14, border: '1px solid var(--border-faint, #e5e7eb)', overflow: 'hidden' }}>
<div style={{ padding: '12px 16px', background: '#f9fafb', borderBottom: '1px solid #f3f4f6', display: 'flex', alignItems: 'center', gap: 8 }}>
<MessageCircle size={14} color="#6b7280" />
<span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>{t('shared.tabChat')} · {(collab || []).length} {t('shared.messages')}</span>
</div>
<div style={{ maxHeight: 500, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{(collab || []).map((msg: any, i: number) => {
const prevMsg = i > 0 ? collab[i - 1] : null
const showDate = !prevMsg || new Date(msg.created_at).toDateString() !== new Date(prevMsg.created_at).toDateString()
return (
<div key={msg.id}>
{showDate && (
<div style={{ textAlign: 'center', margin: '8px 0', fontSize: 10, fontWeight: 600, color: '#9ca3af' }}>
{new Date(msg.created_at).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}
</div>
)}
<div style={{ display: 'flex', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#e5e7eb', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700, color: '#6b7280', flexShrink: 0, overflow: 'hidden' }}>
{msg.avatar ? <img src={`/uploads/avatars/${msg.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (msg.username || '?')[0].toUpperCase()}
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#111827' }}>{msg.username}</span>
<span style={{ fontSize: 10, color: '#9ca3af' }}>{new Date(msg.created_at).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })}</span>
</div>
<div style={{ fontSize: 13, color: '#374151', marginTop: 3, lineHeight: 1.5, whiteSpace: 'pre-wrap' }}>{msg.text}</div>
</div>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Footer */}
<div style={{ textAlign: 'center', padding: '40px 0 20px' }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 16px', borderRadius: 20, background: 'var(--bg-card, white)', border: '1px solid var(--border-faint, #e5e7eb)', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<img src="/icons/icon.svg" alt="TREK" width="18" height="18" style={{ borderRadius: 4 }} />
<span style={{ fontSize: 11, color: '#9ca3af' }}>{t('shared.sharedVia')} <strong style={{ color: '#6b7280' }}>TREK</strong></span>
</div>
<div style={{ marginTop: 8, fontSize: 10, color: '#d1d5db' }}>Made with <span style={{ color: '#ef4444' }}>&hearts;</span> by Maurice · <a href="https://github.com/mauriceboe/TREK" style={{ color: '#9ca3af', textDecoration: 'none' }}>GitHub</a></div>
</div>
</div>
</div>
)
}
+71 -15
View File
@@ -12,6 +12,7 @@ import PlaceFormModal from '../components/Planner/PlaceFormModal'
import TripFormModal from '../components/Trips/TripFormModal'
import TripMembersModal from '../components/Trips/TripMembersModal'
import { ReservationModal } from '../components/Planner/ReservationModal'
import MemoriesPanel from '../components/Memories/MemoriesPanel'
import ReservationsPanel from '../components/Planner/ReservationsPanel'
import PackingListPanel from '../components/Packing/PackingListPanel'
import FileManager from '../components/Files/FileManager'
@@ -33,7 +34,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
const { id: tripId } = useParams<{ id: string }>()
const navigate = useNavigate()
const toast = useToast()
const { t } = useTranslation()
const { t, language } = useTranslation()
const { settings } = useSettingsStore()
const tripStore = useTripStore()
const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore
@@ -44,14 +45,17 @@ export default function TripPlannerPage(): React.ReactElement | null {
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
const loadAccommodations = useCallback(() => {
if (tripId) accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
if (tripId) {
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
tripStore.loadReservations(tripId)
}
}, [tripId])
useEffect(() => {
addonsApi.enabled().then(data => {
const map = {}
data.addons.forEach(a => { map[a.id] = true })
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab })
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, memories: !!map.memories })
}).catch(() => {})
authApi.getAppConfig().then(config => {
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
@@ -64,7 +68,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: 'Collab' }] : []),
...(enabledAddons.memories ? [{ id: 'memories', label: t('memories.title') }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name') }] : []),
]
const [activeTab, setActiveTab] = useState<string>(() => {
@@ -83,6 +88,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
const [showDayDetail, setShowDayDetail] = useState<Day | null>(null)
const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false)
const [editingPlace, setEditingPlace] = useState<Place | null>(null)
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string } | null>(null)
const [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null)
const [showTripForm, setShowTripForm] = useState<boolean>(false)
const [showMembersModal, setShowMembersModal] = useState<boolean>(false)
@@ -112,9 +118,15 @@ export default function TripPlannerPage(): React.ReactElement | null {
useTripWebSocket(tripId)
const mapPlaces = useCallback(() => {
return places.filter(p => p.lat && p.lng)
}, [places])
const [mapCategoryFilter, setMapCategoryFilter] = useState<string>('')
const mapPlaces = useMemo(() => {
return places.filter(p => {
if (!p.lat || !p.lng) return false
if (mapCategoryFilter && String(p.category_id) !== String(mapCategoryFilter)) return false
return true
})
}, [places, mapCategoryFilter])
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation(tripStore, selectedDayId)
@@ -145,6 +157,22 @@ export default function TripPlannerPage(): React.ReactElement | null {
setSelectedPlaceId(null)
}, [])
const handleMapContextMenu = useCallback(async (e) => {
e.originalEvent?.preventDefault()
const { lat, lng } = e.latlng
setPrefillCoords({ lat, lng })
setEditingPlace(null)
setEditingAssignmentId(null)
setShowPlaceForm(true)
try {
const { mapsApi } = await import('../api/client')
const data = await mapsApi.reverse(lat, lng, language)
if (data.name || data.address) {
setPrefillCoords(prev => prev ? { ...prev, name: data.name || '', address: data.address || '' } : prev)
}
} catch { /* best effort */ }
}, [language])
const handleSavePlace = useCallback(async (data) => {
const pendingFiles = data._pendingFiles
delete data._pendingFiles
@@ -236,18 +264,30 @@ export default function TripPlannerPage(): React.ReactElement | null {
const r = await tripStore.updateReservation(tripId, editingReservation.id, data)
toast.success(t('trip.toast.reservationUpdated'))
setShowReservationModal(false)
if (data.type === 'hotel') {
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
}
return r
} else {
const r = await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
toast.success(t('trip.toast.reservationAdded'))
setShowReservationModal(false)
// Refresh accommodations if hotel was created
if (data.type === 'hotel') {
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
}
return r
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}
const handleDeleteReservation = async (id) => {
try { await tripStore.deleteReservation(tripId, id); toast.success(t('trip.toast.deleted')) }
try {
await tripStore.deleteReservation(tripId, id)
toast.success(t('trip.toast.deleted'))
// Refresh accommodations in case a hotel booking was deleted
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
}
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}
@@ -338,13 +378,14 @@ export default function TripPlannerPage(): React.ReactElement | null {
{activeTab === 'plan' && (
<div style={{ position: 'absolute', inset: 0 }}>
<MapView
places={mapPlaces()}
places={mapPlaces}
dayPlaces={dayPlaces}
route={route}
routeSegments={routeSegments}
selectedPlaceId={selectedPlaceId}
onMarkerClick={handleMarkerClick}
onMapClick={handleMapClick}
onMapContextMenu={handleMapContextMenu}
center={defaultCenter}
zoom={defaultZoom}
tileUrl={mapTileUrl}
@@ -400,11 +441,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }}
reservations={reservations}
onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }}
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null) }}
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }}
onRemoveAssignment={handleRemoveAssignment}
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
accommodations={tripAccommodations}
onNavigateToFiles={() => handleTabChange('dateien')}
/>
{!leftCollapsed && (
<div
@@ -453,6 +495,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
)}
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', paddingLeft: 4 }}>
<PlacesSidebar
tripId={tripId}
places={places}
categories={categories}
assignments={assignments}
@@ -463,6 +506,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
onAssignToDay={handleAssignToDay}
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
onCategoryFilterChange={setMapCategoryFilter}
/>
</div>
</div>
@@ -500,6 +544,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
lng={geoPlace?.lng}
onClose={() => setShowDayDetail(null)}
onAccommodationChange={loadAccommodations}
leftWidth={leftCollapsed ? 0 : leftWidth}
rightWidth={rightCollapsed ? 0 : rightWidth}
/>
)
})()}
@@ -546,6 +592,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
} catch {}
}}
onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }}
leftWidth={leftCollapsed ? 0 : leftWidth}
rightWidth={rightCollapsed ? 0 : rightWidth}
/>
)}
@@ -560,8 +608,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} />
: <PlacesSidebar places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile />
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} />
}
</div>
</div>
@@ -605,8 +653,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
files={files || []}
onUpload={(fd) => tripStore.addFile(tripId, fd)}
onDelete={(id) => tripStore.deleteFile(tripId, id)}
onUpdate={null}
onUpdate={(id, data) => tripStore.loadFiles(tripId)}
places={places}
days={days}
assignments={assignments}
reservations={reservations}
tripId={tripId}
allowedFileTypes={allowedFileTypes}
@@ -614,6 +664,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div>
)}
{activeTab === 'memories' && (
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
<MemoriesPanel tripId={Number(tripId)} startDate={trip?.start_date || null} endDate={trip?.end_date || null} />
</div>
)}
{activeTab === 'collab' && (
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
<CollabPanel tripId={tripId} tripMembers={tripMembers} />
@@ -621,10 +677,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
)}
</div>
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null) }} onSave={handleSavePlace} place={editingPlace} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} />
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} accommodations={tripAccommodations} />
<ConfirmDialog
isOpen={!!deletePlaceId}
onClose={() => setDeletePlaceId(null)}
+7 -2
View File
@@ -104,7 +104,12 @@ export default function VacayPage(): React.ReactElement {
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>{t('vacay.legend')}</span>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1.5">
{plan?.holidays_enabled && <LegendItem color="#fecaca" label={t('vacay.publicHoliday')} />}
{plan?.holidays_enabled && (plan?.holiday_calendars ?? []).length === 0 && (
<LegendItem color="#fecaca" label={t('vacay.publicHoliday')} />
)}
{plan?.holidays_enabled && (plan?.holiday_calendars ?? []).map(cal => (
<LegendItem key={cal.id} color={cal.color} label={cal.label || cal.region} />
))}
{plan?.company_holidays_enabled && <LegendItem color="#fde68a" label={t('vacay.companyHoliday')} />}
{plan?.block_weekends && <LegendItem color="#e5e7eb" label={t('vacay.weekend')} />}
</div>
@@ -128,7 +133,7 @@ export default function VacayPage(): React.ReactElement {
<CalendarDays size={18} style={{ color: 'var(--text-primary)' }} />
</div>
<div>
<h1 className="text-lg sm:text-xl font-bold" style={{ color: 'var(--text-primary)' }}>Vacay</h1>
<h1 className="text-lg sm:text-xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.catalog.vacay.name')}</h1>
<p className="text-xs hidden sm:block" style={{ color: 'var(--text-muted)' }}>{t('vacay.subtitle')}</p>
</div>
</div>
+37 -5
View File
@@ -9,6 +9,8 @@ interface AuthResponse {
token: string
}
export type LoginResult = AuthResponse | { mfa_required: true; mfa_token: string }
interface AvatarResponse {
avatar_url: string
}
@@ -21,8 +23,10 @@ interface AuthState {
error: string | null
demoMode: boolean
hasMapsKey: boolean
serverTimezone: string
login: (email: string, password: string) => Promise<AuthResponse>
login: (email: string, password: string) => Promise<LoginResult>
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
register: (username: string, email: string, password: string) => Promise<AuthResponse>
logout: () => void
loadUser: () => Promise<void>
@@ -33,6 +37,7 @@ interface AuthState {
deleteAvatar: () => Promise<void>
setDemoMode: (val: boolean) => void
setHasMapsKey: (val: boolean) => void
setServerTimezone: (tz: string) => void
demoLogin: () => Promise<AuthResponse>
}
@@ -44,11 +49,16 @@ export const useAuthStore = create<AuthState>((set, get) => ({
error: null,
demoMode: localStorage.getItem('demo_mode') === 'true',
hasMapsKey: false,
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
login: async (email: string, password: string) => {
set({ isLoading: true, error: null })
try {
const data = await authApi.login({ email, password })
const data = await authApi.login({ email, password }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
if (data.mfa_required && data.mfa_token) {
set({ isLoading: false, error: null })
return { mfa_required: true as const, mfa_token: data.mfa_token }
}
localStorage.setItem('auth_token', data.token)
set({
user: data.user,
@@ -58,7 +68,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
error: null,
})
connect(data.token)
return data
return data as AuthResponse
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Login failed')
set({ isLoading: false, error })
@@ -66,10 +76,31 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
},
register: async (username: string, email: string, password: string) => {
completeMfaLogin: async (mfaToken: string, code: string) => {
set({ isLoading: true, error: null })
try {
const data = await authApi.register({ username, email, password })
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') })
localStorage.setItem('auth_token', data.token)
set({
user: data.user,
token: data.token,
isAuthenticated: true,
isLoading: false,
error: null,
})
connect(data.token)
return data as AuthResponse
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Verification failed')
set({ isLoading: false, error })
throw new Error(error)
}
},
register: async (username: string, email: string, password: string, invite_token?: string) => {
set({ isLoading: true, error: null })
try {
const data = await authApi.register({ username, email, password, invite_token })
localStorage.setItem('auth_token', data.token)
set({
user: data.user,
@@ -173,6 +204,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
demoLogin: async () => {
set({ isLoading: true, error: null })
@@ -232,6 +232,11 @@ export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void {
files: state.files.filter(f => f.id !== payload.fileId),
}
// Memories / Photos
case 'memories:updated':
window.dispatchEvent(new CustomEvent('memories:updated', { detail: payload }))
return {}
default:
return {}
}
+47 -20
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand'
import apiClient from '../api/client'
import type { AxiosResponse } from 'axios'
import type { VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo } from '../types'
import type { VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo, VacayHolidayCalendar } from '../types'
const ax = apiClient
@@ -65,6 +65,9 @@ interface VacayApi {
updateStats: (year: number, days: number, targetUserId?: number) => Promise<unknown>
getCountries: () => Promise<{ countries: string[] }>
getHolidays: (year: number, country: string) => Promise<VacayHolidayRaw[]>
addHolidayCalendar: (data: { region: string; color?: string; label?: string | null }) => Promise<{ calendar: VacayHolidayCalendar }>
updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null }) => Promise<{ calendar: VacayHolidayCalendar }>
deleteHolidayCalendar: (id: number) => Promise<unknown>
}
const api: VacayApi = {
@@ -87,6 +90,9 @@ const api: VacayApi = {
updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId }).then((r: AxiosResponse) => r.data),
getCountries: () => ax.get('/addons/vacay/holidays/countries').then((r: AxiosResponse) => r.data),
getHolidays: (year, country) => ax.get(`/addons/vacay/holidays/${year}/${country}`).then((r: AxiosResponse) => r.data),
addHolidayCalendar: (data) => ax.post('/addons/vacay/plan/holiday-calendars', data).then((r: AxiosResponse) => r.data),
updateHolidayCalendar: (id, data) => ax.put(`/addons/vacay/plan/holiday-calendars/${id}`, data).then((r: AxiosResponse) => r.data),
deleteHolidayCalendar: (id) => ax.delete(`/addons/vacay/plan/holiday-calendars/${id}`).then((r: AxiosResponse) => r.data),
}
interface VacayState {
@@ -124,6 +130,9 @@ interface VacayState {
loadStats: (year?: number) => Promise<void>
updateVacationDays: (year: number, days: number, targetUserId?: number) => Promise<void>
loadHolidays: (year?: number) => Promise<void>
addHolidayCalendar: (data: { region: string; color?: string; label?: string | null }) => Promise<void>
updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null }) => Promise<void>
deleteHolidayCalendar: (id: number) => Promise<void>
loadAll: () => Promise<void>
}
@@ -247,29 +256,47 @@ export const useVacayStore = create<VacayState>((set, get) => ({
loadHolidays: async (year?: number) => {
const y = year || get().selectedYear
const plan = get().plan
if (!plan?.holidays_enabled || !plan?.holidays_region) {
const calendars = plan?.holiday_calendars ?? []
if (!plan?.holidays_enabled || calendars.length === 0) {
set({ holidays: {} })
return
}
const country = plan.holidays_region.split('-')[0]
const region = plan.holidays_region.includes('-') ? plan.holidays_region : null
try {
const data = await api.getHolidays(y, country)
const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0)
if (hasRegions && !region) {
set({ holidays: {} })
return
}
const map: HolidaysMap = {}
data.forEach((h: VacayHolidayRaw) => {
if (h.global || !h.counties || (region && h.counties.includes(region))) {
map[h.date] = { name: h.name, localName: h.localName }
}
})
set({ holidays: map })
} catch {
set({ holidays: {} })
const map: HolidaysMap = {}
for (const cal of calendars) {
const country = cal.region.split('-')[0]
const region = cal.region.includes('-') ? cal.region : null
try {
const data = await api.getHolidays(y, country)
const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0)
if (hasRegions && !region) continue
data.forEach((h: VacayHolidayRaw) => {
if (h.global || !h.counties || (region && h.counties.includes(region))) {
if (!map[h.date]) {
map[h.date] = { name: h.name, localName: h.localName, color: cal.color, label: cal.label }
}
}
})
} catch { /* API error, skip */ }
}
set({ holidays: map })
},
addHolidayCalendar: async (data) => {
await api.addHolidayCalendar(data)
await get().loadPlan()
await get().loadHolidays()
},
updateHolidayCalendar: async (id, data) => {
await api.updateHolidayCalendar(id, data)
await get().loadPlan()
await get().loadHolidays()
},
deleteHolidayCalendar: async (id) => {
await api.deleteHolidayCalendar(id)
await get().loadPlan()
await get().loadHolidays()
},
loadAll: async () => {
+50 -5
View File
@@ -1,4 +1,4 @@
// Shared types for the NOMAD travel planner
// Shared types for the TREK travel planner
export interface User {
id: number
@@ -8,6 +8,8 @@ export interface User {
avatar_url: string | null
maps_api_key: string | null
created_at: string
/** Present after load; true when TOTP MFA is enabled for password login */
mfa_enabled?: boolean
}
export interface Trip {
@@ -46,6 +48,7 @@ export interface Place {
price: string | null
image_url: string | null
google_place_id: string | null
osm_id: string | null
place_time: string | null
end_time: string | null
created_at: string
@@ -114,24 +117,46 @@ export interface Reservation {
id: number
trip_id: number
name: string
type: string | null
title?: string
type: string
status: 'pending' | 'confirmed'
date: string | null
time: string | null
reservation_time?: string | null
reservation_end_time?: string | null
location?: string | null
confirmation_number: string | null
notes: string | null
url: string | null
day_id?: number | null
place_id?: number | null
assignment_id?: number | null
accommodation_id?: number | null
day_plan_position?: number | null
metadata?: Record<string, string> | string | null
created_at: string
}
export interface TripFile {
id: number
trip_id: number
place_id?: number | null
reservation_id?: number | null
note_id?: number | null
uploaded_by?: number | null
uploaded_by_name?: string | null
uploaded_by_avatar?: string | null
filename: string
original_name: string
file_size?: number | null
mime_type: string
size: number
description?: string | null
starred?: number
deleted_at?: string | null
created_at: string
reservation_title?: string
linked_reservation_ids?: number[]
url?: string
}
export interface Settings {
@@ -146,6 +171,7 @@ export interface Settings {
time_format: string
show_place_description: boolean
route_calculation?: boolean
blur_booking_codes?: boolean
}
export interface AssignmentsMap {
@@ -254,6 +280,7 @@ export interface AppConfig {
oidc_display_name?: string
has_maps_key?: boolean
allowed_file_types?: string
timezone?: string
}
// Translation function type
@@ -266,10 +293,23 @@ export interface WebSocketEvent {
}
// Vacay types
export interface VacayHolidayCalendar {
id: number
plan_id: number
region: string
label: string | null
color: string
sort_order: number
}
export interface VacayPlan {
id: number
holidays_enabled: boolean
holidays_region: string | null
holiday_calendars: VacayHolidayCalendar[]
block_weekends: boolean
carry_over_enabled: boolean
company_holidays_enabled: boolean
name?: string
year?: number
owner_id?: number
@@ -286,6 +326,9 @@ export interface VacayUser {
export interface VacayEntry {
date: string
user_id: number
plan_id?: number
person_color?: string
person_name?: string
}
export interface VacayStat {
@@ -297,6 +340,8 @@ export interface VacayStat {
export interface HolidayInfo {
name: string
localName: string
color: string
label: string | null
}
export interface HolidaysMap {
@@ -326,7 +371,7 @@ export function getApiErrorMessage(err: unknown, fallback: string): string {
// MergedItem used in day notes hook
export interface MergedItem {
type: 'assignment' | 'note'
type: 'assignment' | 'note' | 'place' | 'transport'
sortKey: number
data: Assignment | DayNote
data: Assignment | DayNote | Reservation
}
+11 -3
View File
@@ -1,10 +1,18 @@
import type { AssignmentsMap } from '../types'
export function formatDate(dateStr: string | null | undefined, locale: string): string | null {
const ZERO_DECIMAL_CURRENCIES = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'HUF'])
export function currencyDecimals(currency: string): number {
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2
}
export function formatDate(dateStr: string | null | undefined, locale: string, timeZone?: string): string | null {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, {
const opts: Intl.DateTimeFormatOptions = {
weekday: 'short', day: 'numeric', month: 'short',
})
}
if (timeZone) opts.timeZone = timeZone
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, opts)
}
export function formatTime(timeStr: string | null | undefined, locale: string, timeFormat: string): string {
+4 -3
View File
@@ -8,6 +8,7 @@ export default defineConfig({
VitePWA({
registerType: 'autoUpdate',
workbox: {
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api/, /^\/uploads/],
@@ -66,9 +67,9 @@ export default defineConfig({
],
},
manifest: {
name: 'NOMAD \u2014 Travel Planner',
short_name: 'NOMAD',
description: 'Navigation Organizer for Maps, Activities & Destinations',
name: 'TREK \u2014 Travel Planner',
short_name: 'TREK',
description: 'Travel Resource & Exploration Kit',
theme_color: '#111827',
background_color: '#0f172a',
display: 'standalone',
+19 -2
View File
@@ -1,7 +1,23 @@
services:
init-permissions:
image: alpine:3.20
container_name: trek-init-permissions
user: "0:0"
command: >
sh -c "mkdir -p /app/data /app/uploads &&
chown -R 1000:1000 /app/data /app/uploads &&
chmod -R u+rwX /app/data /app/uploads"
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
restart: "no"
app:
image: mauriceboe/nomad:2.5.5
container_name: nomad
image: mauriceboe/trek:latest
container_name: trek
depends_on:
init-permissions:
condition: service_completed_successfully
ports:
- "3000:3000"
environment:
@@ -9,6 +25,7 @@ services:
- JWT_SECRET=${JWT_SECRET:-}
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
- PORT=3000
- TZ=${TZ:-UTC}
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
+1
View File
@@ -1,3 +1,4 @@
PORT=3001
JWT_SECRET=your-super-secret-jwt-key-change-in-production
NODE_ENV=development
DEBUG=false
+472 -95
View File
@@ -1,12 +1,12 @@
{
"name": "nomad-server",
"version": "2.6.0",
"name": "trek-server",
"version": "2.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nomad-server",
"version": "2.6.0",
"name": "trek-server",
"version": "2.7.0",
"dependencies": {
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
@@ -16,9 +16,12 @@
"express": "^4.18.3",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^2.1.1",
"node-cron": "^4.2.1",
"node-fetch": "^2.7.0",
"nodemailer": "^8.0.4",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"unzipper": "^0.12.3",
@@ -30,11 +33,13 @@
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/express": "^4.17.25",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0",
"@types/node": "^25.5.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.11",
"@types/qrcode": "^1.5.5",
"@types/unzipper": "^0.10.11",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
@@ -457,6 +462,56 @@
"node": ">=18"
}
},
"node_modules/@otplib/core": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
"license": "MIT"
},
"node_modules/@otplib/plugin-crypto": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1"
}
},
"node_modules/@otplib/plugin-thirty-two": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"thirty-two": "^1.0.2"
}
},
"node_modules/@otplib/preset-default": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"node_modules/@otplib/preset-v11": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"node_modules/@types/archiver": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz",
@@ -516,21 +571,22 @@
}
},
"node_modules/@types/express": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
"@types/serve-static": "^2"
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/serve-static": "^1"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"version": "4.19.8",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -558,6 +614,13 @@
"@types/node": "*"
}
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -592,6 +655,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/nodemailer": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
@@ -627,13 +710,25 @@
}
},
"node_modules/@types/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*",
"@types/send": "<1"
}
},
"node_modules/@types/serve-static/node_modules/@types/send": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
}
},
@@ -677,6 +772,30 @@
"node": ">= 0.6"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -959,9 +1078,9 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1078,6 +1197,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -1109,6 +1237,35 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/compress-commons": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.3.tgz",
@@ -1125,50 +1282,20 @@
}
},
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 0.8"
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^2.2.2",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/concat-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/concat-stream/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -1262,6 +1389,15 @@
"ms": "2.0.0"
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -1314,6 +1450,12 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -1394,6 +1536,12 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -1605,6 +1753,19 @@
"node": ">= 0.8"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1672,6 +1833,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -1767,9 +1937,9 @@
"license": "MIT"
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -1962,6 +2132,15 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -2094,6 +2273,18 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
@@ -2248,18 +2439,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
@@ -2273,22 +2452,22 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "1.4.5-lts.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.0.0",
"concat-stream": "^1.5.2",
"mkdirp": "^0.5.4",
"object-assign": "^4.1.1",
"type-is": "^1.6.4",
"xtend": "^4.0.0"
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 6.0.0"
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/napi-build-utils": {
@@ -2353,6 +2532,15 @@
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
@@ -2458,6 +2646,53 @@
"wrappy": "1"
}
},
"node_modules/otplib": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/preset-default": "^12.0.1",
"@otplib/preset-v11": "^12.0.1"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -2467,16 +2702,25 @@
"node": ">= 0.8"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2486,6 +2730,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -2549,6 +2802,23 @@
"once": "^1.3.1"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
@@ -2633,9 +2903,9 @@
"license": "MIT"
},
"node_modules/readdir-glob/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -2666,6 +2936,21 @@
"node": ">=8.10.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -2758,6 +3043,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -2931,6 +3222,32 @@
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
@@ -3011,6 +3328,14 @@
"b4a": "^1.6.4"
}
},
"node_modules/thirty-two": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
"engines": {
"node": ">=0.2.6"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3210,6 +3535,26 @@
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -3237,13 +3582,45 @@
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=0.4"
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/zip-stream": {
+9 -4
View File
@@ -1,6 +1,6 @@
{
"name": "nomad-server",
"version": "2.6.1",
"name": "trek-server",
"version": "2.7.1",
"main": "src/index.ts",
"scripts": {
"start": "node --import tsx src/index.ts",
@@ -15,9 +15,12 @@
"express": "^4.18.3",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^2.1.1",
"node-cron": "^4.2.1",
"node-fetch": "^2.7.0",
"nodemailer": "^8.0.4",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"unzipper": "^0.12.3",
@@ -29,11 +32,13 @@
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/express": "^4.17.25",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0",
"@types/node": "^25.5.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.11",
"@types/qrcode": "^1.5.5",
"@types/unzipper": "^0.10.11",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
+201
View File
@@ -193,6 +193,207 @@ function runMigrations(db: Database.Database): void {
() => {
try { db.exec('ALTER TABLE reservations ADD COLUMN reservation_end_time TEXT'); } catch {}
},
() => {
try { db.exec('ALTER TABLE places ADD COLUMN osm_id TEXT'); } catch {}
},
() => {
try { db.exec('ALTER TABLE trip_files ADD COLUMN uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
try { db.exec('ALTER TABLE trip_files ADD COLUMN starred INTEGER DEFAULT 0'); } catch {}
try { db.exec('ALTER TABLE trip_files ADD COLUMN deleted_at TEXT'); } catch {}
},
() => {
try { db.exec('ALTER TABLE reservations ADD COLUMN accommodation_id INTEGER REFERENCES day_accommodations(id) ON DELETE SET NULL'); } catch {}
try { db.exec('ALTER TABLE reservations ADD COLUMN metadata TEXT'); } catch {}
},
() => {
db.exec(`CREATE TABLE IF NOT EXISTS invite_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
max_uses INTEGER NOT NULL DEFAULT 1,
used_count INTEGER NOT NULL DEFAULT 0,
expires_at TEXT,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
},
() => {
try { db.exec('ALTER TABLE users ADD COLUMN mfa_enabled INTEGER DEFAULT 0'); } catch {}
try { db.exec('ALTER TABLE users ADD COLUMN mfa_secret TEXT'); } catch {}
},
() => {
db.exec(`CREATE TABLE IF NOT EXISTS packing_category_assignees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
category_name TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(trip_id, category_name, user_id)
)`);
},
() => {
db.exec(`CREATE TABLE IF NOT EXISTS packing_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.exec(`CREATE TABLE IF NOT EXISTS packing_template_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_id INTEGER NOT NULL REFERENCES packing_templates(id) ON DELETE CASCADE,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
)`);
// Recreate items table with category_id FK (replaces old template_id-based schema)
try { db.exec('DROP TABLE IF EXISTS packing_template_items'); } catch {}
db.exec(`CREATE TABLE packing_template_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL REFERENCES packing_template_categories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
)`);
},
() => {
db.exec(`CREATE TABLE IF NOT EXISTS packing_bags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#6366f1',
weight_limit_grams INTEGER,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
try { db.exec('ALTER TABLE packing_items ADD COLUMN weight_grams INTEGER'); } catch {}
try { db.exec('ALTER TABLE packing_items ADD COLUMN bag_id INTEGER REFERENCES packing_bags(id) ON DELETE SET NULL'); } catch {}
},
() => {
db.exec(`CREATE TABLE IF NOT EXISTS visited_countries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
country_code TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, country_code)
)`);
},
() => {
db.exec(`CREATE TABLE IF NOT EXISTS bucket_list (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
lat REAL,
lng REAL,
country_code TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
},
() => {
// Configurable weekend days
try { db.exec("ALTER TABLE vacay_plans ADD COLUMN weekend_days TEXT DEFAULT '0,6'"); } catch {}
},
() => {
// Immich integration
try { db.exec("ALTER TABLE users ADD COLUMN immich_url TEXT"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN immich_api_key TEXT"); } catch {}
db.exec(`CREATE TABLE IF NOT EXISTS trip_photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
immich_asset_id TEXT NOT NULL,
shared INTEGER NOT NULL DEFAULT 1,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id, immich_asset_id)
)`);
// Add memories addon
try {
db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)").run('memories', 'Photos', 'trip', 'Image', 0, 7);
} catch {}
},
() => {
// Allow files to be linked to multiple reservations/assignments
db.exec(`CREATE TABLE IF NOT EXISTS file_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_id INTEGER NOT NULL REFERENCES trip_files(id) ON DELETE CASCADE,
reservation_id INTEGER REFERENCES reservations(id) ON DELETE CASCADE,
assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE,
place_id INTEGER REFERENCES places(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(file_id, reservation_id),
UNIQUE(file_id, assignment_id),
UNIQUE(file_id, place_id)
)`);
},
() => {
// Add day_plan_position to reservations for persistent transport ordering in day timeline
try { db.exec('ALTER TABLE reservations ADD COLUMN day_plan_position REAL DEFAULT NULL'); } catch {}
},
() => {
// Add paid_by_user_id to budget_items for expense tracking / settlement
try { db.exec('ALTER TABLE budget_items ADD COLUMN paid_by_user_id INTEGER REFERENCES users(id)'); } catch {}
},
() => {
// Add target_date to bucket_list for optional visit planning
try { db.exec('ALTER TABLE bucket_list ADD COLUMN target_date TEXT DEFAULT NULL'); } catch {}
},
() => {
// Notification preferences per user
db.exec(`CREATE TABLE IF NOT EXISTS notification_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
notify_trip_invite INTEGER DEFAULT 1,
notify_booking_change INTEGER DEFAULT 1,
notify_trip_reminder INTEGER DEFAULT 1,
notify_vacay_invite INTEGER DEFAULT 1,
notify_photos_shared INTEGER DEFAULT 1,
notify_collab_message INTEGER DEFAULT 1,
notify_packing_tagged INTEGER DEFAULT 1,
notify_webhook INTEGER DEFAULT 0,
UNIQUE(user_id)
)`);
},
() => {
// Add missing notification preference columns for existing tables
try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_vacay_invite INTEGER DEFAULT 1'); } catch {}
try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_photos_shared INTEGER DEFAULT 1'); } catch {}
try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_collab_message INTEGER DEFAULT 1'); } catch {}
try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_packing_tagged INTEGER DEFAULT 1'); } catch {}
},
() => {
// Public share links for read-only trip access
db.exec(`CREATE TABLE IF NOT EXISTS share_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_by INTEGER NOT NULL REFERENCES users(id),
share_map INTEGER DEFAULT 1,
share_bookings INTEGER DEFAULT 1,
share_packing INTEGER DEFAULT 0,
share_budget INTEGER DEFAULT 0,
share_collab INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
},
() => {
// Add permission columns to share_tokens
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_map INTEGER DEFAULT 1'); } catch {}
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_bookings INTEGER DEFAULT 1'); } catch {}
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_packing INTEGER DEFAULT 0'); } catch {}
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_budget INTEGER DEFAULT 0'); } catch {}
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_collab INTEGER DEFAULT 0'); } catch {}
},
() => {
// Audit log
db.exec(`
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
resource TEXT,
details TEXT,
ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
`);
},
];
if (currentVersion < migrations.length) {
+22
View File
@@ -15,6 +15,8 @@ function createTables(db: Database.Database): void {
oidc_sub TEXT,
oidc_issuer TEXT,
last_login DATETIME,
mfa_enabled INTEGER DEFAULT 0,
mfa_secret TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -281,6 +283,15 @@ function createTables(db: Database.Database): void {
UNIQUE(plan_id, date)
);
CREATE TABLE IF NOT EXISTS vacay_holiday_calendars (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
region TEXT NOT NULL,
label TEXT,
color TEXT NOT NULL DEFAULT '#fecaca',
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS day_accommodations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
@@ -369,6 +380,17 @@ function createTables(db: Database.Database): void {
UNIQUE(assignment_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
resource TEXT,
details TEXT,
ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
`);
}
+2 -2
View File
@@ -3,9 +3,9 @@ import Database from 'better-sqlite3';
function seedDemoData(db: Database.Database): { adminId: number; demoId: number } {
const ADMIN_USER = process.env.DEMO_ADMIN_USER || 'admin';
const ADMIN_EMAIL = process.env.DEMO_ADMIN_EMAIL || 'admin@nomad.app';
const ADMIN_EMAIL = process.env.DEMO_ADMIN_EMAIL || 'admin@trek.app';
const ADMIN_PASS = process.env.DEMO_ADMIN_PASS || 'admin12345';
const DEMO_EMAIL = 'demo@nomad.app';
const DEMO_EMAIL = 'demo@trek.app';
const DEMO_PASS = 'demo12345';
// Create admin user if not exists
+60 -7
View File
@@ -6,6 +6,7 @@ import path from 'path';
import fs from 'fs';
const app = express();
const DEBUG = String(process.env.DEBUG || 'false').toLowerCase() === 'true';
// Trust first proxy (nginx/Docker) for correct req.ip
if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) {
@@ -44,6 +45,8 @@ if (allowedOrigins) {
corsOrigin = true;
}
const shouldForceHttps = process.env.FORCE_HTTPS === 'true';
app.use(cors({
origin: corsOrigin,
credentials: true
@@ -60,12 +63,15 @@ app.use(helmet({
objectSrc: ["'self'"],
frameSrc: ["'self'"],
frameAncestors: ["'self'"],
upgradeInsecureRequests: shouldForceHttps ? [] : null
}
},
crossOriginEmbedderPolicy: false,
hsts: shouldForceHttps ? { maxAge: 31536000, includeSubDomains: false } : false,
}));
// Redirect HTTP to HTTPS in production
if (process.env.NODE_ENV === 'production' && process.env.FORCE_HTTPS !== 'false') {
// Redirect HTTP to HTTPS (opt-in via FORCE_HTTPS=true)
if (shouldForceHttps) {
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
res.redirect(301, 'https://' + req.headers.host + req.url);
@@ -74,10 +80,40 @@ if (process.env.NODE_ENV === 'production' && process.env.FORCE_HTTPS !== 'false'
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: true }));
// Avatars are public (shown on login, sharing screens)
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
if (DEBUG) {
app.use((req: Request, res: Response, next: NextFunction) => {
const startedAt = Date.now();
const requestId = Math.random().toString(36).slice(2, 10);
const redact = (value: unknown): unknown => {
if (!value || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.map(redact);
const hidden = new Set(['password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code']);
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = hidden.has(k.toLowerCase()) ? '[REDACTED]' : redact(v);
}
return out;
};
// All other uploads require authentication
const safeQuery = redact(req.query);
const safeBody = redact(req.body);
console.log(`[DEBUG][REQ ${requestId}] ${req.method} ${req.originalUrl} ip=${req.ip} query=${JSON.stringify(safeQuery)} body=${JSON.stringify(safeBody)}`);
res.on('finish', () => {
const elapsedMs = Date.now() - startedAt;
console.log(`[DEBUG][RES ${requestId}] ${req.method} ${req.originalUrl} status=${res.statusCode} elapsed_ms=${elapsedMs}`);
});
next();
});
}
// Avatars are public (shown on login, sharing screens)
import { authenticate } from './middleware/auth';
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
// Serve uploaded files (UUIDs are unguessable, path traversal protected)
app.get('/uploads/:type/:filename', (req: Request, res: Response) => {
const { type, filename } = req.params;
const allowedTypes = ['covers', 'files', 'photos'];
@@ -147,17 +183,33 @@ import vacayRoutes from './routes/vacay';
app.use('/api/addons/vacay', vacayRoutes);
import atlasRoutes from './routes/atlas';
app.use('/api/addons/atlas', atlasRoutes);
import immichRoutes from './routes/immich';
app.use('/api/integrations/immich', immichRoutes);
app.use('/api/maps', mapsRoutes);
app.use('/api/weather', weatherRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/backup', backupRoutes);
import notificationRoutes from './routes/notifications';
app.use('/api/notifications', notificationRoutes);
import shareRoutes from './routes/share';
app.use('/api', shareRoutes);
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
const publicPath = path.join(__dirname, '../public');
app.use(express.static(publicPath));
app.use(express.static(publicPath, {
setHeaders: (res, filePath) => {
// Never cache index.html so version updates are picked up immediately
if (filePath.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
}
},
}));
app.get('*', (req: Request, res: Response) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(path.join(publicPath, 'index.html'));
});
}
@@ -172,8 +224,9 @@ import * as scheduler from './scheduler';
const PORT = process.env.PORT || 3001;
const server = app.listen(PORT, () => {
console.log(`NOMAD API running on port ${PORT}`);
console.log(`TREK API running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
console.log(`Debug logs: ${DEBUG ? 'ENABLED' : 'disabled'}`);
if (process.env.DEMO_MODE === 'true') console.log('Demo mode: ENABLED');
if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') {
console.warn('[SECURITY WARNING] DEMO_MODE is enabled in production! Demo credentials are publicly exposed.');
+320 -7
View File
@@ -1,16 +1,23 @@
import express, { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { execSync } from 'child_process';
import path from 'path';
import fs from 'fs';
import { db } from '../db/database';
import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest, User, Addon } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
const router = express.Router();
router.use(authenticate, adminOnly);
function utcSuffix(ts: string | null | undefined): string | null {
if (!ts) return null;
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
}
router.get('/users', (req: Request, res: Response) => {
const users = db.prepare(
'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
@@ -20,7 +27,13 @@ router.get('/users', (req: Request, res: Response) => {
const { getOnlineUserIds } = require('../websocket');
onlineUserIds = getOnlineUserIds();
} catch { /* */ }
const usersWithStatus = users.map(u => ({ ...u, online: onlineUserIds.has(u.id) }));
const usersWithStatus = users.map(u => ({
...u,
created_at: utcSuffix(u.created_at),
updated_at: utcSuffix(u.updated_at as string),
last_login: utcSuffix(u.last_login),
online: onlineUserIds.has(u.id),
}));
res.json({ users: usersWithStatus });
});
@@ -51,6 +64,14 @@ router.post('/users', (req: Request, res: Response) => {
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
).get(result.lastInsertRowid);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.user_create',
resource: String(result.lastInsertRowid),
ip: getClientIp(req),
details: { username: username.trim(), email: email.trim(), role: role || 'user' },
});
res.status(201).json({ user });
});
@@ -89,12 +110,25 @@ router.put('/users/:id', (req: Request, res: Response) => {
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
).get(req.params.id);
const authReq = req as AuthRequest;
const changed: string[] = [];
if (username) changed.push('username');
if (email) changed.push('email');
if (role) changed.push('role');
if (password) changed.push('password');
writeAudit({
userId: authReq.user.id,
action: 'admin.user_update',
resource: String(req.params.id),
ip: getClientIp(req),
details: { fields: changed },
});
res.json({ user: updated });
});
router.delete('/users/:id', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (parseInt(req.params.id) === authReq.user.id) {
if (parseInt(req.params.id as string) === authReq.user.id) {
return res.status(400).json({ error: 'Cannot delete own account' });
}
@@ -102,6 +136,12 @@ router.delete('/users/:id', (req: Request, res: Response) => {
if (!user) return res.status(404).json({ error: 'User not found' });
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
writeAudit({
userId: authReq.user.id,
action: 'admin.user_delete',
resource: String(req.params.id),
ip: getClientIp(req),
});
res.json({ success: true });
});
@@ -114,6 +154,48 @@ router.get('/stats', (_req: Request, res: Response) => {
res.json({ totalUsers, totalTrips, totalPlaces, totalFiles });
});
router.get('/audit-log', (req: Request, res: Response) => {
const limitRaw = parseInt(String(req.query.limit || '100'), 10);
const offsetRaw = parseInt(String(req.query.offset || '0'), 10);
const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500);
const offset = Math.max(Number.isFinite(offsetRaw) ? offsetRaw : 0, 0);
type Row = {
id: number;
created_at: string;
user_id: number | null;
username: string | null;
user_email: string | null;
action: string;
resource: string | null;
details: string | null;
ip: string | null;
};
const rows = db.prepare(`
SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip
FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
ORDER BY a.id DESC
LIMIT ? OFFSET ?
`).all(limit, offset) as Row[];
const total = (db.prepare('SELECT COUNT(*) as c FROM audit_log').get() as { c: number }).c;
res.json({
entries: rows.map((r) => {
let details: Record<string, unknown> | null = null;
if (r.details) {
try {
details = JSON.parse(r.details) as Record<string, unknown>;
} catch {
details = { _parse_error: true };
}
}
return { ...r, details };
}),
total,
limit,
offset,
});
});
router.get('/oidc', (_req: Request, res: Response) => {
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || '';
const secret = get('oidc_client_secret');
@@ -122,26 +204,37 @@ router.get('/oidc', (_req: Request, res: Response) => {
client_id: get('oidc_client_id'),
client_secret_set: !!secret,
display_name: get('oidc_display_name'),
oidc_only: get('oidc_only') === 'true',
});
});
router.put('/oidc', (req: Request, res: Response) => {
const { issuer, client_id, client_secret, display_name } = req.body;
const { issuer, client_id, client_secret, display_name, oidc_only } = req.body;
const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || '');
set('oidc_issuer', issuer);
set('oidc_client_id', client_id);
if (client_secret !== undefined) set('oidc_client_secret', client_secret);
set('oidc_display_name', display_name);
set('oidc_only', oidc_only ? 'true' : 'false');
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.oidc_update',
ip: getClientIp(req),
details: { oidc_only: !!oidc_only, issuer_set: !!issuer },
});
res.json({ success: true });
});
router.post('/save-demo-baseline', (_req: Request, res: Response) => {
router.post('/save-demo-baseline', (req: Request, res: Response) => {
if (process.env.DEMO_MODE !== 'true') {
return res.status(404).json({ error: 'Not found' });
}
try {
const { saveBaseline } = require('../demo/demo-reset');
saveBaseline();
const authReq = req as AuthRequest;
writeAudit({ userId: authReq.user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
res.json({ success: true, message: 'Demo baseline saved. Hourly resets will restore to this state.' });
} catch (err: unknown) {
console.error(err);
@@ -166,12 +259,27 @@ function compareVersions(a: string, b: string): number {
return 0;
}
router.get('/github-releases', async (req: Request, res: Response) => {
const { per_page = '10', page = '1' } = req.query;
try {
const resp = await fetch(
`https://api.github.com/repos/mauriceboe/TREK/releases?per_page=${per_page}&page=${page}`,
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
if (!resp.ok) return res.json([]);
const data = await resp.json();
res.json(Array.isArray(data) ? data : []);
} catch {
res.json([]);
}
});
router.get('/version-check', async (_req: Request, res: Response) => {
const { version: currentVersion } = require('../../package.json');
try {
const resp = await fetch(
'https://api.github.com/repos/mauriceboe/NOMAD/releases/latest',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'NOMAD-Server' } }
'https://api.github.com/repos/mauriceboe/TREK/releases/latest',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
if (!resp.ok) return res.json({ current: currentVersion, latest: currentVersion, update_available: false });
const data = await resp.json() as { tag_name?: string; html_url?: string };
@@ -183,7 +291,7 @@ router.get('/version-check', async (_req: Request, res: Response) => {
}
});
router.post('/update', async (_req: Request, res: Response) => {
router.post('/update', async (req: Request, res: Response) => {
const rootDir = path.resolve(__dirname, '../../..');
const serverDir = path.resolve(__dirname, '../..');
const clientDir = path.join(rootDir, 'client');
@@ -206,6 +314,13 @@ router.post('/update', async (_req: Request, res: Response) => {
const { version: newVersion } = require('../../package.json');
steps.push({ step: 'version', version: newVersion });
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.system_update',
resource: newVersion,
ip: getClientIp(req),
});
res.json({ success: true, steps, restarting: true });
setTimeout(() => {
@@ -219,6 +334,196 @@ router.post('/update', async (_req: Request, res: Response) => {
}
});
// ── Invite Tokens ───────────────────────────────────────────────────────────
router.get('/invites', (_req: Request, res: Response) => {
const invites = db.prepare(`
SELECT i.*, u.username as created_by_name
FROM invite_tokens i
JOIN users u ON i.created_by = u.id
ORDER BY i.created_at DESC
`).all();
res.json({ invites });
});
router.post('/invites', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { max_uses, expires_in_days } = req.body;
const rawUses = parseInt(max_uses);
const uses = rawUses === 0 ? 0 : Math.min(Math.max(rawUses || 1, 1), 5);
const token = crypto.randomBytes(16).toString('hex');
const expiresAt = expires_in_days
? new Date(Date.now() + parseInt(expires_in_days) * 86400000).toISOString()
: null;
const ins = db.prepare(
'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)'
).run(token, uses, expiresAt, authReq.user.id);
const inviteId = Number(ins.lastInsertRowid);
const invite = db.prepare(`
SELECT i.*, u.username as created_by_name
FROM invite_tokens i
JOIN users u ON i.created_by = u.id
WHERE i.id = ?
`).get(inviteId);
writeAudit({
userId: authReq.user.id,
action: 'admin.invite_create',
resource: String(inviteId),
ip: getClientIp(req),
details: { max_uses: uses, expires_in_days: expires_in_days ?? null },
});
res.status(201).json({ invite });
});
router.delete('/invites/:id', (req: Request, res: Response) => {
const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(req.params.id);
if (!invite) return res.status(404).json({ error: 'Invite not found' });
db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(req.params.id);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.invite_delete',
resource: String(req.params.id),
ip: getClientIp(req),
});
res.json({ success: true });
});
// ── Bag Tracking Setting ────────────────────────────────────────────────────
router.get('/bag-tracking', (_req: Request, res: Response) => {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as { value: string } | undefined;
res.json({ enabled: row?.value === 'true' });
});
router.put('/bag-tracking', (req: Request, res: Response) => {
const { enabled } = req.body;
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(enabled ? 'true' : 'false');
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.bag_tracking',
ip: getClientIp(req),
details: { enabled: !!enabled },
});
res.json({ enabled: !!enabled });
});
// ── Packing Templates ───────────────────────────────────────────────────────
router.get('/packing-templates', (_req: Request, res: Response) => {
const templates = db.prepare(`
SELECT pt.*, u.username as created_by_name,
(SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count,
(SELECT COUNT(*) FROM packing_template_categories WHERE template_id = pt.id) as category_count
FROM packing_templates pt
JOIN users u ON pt.created_by = u.id
ORDER BY pt.created_at DESC
`).all();
res.json({ templates });
});
router.get('/packing-templates/:id', (_req: Request, res: Response) => {
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(_req.params.id);
if (!template) return res.status(404).json({ error: 'Template not found' });
const categories = db.prepare('SELECT * FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order, id').all(_req.params.id) as any[];
const items = db.prepare(`
SELECT ti.* FROM packing_template_items ti
JOIN packing_template_categories tc ON ti.category_id = tc.id
WHERE tc.template_id = ? ORDER BY ti.sort_order, ti.id
`).all(_req.params.id);
res.json({ template, categories, items });
});
router.post('/packing-templates', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { name } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
const result = db.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run(name.trim(), authReq.user.id);
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ template });
});
router.put('/packing-templates/:id', (req: Request, res: Response) => {
const { name } = req.body;
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id);
if (!template) return res.status(404).json({ error: 'Template not found' });
if (name?.trim()) db.prepare('UPDATE packing_templates SET name = ? WHERE id = ?').run(name.trim(), req.params.id);
res.json({ template: db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id) });
});
router.delete('/packing-templates/:id', (req: Request, res: Response) => {
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id);
if (!template) return res.status(404).json({ error: 'Template not found' });
db.prepare('DELETE FROM packing_templates WHERE id = ?').run(req.params.id);
const authReq = req as AuthRequest;
const t = template as { name?: string };
writeAudit({
userId: authReq.user.id,
action: 'admin.packing_template_delete',
resource: String(req.params.id),
ip: getClientIp(req),
details: { name: t.name },
});
res.json({ success: true });
});
// Template categories
router.post('/packing-templates/:id/categories', (req: Request, res: Response) => {
const { name } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Category name is required' });
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id);
if (!template) return res.status(404).json({ error: 'Template not found' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_categories WHERE template_id = ?').get(req.params.id) as { max: number | null };
const result = db.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)').run(req.params.id, name.trim(), (maxOrder.max ?? -1) + 1);
res.status(201).json({ category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(result.lastInsertRowid) });
});
router.put('/packing-templates/:templateId/categories/:catId', (req: Request, res: Response) => {
const { name } = req.body;
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(req.params.catId, req.params.templateId);
if (!cat) return res.status(404).json({ error: 'Category not found' });
if (name?.trim()) db.prepare('UPDATE packing_template_categories SET name = ? WHERE id = ?').run(name.trim(), req.params.catId);
res.json({ category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(req.params.catId) });
});
router.delete('/packing-templates/:templateId/categories/:catId', (_req: Request, res: Response) => {
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(_req.params.catId, _req.params.templateId);
if (!cat) return res.status(404).json({ error: 'Category not found' });
db.prepare('DELETE FROM packing_template_categories WHERE id = ?').run(_req.params.catId);
res.json({ success: true });
});
// Template items
router.post('/packing-templates/:templateId/categories/:catId/items', (req: Request, res: Response) => {
const { name } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Item name is required' });
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(req.params.catId, req.params.templateId);
if (!cat) return res.status(404).json({ error: 'Category not found' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_items WHERE category_id = ?').get(req.params.catId) as { max: number | null };
const result = db.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(req.params.catId, name.trim(), (maxOrder.max ?? -1) + 1);
res.status(201).json({ item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(result.lastInsertRowid) });
});
router.put('/packing-templates/:templateId/items/:itemId', (req: Request, res: Response) => {
const { name } = req.body;
const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(req.params.itemId);
if (!item) return res.status(404).json({ error: 'Item not found' });
if (name?.trim()) db.prepare('UPDATE packing_template_items SET name = ? WHERE id = ?').run(name.trim(), req.params.itemId);
res.json({ item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(req.params.itemId) });
});
router.delete('/packing-templates/:templateId/items/:itemId', (_req: Request, res: Response) => {
const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(_req.params.itemId);
if (!item) return res.status(404).json({ error: 'Item not found' });
db.prepare('DELETE FROM packing_template_items WHERE id = ?').run(_req.params.itemId);
res.json({ success: true });
});
router.get('/addons', (_req: Request, res: Response) => {
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })) });
@@ -231,6 +536,14 @@ router.put('/addons/:id', (req: Request, res: Response) => {
if (enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id);
if (config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id);
const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon;
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.addon_update',
resource: String(req.params.id),
ip: getClientIp(req),
details: { enabled: enabled !== undefined ? !!enabled : undefined, config_changed: config !== undefined },
});
res.json({ addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') } });
});

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