Compare commits

...

75 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
96 changed files with 12595 additions and 473 deletions
+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
+38 -11
View File
@@ -22,7 +22,7 @@
</p>
![TREK Screenshot](docs/screenshot.png)
![NOMAD Screenshot 2](docs/screenshot-2.png)
![TREK Screenshot 2](docs/screenshot-2.png)
<details>
<summary>More Screenshots</summary>
@@ -44,11 +44,14 @@
- **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 TREK branding
@@ -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, German, Chinese (Simplified), Dutch, Russian (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
@@ -119,6 +125,11 @@ services:
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
@@ -163,13 +174,13 @@ For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, T
```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.
@@ -226,7 +253,7 @@ API keys are configured in the **Admin Panel** after login. Keys set by the admi
```bash
git clone https://github.com/mauriceboe/TREK.git
cd NOMAD
cd TREK
docker build -t trek .
```
+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 TREK 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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "trek-client",
"version": "2.6.2",
"version": "2.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-client",
"version": "2.6.2",
"version": "2.7.0",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "2.7.0",
"version": "2.7.1",
"private": true,
"type": "module",
"scripts": {
+25 -2
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,6 +129,7 @@ export default function App() {
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/shared/:token" element={<SharedTripPage />} />
<Route path="/register" element={<LoginPage />} />
<Route
path="/dashboard"
+26
View File
@@ -91,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 = {
@@ -108,6 +112,7 @@ 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),
@@ -163,6 +168,8 @@ export const adminApi = {
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 = {
@@ -174,6 +181,7 @@ export const mapsApi = {
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 = {
@@ -184,6 +192,7 @@ 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 = {
@@ -197,6 +206,9 @@ export const filesApi = {
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 = {
@@ -204,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 = {
@@ -278,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
+2 -2
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 {
@@ -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>
+2 -2
View File
@@ -18,8 +18,8 @@ export default function GitHubPanel() {
const fetchReleases = async (pageNum = 1, append = false) => {
try {
const res = await apiClient.get(`/auth/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
const data = res.data
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) {
+143 -21
View File
@@ -3,7 +3,7 @@ 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'
@@ -29,8 +29,23 @@ 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) => {
@@ -145,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)
@@ -160,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' }} />
@@ -177,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
)}
@@ -194,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)
@@ -237,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={{
@@ -376,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 })
}
@@ -539,6 +582,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)}
compact={false}
/>
</div>
@@ -553,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')} />
@@ -628,6 +673,91 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
{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 && (
@@ -641,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>
)}
@@ -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, locale) {
function getTime(tz, locale, is12h) {
try {
return new Date().toLocaleTimeString(locale, { 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 '—' }
}
@@ -42,6 +43,7 @@ function getOffset(tz) {
export default function TimezoneWidget() {
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) : [
@@ -87,7 +89,7 @@ export default function TimezoneWidget() {
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
const localTime = new Date().toLocaleTimeString(locale, { 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)
@@ -113,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, locale)}</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)' }}>
@@ -155,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, locale)}</span>
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale, is12h)}</span>
</button>
))}
</div>
+95 -38
View File
@@ -302,10 +302,15 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
const renderFileRow = (file: TripFile, isTrash = false) => {
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 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 (
@@ -365,12 +370,12 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{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')}`} />
)}
{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'} />
)}
@@ -477,20 +482,45 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
}
}
const unassigned = places.filter(p => !assignedPlaceIds.has(p.id))
const placeBtn = (p: Place) => (
<button key={p.id} onClick={() => handleAssign(file.id, { place_id: file.place_id === p.id ? null : p.id })} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: file.place_id === p.id ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: file.place_id === p.id ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = file.place_id === p.id ? '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>
{file.place_id === p.id && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
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 }}>
@@ -519,20 +549,47 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
<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 => (
<button key={r.id} onClick={() => handleAssign(file.id, { reservation_id: file.reservation_id === r.id ? null : r.id })} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: file.reservation_id === r.id ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: file.reservation_id === r.id ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = file.reservation_id === r.id ? '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>
{file.reservation_id === r.id && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
))}
{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>
)
+128 -22
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>`,
@@ -240,6 +240,96 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
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 = [],
dayPlaces = [],
@@ -270,33 +360,48 @@ export function MapView({
}, [leftWidth, rightWidth, hasInspector])
const [photoUrls, setPhotoUrls] = useState({})
// Fetch photos for places (Google or Wikimedia Commons fallback)
// Fetch photos for places with concurrency limit to avoid blocking map rendering
useEffect(() => {
places.forEach(place => {
if (place.image_url) 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
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
return false
}
if (mapPhotoInFlight.has(cacheKey)) return
if (mapPhotoInFlight.has(cacheKey)) return false
const photoId = place.google_place_id || place.osm_id
if (!photoId && !(place.lat && place.lng)) return
mapPhotoInFlight.add(cacheKey)
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)
}
mapPhotoInFlight.delete(cacheKey)
})
.catch(() => { mapPhotoCache.set(cacheKey, null); mapPhotoInFlight.delete(cacheKey) })
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 (
@@ -318,6 +423,7 @@ export function MapView({
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<LocationTracker />
<MarkerClusterGroup
chunkedLoading
@@ -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>
)
}
+41 -2
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 `
@@ -3,9 +3,10 @@ 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,
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus,
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus, Upload,
} from 'lucide-react'
import type { PackingItem } from '../../types'
@@ -651,9 +652,13 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
const handleAddNewCategory = async () => {
if (!newCatName.trim()) return
// Create a first item in the new category to make it appear
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: newCatName.trim() })
await addPackingItem(tripId, { name: '...', category: catName })
setNewCatName('')
setAddingCategory(false)
} catch { toast.error(t('packing.toast.addError')) }
@@ -723,6 +728,9 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
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(() => {
@@ -753,6 +761,44 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
}
}
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" }
return (
@@ -777,6 +823,13 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
</button>
)}
<button onClick={() => setShowImportModal(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
}}>
<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={{
@@ -1098,6 +1151,60 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
.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>
)
}
@@ -50,12 +50,15 @@ 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) {
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)
@@ -146,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%)',
@@ -368,7 +371,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<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>#{linked.confirmation_number}</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>
+716 -56
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'
@@ -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>
@@ -492,6 +821,18 @@ export default function DayPlanSidebar({
</button>
{(() => {
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
@@ -542,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
@@ -577,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') {
@@ -590,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}`}>
@@ -773,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>
@@ -785,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}`
@@ -991,6 +1458,199 @@ 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' }}>
@@ -104,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) {
@@ -120,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'
@@ -169,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 || [])
@@ -196,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",
}}
+115 -25
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
@@ -26,33 +31,55 @@ interface PlacesSidebarProps {
}
export default function PlacesSidebar({
places, categories, assignments, selectedDayId, selectedPlaceId,
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, setCategoryFilterLocal] = useState('')
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
const setCategoryFilter = (val: string) => {
setCategoryFilterLocal(val)
onCategoryFilterChange?.(val)
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)
@@ -72,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 }}>
@@ -106,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 */}
@@ -178,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'
@@ -62,6 +65,8 @@ interface 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)
@@ -78,6 +83,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
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),
@@ -204,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,
@@ -459,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) => (
@@ -477,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>
@@ -505,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} />
@@ -134,7 +138,19 @@ 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>
@@ -227,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>
)
}
@@ -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>
+10 -14
View File
@@ -3,14 +3,7 @@ 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 WEEKDAYS_ES = ['Lu', 'Ma', 'Mi', 'Ju', 'Vi', 'Sa', 'Do']
const WEEKDAYS_AR = ['اث', 'ثل', 'أر', 'خم', 'جم', 'سب', 'أح']
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 MONTHS_ES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']
const MONTHS_AR = ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر']
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)
@@ -29,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 : language === 'es' ? WEEKDAYS_ES : language === 'ar' ? WEEKDAYS_AR : WEEKDAYS_EN
const monthNames = language === 'de' ? MONTHS_DE : language === 'es' ? MONTHS_ES : language === 'ar' ? MONTHS_AR : 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()
@@ -58,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)' }}>
@@ -76,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] || []
@@ -49,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}
+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 }
@@ -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 {
+28 -18
View File
@@ -16,8 +16,18 @@ interface PlaceAvatarProps {
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(() => {
@@ -33,28 +43,27 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
}
if (photoInFlight.has(cacheKey)) {
// Another instance is already fetching, wait for it
const check = setInterval(() => {
if (photoCache.has(cacheKey)) {
clearInterval(check)
const cached = photoCache.get(cacheKey)
if (cached) setPhotoSrc(cached)
}
}, 200)
return () => clearInterval(check)
// 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) {
photoCache.set(cacheKey, data.photoUrl)
setPhotoSrc(data.photoUrl)
} else {
photoCache.set(cacheKey, null)
}
const url = data.photoUrl || null
photoCache.set(cacheKey, url)
if (url) setPhotoSrc(url)
notifyListeners(cacheKey, url)
photoInFlight.delete(cacheKey)
})
.catch(() => {
photoCache.set(cacheKey, null)
notifyListeners(cacheKey, null)
photoInFlight.delete(cacheKey)
})
.catch(() => { photoCache.set(cacheKey, null); photoInFlight.delete(cacheKey) })
}, [place.id, place.image_url, place.google_place_id, place.osm_id])
const bgColor = category?.color || '#6366f1'
@@ -76,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)}
/>
@@ -88,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>
)
}
})
+12 -3
View File
@@ -4,10 +4,14 @@ 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 | { name: string; category: string }[]>
@@ -16,14 +20,18 @@ export const SUPPORTED_LANGUAGES = [
{ 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, ru, zh, nl, ar }
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA' }
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 {
@@ -31,7 +39,8 @@ export function getLocaleForLanguage(language: string): string {
}
export function getIntlLanguage(language: string): string {
return ['de', 'es', 'fr', 'ru', 'zh', 'nl', 'ar'].includes(language) ? language : 'en'
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 {
+156 -1
View File
@@ -144,6 +144,50 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.temperature': 'وحدة الحرارة',
'settings.timeFormat': 'تنسيق الوقت',
'settings.routeCalculation': 'حساب المسار',
'settings.blurBookingCodes': 'إخفاء رموز الحجز',
'settings.notifications': 'الإشعارات',
'settings.notifyTripInvite': 'دعوات الرحلات',
'settings.notifyBookingChange': 'تغييرات الحجز',
'settings.notifyTripReminder': 'تذكيرات الرحلات',
'settings.notifyVacayInvite': 'دعوات دمج الإجازات',
'settings.notifyPhotosShared': 'صور مشتركة (Immich)',
'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)',
'settings.notifyPackingTagged': 'قائمة الأمتعة: التعيينات',
'settings.notifyWebhook': 'إشعارات Webhook',
'admin.smtp.title': 'البريد والإشعارات',
'admin.smtp.hint': 'تكوين SMTP لإشعارات البريد الإلكتروني. اختياري: عنوان Webhook لـ Discord أو Slack وغيرها.',
'admin.smtp.testButton': 'إرسال بريد تجريبي',
'admin.smtp.testSuccess': 'تم إرسال البريد التجريبي بنجاح',
'admin.smtp.testFailed': 'فشل إرسال البريد التجريبي',
'dayplan.icsTooltip': 'تصدير التقويم (ICS)',
'share.linkTitle': 'رابط عام',
'share.linkHint': 'أنشئ رابطًا يمكن لأي شخص استخدامه لعرض هذه الرحلة بدون تسجيل الدخول. للقراءة فقط — لا يمكن التعديل.',
'share.createLink': 'إنشاء رابط',
'share.deleteLink': 'حذف الرابط',
'share.createError': 'تعذر إنشاء الرابط',
'common.copy': 'نسخ',
'common.copied': 'تم النسخ',
'share.permMap': 'الخريطة والخطة',
'share.permBookings': 'الحجوزات',
'share.permPacking': 'الأمتعة',
'shared.expired': 'الرابط منتهي أو غير صالح',
'shared.expiredHint': 'رابط الرحلة المشترك لم يعد نشطًا.',
'shared.readOnly': 'عرض للقراءة فقط',
'shared.tabPlan': 'الخطة',
'shared.tabBookings': 'الحجوزات',
'shared.tabPacking': 'قائمة التعبئة',
'shared.tabBudget': 'الميزانية',
'shared.tabChat': 'الدردشة',
'shared.days': 'أيام',
'shared.places': 'أماكن',
'shared.other': 'أخرى',
'shared.totalBudget': 'إجمالي الميزانية',
'shared.messages': 'رسائل',
'shared.sharedVia': 'تمت المشاركة عبر',
'shared.confirmed': 'مؤكد',
'shared.pending': 'قيد الانتظار',
'share.permBudget': 'الميزانية',
'share.permCollab': 'الدردشة',
'settings.on': 'تشغيل',
'settings.off': 'إيقاف',
'settings.account': 'الحساب',
@@ -276,6 +320,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.users': 'المستخدمون',
'admin.tabs.categories': 'الفئات',
'admin.tabs.backup': 'النسخ الاحتياطي',
'admin.tabs.audit': 'سجل التدقيق',
'admin.tabs.settings': 'الإعدادات',
'admin.tabs.config': 'الإعدادات',
'admin.tabs.templates': 'قوالب التعبئة',
@@ -380,7 +425,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// Addons
'admin.addons.title': 'الإضافات',
'admin.addons.subtitle': 'فعّل أو عطّل الميزات لتخصيص تجربة TREK.',
'admin.addons.catalog.memories.name': 'الذكريات',
'admin.addons.catalog.memories.name': 'ذكريات',
'admin.addons.catalog.memories.description': 'ألبومات صور مشتركة لكل رحلة',
'admin.addons.catalog.packing.name': 'التعبئة',
'admin.addons.catalog.packing.description': 'قوائم تحقق لإعداد أمتعتك لكل رحلة',
@@ -419,6 +464,18 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.weather.locationHint': 'يعتمد الطقس على أول مكان بإحداثيات في كل يوم. إذا لم يكن هناك مكان مخصص ليوم ما، يُستخدم أي مكان من قائمة الأماكن كمرجع.',
// GitHub
'admin.audit.subtitle': 'أحداث الأمان والإدارة (النسخ الاحتياطية، المستخدمون، المصادقة الثنائية، الإعدادات).',
'admin.audit.empty': 'لا توجد سجلات تدقيق بعد.',
'admin.audit.refresh': 'تحديث',
'admin.audit.loadMore': 'تحميل المزيد',
'admin.audit.showing': 'تم تحميل {count} · الإجمالي {total}',
'admin.audit.col.time': 'الوقت',
'admin.audit.col.user': 'المستخدم',
'admin.audit.col.action': 'الإجراء',
'admin.audit.col.resource': 'المورد',
'admin.audit.col.ip': 'عنوان IP',
'admin.audit.col.details': 'التفاصيل',
'admin.github.title': 'سجل الإصدارات',
'admin.github.subtitle': 'آخر التحديثات من {repo}',
'admin.github.latest': 'الأحدث',
@@ -482,6 +539,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'vacay.carriedOver': 'من {year}',
'vacay.blockWeekends': 'حظر عطلة نهاية الأسبوع',
'vacay.blockWeekendsHint': 'منع إدخالات الإجازة يومي السبت والأحد',
'vacay.weekendDays': 'أيام عطلة نهاية الأسبوع',
'vacay.mon': 'الاثنين',
'vacay.tue': 'الثلاثاء',
'vacay.wed': 'الأربعاء',
'vacay.thu': 'الخميس',
'vacay.fri': 'الجمعة',
'vacay.sat': 'السبت',
'vacay.sun': 'الأحد',
'vacay.publicHolidays': 'العطل الرسمية',
'vacay.publicHolidaysHint': 'وضع علامة على العطل الرسمية في التقويم',
'vacay.selectCountry': 'اختر الدولة',
@@ -539,6 +604,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'atlas.markVisited': 'تعيين كمُزار',
'atlas.markVisitedHint': 'إضافة هذا البلد إلى قائمة المُزارة',
'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات',
'atlas.addPoi': 'إضافة مكان',
'atlas.bucketNamePlaceholder': 'الاسم (بلد، مدينة، مكان…)',
'atlas.month': 'الشهر',
'atlas.year': 'السنة',
'atlas.addToBucketHint': 'حفظ كمكان تريد زيارته',
'atlas.bucketWhen': 'متى تخطط للزيارة؟',
'atlas.statsTab': 'الإحصائيات',
@@ -624,14 +693,26 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'تصدير خطة اليوم بصيغة PDF',
'dayplan.pdfError': 'فشل تصدير PDF',
'dayplan.cannotReorderTransport': 'لا يمكن إعادة ترتيب الحجوزات ذات الوقت الثابت',
'dayplan.confirmRemoveTimeTitle': 'إزالة الوقت؟',
'dayplan.confirmRemoveTimeBody': 'هذا المكان له وقت ثابت ({time}). نقله سيزيل الوقت ويسمح بالترتيب الحر.',
'dayplan.confirmRemoveTimeAction': 'إزالة الوقت ونقل',
'dayplan.cannotDropOnTimed': 'لا يمكن وضع العناصر بين الإدخالات المرتبطة بوقت',
'dayplan.cannotBreakChronology': 'سيؤدي هذا إلى كسر الترتيب الزمني للعناصر والحجوزات المجدولة',
// Places Sidebar
'places.addPlace': 'إضافة مكان/نشاط',
'places.importGpx': 'استيراد GPX',
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
'places.gpxError': 'فشل استيراد GPX',
'places.urlResolved': 'تم استيراد المكان من الرابط',
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
'places.all': 'الكل',
'places.unplanned': 'غير مخطط',
'places.search': 'ابحث عن أماكن...',
'places.allCategories': 'كل الفئات',
'places.categoriesSelected': 'فئات',
'places.clearFilter': 'مسح الفلتر',
'places.count': '{count} أماكن',
'places.countSingular': 'مكان واحد',
'places.allPlanned': 'تم تخطيط جميع الأماكن',
@@ -731,6 +812,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.tour': 'جولة',
'reservations.type.other': 'أخرى',
'reservations.confirm.delete': 'هل تريد حذف الحجز "{name}"؟',
'reservations.confirm.deleteTitle': 'حذف الحجز؟',
'reservations.confirm.deleteBody': 'سيتم حذف "{name}" نهائيًا.',
'reservations.toast.updated': 'تم تحديث الحجز',
'reservations.toast.removed': 'تم حذف الحجز',
'reservations.toast.fileUploaded': 'تم رفع الملف',
@@ -750,6 +833,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'reservations.pendingSave': 'سيتم الحفظ…',
'reservations.uploading': 'جارٍ الرفع...',
'reservations.attachFile': 'إرفاق ملف',
'reservations.linkExisting': 'ربط ملف موجود',
'reservations.toast.saveError': 'فشل الحفظ',
'reservations.toast.updateError': 'فشل التحديث',
'reservations.toast.deleteError': 'فشل الحذف',
@@ -787,6 +871,9 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'budget.paid': 'مدفوع',
'budget.open': 'مفتوح',
'budget.noMembers': 'لا أعضاء معينون',
'budget.settlement': 'التسوية',
'budget.settlementInfo': 'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.',
'budget.netBalances': 'الأرصدة الصافية',
// Files
'files.title': 'الملفات',
@@ -840,6 +927,15 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// Packing
'packing.title': 'قائمة التجهيز',
'packing.empty': 'قائمة التجهيز فارغة',
'packing.import': 'استيراد',
'packing.importTitle': 'استيراد قائمة التعبئة',
'packing.importHint': 'عنصر واحد لكل سطر. يمكن إضافة الفئة والكمية مفصولة بفاصلة أو فاصلة منقوطة أو علامة تبويب: الاسم، الفئة، الكمية',
'packing.importPlaceholder': 'فرشاة أسنان\nواقي شمس، نظافة\nقمصان، ملابس، 5\nجواز سفر، مستندات',
'packing.importCsv': 'تحميل CSV/TXT',
'packing.importAction': 'استيراد {count}',
'packing.importSuccess': 'تم استيراد {count} عنصر',
'packing.importError': 'فشل الاستيراد',
'packing.importEmpty': 'لا توجد عناصر للاستيراد',
'packing.progress': '{packed} من {total} جُهّز ({percent}%)',
'packing.clearChecked': 'إزالة {count} محدد',
'packing.clearCheckedShort': 'إزالة {count}',
@@ -991,7 +1087,27 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'backup.auto.enable': 'تفعيل النسخ التلقائي',
'backup.auto.enableHint': 'سيتم إنشاء نسخ احتياطية تلقائيًا وفق الجدول المختار',
'backup.auto.interval': 'الفترة',
'backup.auto.hour': 'التنفيذ في الساعة',
'backup.auto.hourHint': 'التوقيت المحلي للخادم (تنسيق {format})',
'backup.auto.dayOfWeek': 'يوم الأسبوع',
'backup.auto.dayOfMonth': 'يوم الشهر',
'backup.auto.dayOfMonthHint': 'محدود بين 1–28 للتوافق مع جميع الأشهر',
'backup.auto.scheduleSummary': 'الجدول',
'backup.auto.summaryDaily': 'كل يوم الساعة {hour}:00',
'backup.auto.summaryWeekly': 'كل {day} الساعة {hour}:00',
'backup.auto.summaryMonthly': 'اليوم {day} من كل شهر الساعة {hour}:00',
'backup.auto.envLocked': 'Docker',
'backup.auto.envLockedHint': 'النسخ الاحتياطي التلقائي مُعدّ عبر متغيرات بيئة Docker. لتعديل الإعدادات، حدّث docker-compose.yml وأعد تشغيل الحاوية.',
'backup.auto.copyEnv': 'نسخ متغيرات بيئة Docker',
'backup.auto.envCopied': 'تم نسخ متغيرات بيئة Docker إلى الحافظة',
'backup.auto.keepLabel': 'حذف النسخ القديمة بعد',
'backup.dow.sunday': 'أحد',
'backup.dow.monday': 'إثن',
'backup.dow.tuesday': 'ثلا',
'backup.dow.wednesday': 'أرب',
'backup.dow.thursday': 'خمي',
'backup.dow.friday': 'جمع',
'backup.dow.saturday': 'سبت',
'backup.interval.hourly': 'كل ساعة',
'backup.interval.daily': 'يوميًا',
'backup.interval.weekly': 'أسبوعيًا',
@@ -1118,6 +1234,45 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'day.editAccommodation': 'تعديل الإقامة',
'day.reservations': 'الحجوزات',
// Memories / Immich
'memories.title': 'صور',
'memories.notConnected': 'Immich غير متصل',
'memories.notConnectedHint': 'قم بتوصيل Immich في الإعدادات لعرض صور رحلتك هنا.',
'memories.noDates': 'أضف تواريخ لرحلتك لتحميل الصور.',
'memories.noPhotos': 'لم يتم العثور على صور',
'memories.noPhotosHint': 'لم يتم العثور على صور في Immich لفترة هذه الرحلة.',
'memories.photosFound': 'صور',
'memories.fromOthers': 'من آخرين',
'memories.sharePhotos': 'مشاركة الصور',
'memories.sharing': 'مشترك',
'memories.reviewTitle': 'مراجعة صورك',
'memories.reviewHint': 'انقر على الصور لاستبعادها من المشاركة.',
'memories.shareCount': 'مشاركة {count} صور',
'memories.immichUrl': 'عنوان خادم Immich',
'memories.immichApiKey': 'مفتاح API',
'memories.testConnection': 'اختبار الاتصال',
'memories.connected': 'متصل',
'memories.disconnected': 'غير متصل',
'memories.connectionSuccess': 'تم الاتصال بـ Immich',
'memories.connectionError': 'تعذر الاتصال بـ Immich',
'memories.saved': 'تم حفظ إعدادات Immich',
'memories.oldest': 'الأقدم أولاً',
'memories.newest': 'الأحدث أولاً',
'memories.allLocations': 'جميع المواقع',
'memories.addPhotos': 'إضافة صور',
'memories.selectPhotos': 'اختيار صور من Immich',
'memories.selectHint': 'انقر على الصور لتحديدها.',
'memories.selected': 'محدد',
'memories.addSelected': 'إضافة {count} صور',
'memories.alreadyAdded': 'تمت الإضافة',
'memories.private': 'خاص',
'memories.stopSharing': 'إيقاف المشاركة',
'memories.tripDates': 'تواريخ الرحلة',
'memories.allPhotos': 'جميع الصور',
'memories.confirmShareTitle': 'مشاركة مع أعضاء الرحلة؟',
'memories.confirmShareHint': '{count} صور ستكون مرئية لجميع أعضاء هذه الرحلة. يمكنك جعل الصور الفردية خاصة لاحقًا.',
'memories.confirmShareButton': 'مشاركة الصور',
// Collab Addon
'collab.tabs.chat': 'الدردشة',
'collab.tabs.notes': 'الملاحظات',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+159 -3
View File
@@ -139,6 +139,50 @@ const de: Record<string, string | { name: string; category: 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',
@@ -271,6 +315,7 @@ const de: Record<string, string | { name: string; category: 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',
@@ -374,8 +419,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um TREK nach deinen Wünschen anzupassen.',
'admin.addons.catalog.memories.name': 'Erinnerungen',
'admin.addons.catalog.memories.description': 'Geteilte Fotoalben für jede Reise',
'admin.addons.catalog.packing.name': 'Packliste',
'admin.addons.catalog.packing.description': 'Checklisten zum Kofferpacken für jede Reise',
'admin.addons.catalog.budget.name': 'Budget',
@@ -388,6 +431,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -413,6 +458,19 @@ const de: Record<string, string | { name: string; category: 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',
@@ -475,7 +533,15 @@ const de: Record<string, string | { name: string; category: 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',
@@ -534,6 +600,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -597,6 +667,12 @@ const de: Record<string, string | { name: string; category: 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',
@@ -622,11 +698,17 @@ const de: Record<string, string | { name: string; category: 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',
@@ -725,6 +807,8 @@ const de: Record<string, string | { name: string; category: 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',
@@ -748,6 +832,7 @@ const de: Record<string, string | { name: string; category: 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',
@@ -781,6 +866,9 @@ const de: Record<string, string | { name: string; category: 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',
@@ -834,6 +922,15 @@ const de: Record<string, string | { name: string; category: string }[]> = {
// 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',
@@ -985,7 +1082,27 @@ const de: Record<string, string | { name: string; category: 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',
@@ -1112,6 +1229,45 @@ const de: Record<string, string | { name: string; category: 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',
+158 -3
View File
@@ -139,6 +139,50 @@ const en: Record<string, string | { name: string; category: 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',
@@ -271,6 +315,7 @@ const en: Record<string, string | { name: string; category: 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',
@@ -374,8 +419,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Enable or disable features to customize your TREK experience.',
'admin.addons.catalog.memories.name': 'Memories',
'admin.addons.catalog.memories.description': 'Shared photo albums for each trip',
'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',
@@ -388,6 +431,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -413,6 +458,18 @@ const en: Record<string, string | { name: string; category: 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',
@@ -475,7 +532,15 @@ const en: Record<string, string | { name: string; category: 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',
@@ -534,6 +599,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -597,6 +666,12 @@ const en: Record<string, string | { name: string; category: 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',
@@ -622,11 +697,17 @@ const en: Record<string, string | { name: string; category: 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',
@@ -725,6 +806,8 @@ const en: Record<string, string | { name: string; category: 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',
@@ -744,6 +827,7 @@ const en: Record<string, string | { name: string; category: 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',
@@ -781,6 +865,9 @@ const en: Record<string, string | { name: string; category: 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',
@@ -834,6 +921,15 @@ const en: Record<string, string | { name: string; category: string }[]> = {
// 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}',
@@ -985,7 +1081,27 @@ const en: Record<string, string | { name: string; category: 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',
@@ -1112,6 +1228,45 @@ const en: Record<string, string | { name: string; category: 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',
+156
View File
@@ -140,6 +140,50 @@ const es: Record<string, string> = {
'settings.temperature': 'Unidad de temperatura',
'settings.timeFormat': 'Formato de hora',
'settings.routeCalculation': 'Cálculo de ruta',
'settings.blurBookingCodes': 'Difuminar códigos de reserva',
'settings.notifications': 'Notificaciones',
'settings.notifyTripInvite': 'Invitaciones de viaje',
'settings.notifyBookingChange': 'Cambios en reservas',
'settings.notifyTripReminder': 'Recordatorios de viaje',
'settings.notifyVacayInvite': 'Invitaciones de fusión Vacay',
'settings.notifyPhotosShared': 'Fotos compartidas (Immich)',
'settings.notifyCollabMessage': 'Mensajes de chat (Collab)',
'settings.notifyPackingTagged': 'Lista de equipaje: asignaciones',
'settings.notifyWebhook': 'Notificaciones webhook',
'admin.smtp.title': 'Correo y notificaciones',
'admin.smtp.hint': 'Configuración SMTP para notificaciones por correo. Opcional: URL webhook para Discord, Slack, etc.',
'admin.smtp.testButton': 'Enviar correo de prueba',
'admin.smtp.testSuccess': 'Correo de prueba enviado correctamente',
'admin.smtp.testFailed': 'Error al enviar correo de prueba',
'dayplan.icsTooltip': 'Exportar calendario (ICS)',
'share.linkTitle': 'Enlace público',
'share.linkHint': 'Crea un enlace que cualquiera puede usar para ver este viaje sin iniciar sesión. Solo lectura — no se puede editar.',
'share.createLink': 'Crear enlace',
'share.deleteLink': 'Eliminar enlace',
'share.createError': 'No se pudo crear el enlace',
'common.copy': 'Copiar',
'common.copied': 'Copiado',
'share.permMap': 'Mapa y plan',
'share.permBookings': 'Reservas',
'share.permPacking': 'Equipaje',
'shared.expired': 'Enlace expirado o inválido',
'shared.expiredHint': 'Este enlace de viaje compartido ya no está activo.',
'shared.readOnly': 'Vista de solo lectura',
'shared.tabPlan': 'Plan',
'shared.tabBookings': 'Reservas',
'shared.tabPacking': 'Equipaje',
'shared.tabBudget': 'Presupuesto',
'shared.tabChat': 'Chat',
'shared.days': 'días',
'shared.places': 'lugares',
'shared.other': 'Otro',
'shared.totalBudget': 'Presupuesto total',
'shared.messages': 'mensajes',
'shared.sharedVia': 'Compartido vía',
'shared.confirmed': 'Confirmado',
'shared.pending': 'Pendiente',
'share.permBudget': 'Presupuesto',
'share.permCollab': 'Chat',
'settings.on': 'Activado',
'settings.off': 'Desactivado',
'settings.account': 'Cuenta',
@@ -269,6 +313,7 @@ const es: Record<string, string> = {
'admin.tabs.users': 'Usuarios',
'admin.tabs.categories': 'Categorías',
'admin.tabs.backup': 'Copia de seguridad',
'admin.tabs.audit': 'Registro de auditoría',
'admin.stats.users': 'Usuarios',
'admin.stats.trips': 'Viajes',
'admin.stats.places': 'Lugares',
@@ -393,6 +438,19 @@ const es: Record<string, string> = {
// GitHub
'admin.tabs.github': 'GitHub',
'admin.audit.subtitle': 'Eventos sensibles de seguridad y administración (copias de seguridad, usuarios, MFA, ajustes).',
'admin.audit.empty': 'Aún no hay entradas de auditoría.',
'admin.audit.refresh': 'Actualizar',
'admin.audit.loadMore': 'Cargar más',
'admin.audit.showing': '{count} cargados · {total} en total',
'admin.audit.col.time': 'Fecha y hora',
'admin.audit.col.user': 'Usuario',
'admin.audit.col.action': 'Acción',
'admin.audit.col.resource': 'Recurso',
'admin.audit.col.ip': 'IP',
'admin.audit.col.details': 'Detalles',
'admin.github.title': 'Historial de versiones',
'admin.github.subtitle': 'Últimas novedades de {repo}',
'admin.github.latest': 'Última',
@@ -455,6 +513,14 @@ const es: Record<string, string> = {
'vacay.carriedOver': 'de {year}',
'vacay.blockWeekends': 'Bloquear fines de semana',
'vacay.blockWeekendsHint': 'Impide marcar vacaciones en sábados y domingos',
'vacay.weekendDays': 'Días de fin de semana',
'vacay.mon': 'Lun',
'vacay.tue': 'Mar',
'vacay.wed': 'Mié',
'vacay.thu': 'Jue',
'vacay.fri': 'Vie',
'vacay.sat': 'Sáb',
'vacay.sun': 'Dom',
'vacay.publicHolidays': 'Festivos',
'vacay.publicHolidaysHint': 'Marcar festivos en el calendario',
'vacay.selectCountry': 'Seleccionar país',
@@ -548,6 +614,10 @@ const es: Record<string, string> = {
'atlas.markVisited': 'Marcar como visitado',
'atlas.markVisitedHint': 'Añadir este país a tu lista de visitados',
'atlas.addToBucket': 'Añadir a lista de deseos',
'atlas.addPoi': 'Añadir lugar',
'atlas.bucketNamePlaceholder': 'Nombre (país, ciudad, lugar…)',
'atlas.month': 'Mes',
'atlas.year': 'Año',
'atlas.addToBucketHint': 'Guardar como lugar que quieres visitar',
'atlas.bucketWhen': '¿Cuándo planeas visitarlo?',
@@ -597,14 +667,26 @@ const es: Record<string, string> = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Exportar plan diario como PDF',
'dayplan.pdfError': 'No se pudo exportar el PDF',
'dayplan.cannotReorderTransport': 'Las reservas con hora fija no se pueden reordenar',
'dayplan.confirmRemoveTimeTitle': '¿Eliminar hora?',
'dayplan.confirmRemoveTimeBody': 'Este lugar tiene una hora fija ({time}). Al moverlo se eliminará la hora y se permitirá el orden libre.',
'dayplan.confirmRemoveTimeAction': 'Eliminar hora y mover',
'dayplan.cannotDropOnTimed': 'No se pueden colocar elementos entre entradas con hora fija',
'dayplan.cannotBreakChronology': 'Esto rompería el orden cronológico de los elementos y reservas programados',
// Places Sidebar
'places.addPlace': 'Añadir lugar/actividad',
'places.importGpx': 'Importar GPX',
'places.gpxImported': '{count} lugares importados desde GPX',
'places.gpxError': 'Error al importar GPX',
'places.urlResolved': 'Lugar importado desde URL',
'places.assignToDay': '¿A qué día añadirlo?',
'places.all': 'Todo',
'places.unplanned': 'Sin planificar',
'places.search': 'Buscar lugares...',
'places.allCategories': 'Todas las categorías',
'places.categoriesSelected': 'categorías',
'places.clearFilter': 'Borrar filtro',
'places.count': '{count} lugares',
'places.countSingular': '1 lugar',
'places.allPlanned': 'Todos los lugares están planificados',
@@ -687,6 +769,8 @@ const es: Record<string, string> = {
'reservations.type.tour': 'Tour',
'reservations.type.other': 'Otro',
'reservations.confirm.delete': '¿Seguro que quieres eliminar la reserva "{name}"?',
'reservations.confirm.deleteTitle': '¿Eliminar reserva?',
'reservations.confirm.deleteBody': '« {name} » se eliminará permanentemente.',
'reservations.toast.updated': 'Reserva actualizada',
'reservations.toast.removed': 'Reserva eliminada',
'reservations.toast.fileUploaded': 'Archivo subido',
@@ -706,6 +790,7 @@ const es: Record<string, string> = {
'reservations.pendingSave': 'se guardará…',
'reservations.uploading': 'Subiendo...',
'reservations.attachFile': 'Adjuntar archivo',
'reservations.linkExisting': 'Vincular archivo existente',
'reservations.toast.saveError': 'No se pudo guardar',
'reservations.toast.updateError': 'No se pudo actualizar',
'reservations.toast.deleteError': 'No se pudo eliminar',
@@ -743,6 +828,9 @@ const es: Record<string, string> = {
'budget.paid': 'Pagado',
'budget.open': 'Abrir',
'budget.noMembers': 'No hay miembros asignados',
'budget.settlement': 'Liquidación',
'budget.settlementInfo': 'Haz clic en el avatar de un miembro en una partida del presupuesto para marcarlo en verde — esto significa que ha pagado. La liquidación muestra quién debe cuánto a quién.',
'budget.netBalances': 'Saldos netos',
// Files
'files.title': 'Archivos',
@@ -774,6 +862,15 @@ const es: Record<string, string> = {
// Packing
'packing.title': 'Lista de equipaje',
'packing.empty': 'La lista de equipaje está vacía',
'packing.import': 'Importar',
'packing.importTitle': 'Importar lista de equipaje',
'packing.importHint': 'Un elemento por línea. Categoría y cantidad opcionales separadas por coma, punto y coma o tabulación: Nombre, Categoría, Cantidad',
'packing.importPlaceholder': 'Cepillo de dientes\nProtector solar, Higiene\nCamisetas, Ropa, 5\nPasaporte, Documentos',
'packing.importCsv': 'Cargar CSV/TXT',
'packing.importAction': 'Importar {count}',
'packing.importSuccess': '{count} elementos importados',
'packing.importError': 'Error al importar',
'packing.importEmpty': 'Sin elementos para importar',
'packing.progress': '{packed} de {total} preparados ({percent}%)',
'packing.clearChecked': 'Eliminar {count} marcados',
'packing.clearCheckedShort': 'Eliminar {count}',
@@ -925,7 +1022,27 @@ const es: Record<string, string> = {
'backup.auto.enable': 'Activar copia automática',
'backup.auto.enableHint': 'Se crearán copias automáticamente según la frecuencia elegida',
'backup.auto.interval': 'Intervalo',
'backup.auto.hour': 'Ejecutar a la hora',
'backup.auto.hourHint': 'Hora local del servidor (formato {format})',
'backup.auto.dayOfWeek': 'Día de la semana',
'backup.auto.dayOfMonth': 'Día del mes',
'backup.auto.dayOfMonthHint': 'Limitado a 128 para compatibilidad con todos los meses',
'backup.auto.scheduleSummary': 'Programación',
'backup.auto.summaryDaily': 'Todos los días a las {hour}:00',
'backup.auto.summaryWeekly': 'Cada {day} a las {hour}:00',
'backup.auto.summaryMonthly': 'El día {day} de cada mes a las {hour}:00',
'backup.auto.envLocked': 'Docker',
'backup.auto.envLockedHint': 'La copia automática está configurada mediante variables de entorno Docker. Para cambiar estos ajustes, actualiza tu docker-compose.yml y reinicia el contenedor.',
'backup.auto.copyEnv': 'Copiar variables de entorno Docker',
'backup.auto.envCopied': 'Variables de entorno Docker copiadas al portapapeles',
'backup.auto.keepLabel': 'Eliminar copias antiguas después de',
'backup.dow.sunday': 'Dom',
'backup.dow.monday': 'Lun',
'backup.dow.tuesday': 'Mar',
'backup.dow.wednesday': 'Mié',
'backup.dow.thursday': 'Jue',
'backup.dow.friday': 'Vie',
'backup.dow.saturday': 'Sáb',
'backup.interval.hourly': 'Cada hora',
'backup.interval.daily': 'Diaria',
'backup.interval.weekly': 'Semanal',
@@ -1065,6 +1182,45 @@ const es: Record<string, string> = {
'day.editAccommodation': 'Editar alojamiento',
'day.reservations': 'Reservas',
// Memories / Immich
'memories.title': 'Fotos',
'memories.notConnected': 'Immich no conectado',
'memories.notConnectedHint': 'Conecta tu instancia de Immich en Ajustes para ver tus fotos de viaje aquí.',
'memories.noDates': 'Añade fechas a tu viaje para cargar fotos.',
'memories.noPhotos': 'No se encontraron fotos',
'memories.noPhotosHint': 'No se encontraron fotos en Immich para el rango de fechas de este viaje.',
'memories.photosFound': 'fotos',
'memories.fromOthers': 'de otros',
'memories.sharePhotos': 'Compartir fotos',
'memories.sharing': 'Compartiendo',
'memories.reviewTitle': 'Revisar tus fotos',
'memories.reviewHint': 'Haz clic en las fotos para excluirlas de compartir.',
'memories.shareCount': 'Compartir {count} fotos',
'memories.immichUrl': 'URL del servidor Immich',
'memories.immichApiKey': 'Clave API',
'memories.testConnection': 'Probar conexión',
'memories.connected': 'Conectado',
'memories.disconnected': 'No conectado',
'memories.connectionSuccess': 'Conectado a Immich',
'memories.connectionError': 'No se pudo conectar a Immich',
'memories.saved': 'Configuración de Immich guardada',
'memories.oldest': 'Más antiguas',
'memories.newest': 'Más recientes',
'memories.allLocations': 'Todas las ubicaciones',
'memories.addPhotos': 'Añadir fotos',
'memories.selectPhotos': 'Seleccionar fotos de Immich',
'memories.selectHint': 'Toca las fotos para seleccionarlas.',
'memories.selected': 'seleccionado(s)',
'memories.addSelected': 'Añadir {count} fotos',
'memories.alreadyAdded': 'Añadido',
'memories.private': 'Privado',
'memories.stopSharing': 'Dejar de compartir',
'memories.tripDates': 'Fechas del viaje',
'memories.allPhotos': 'Todas las fotos',
'memories.confirmShareTitle': '¿Compartir con los miembros del viaje?',
'memories.confirmShareHint': '{count} fotos serán visibles para todos los miembros de este viaje. Puedes hacer fotos individuales privadas más tarde.',
'memories.confirmShareButton': 'Compartir fotos',
// Collab Addon
'collab.tabs.chat': 'Mensajes',
'collab.tabs.notes': 'Notas',
+224 -68
View File
@@ -5,13 +5,13 @@ const fr: Record<string, string> = {
'common.delete': 'Supprimer',
'common.edit': 'Modifier',
'common.add': 'Ajouter',
'common.loading': 'Chargement...',
'common.loading': 'Chargement',
'common.error': 'Erreur',
'common.back': 'Retour',
'common.all': 'Tout',
'common.close': 'Fermer',
'common.open': 'Ouvrir',
'common.upload': 'Téléverser',
'common.upload': 'Importer',
'common.search': 'Rechercher',
'common.confirm': 'Confirmer',
'common.ok': 'OK',
@@ -24,10 +24,10 @@ const fr: Record<string, string> = {
'common.name': 'Nom',
'common.email': 'E-mail',
'common.password': 'Mot de passe',
'common.saving': 'Enregistrement...',
'common.saving': 'Enregistrement',
'common.update': 'Mettre à jour',
'common.change': 'Modifier',
'common.uploading': 'Téléversement…',
'common.uploading': 'Import en cours…',
'common.backToPlanning': 'Retour à la planification',
'common.reset': 'Réinitialiser',
@@ -44,7 +44,7 @@ const fr: Record<string, string> = {
// Dashboard
'dashboard.title': 'Mes voyages',
'dashboard.subtitle.loading': 'Chargement des voyages...',
'dashboard.subtitle.loading': 'Chargement des voyages',
'dashboard.subtitle.trips': '{count} voyages ({archived} archivés)',
'dashboard.subtitle.empty': 'Commencez votre premier voyage',
'dashboard.subtitle.activeOne': '{count} voyage actif',
@@ -54,8 +54,8 @@ const fr: Record<string, string> = {
'dashboard.gridView': 'Vue en grille',
'dashboard.listView': 'Vue en liste',
'dashboard.currency': 'Devise',
'dashboard.timezone': 'Fuseaux horaires',
'dashboard.localTime': 'Local',
'dashboard.timezone': 'Fuseau horaire',
'dashboard.localTime': 'Heure locale',
'dashboard.timezoneCustomTitle': 'Fuseau horaire personnalisé',
'dashboard.timezoneCustomLabelPlaceholder': 'Libellé (facultatif)',
'dashboard.timezoneCustomTzPlaceholder': 'ex. America/New_York',
@@ -105,7 +105,7 @@ const fr: Record<string, string> = {
'dashboard.addMembers': 'Compagnons de voyage',
'dashboard.addMember': 'Ajouter un membre',
'dashboard.coverSaved': 'Image de couverture enregistrée',
'dashboard.coverUploadError': 'Échec du téléversement',
'dashboard.coverUploadError': 'Échec de l\'import',
'dashboard.coverRemoveError': 'Échec de la suppression',
'dashboard.titleRequired': 'Le titre est obligatoire',
'dashboard.endDateError': 'La date de fin doit être postérieure à la date de début',
@@ -115,7 +115,7 @@ const fr: Record<string, string> = {
'settings.subtitle': 'Configurez vos paramètres personnels',
'settings.map': 'Carte',
'settings.mapTemplate': 'Modèle de carte',
'settings.mapTemplatePlaceholder.select': 'Sélectionner un modèle...',
'settings.mapTemplatePlaceholder.select': 'Sélectionner un modèle',
'settings.mapDefaultHint': 'Laissez vide pour OpenStreetMap (par défaut)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'Modèle d\'URL pour les tuiles de carte',
@@ -127,7 +127,7 @@ const fr: Record<string, string> = {
'settings.mapsKeyHint': 'Pour la recherche de lieux. Nécessite l\'API Places (New). Obtenez-la sur console.cloud.google.com',
'settings.weatherKey': 'Clé API OpenWeatherMap',
'settings.weatherKeyHint': 'Pour les données météo. Gratuit sur openweathermap.org/api',
'settings.keyPlaceholder': 'Saisir la clé...',
'settings.keyPlaceholder': 'Saisir la clé',
'settings.configured': 'Configuré',
'settings.saveKeys': 'Enregistrer les clés',
'settings.display': 'Affichage',
@@ -139,6 +139,50 @@ const fr: Record<string, string> = {
'settings.temperature': 'Unité de température',
'settings.timeFormat': 'Format de l\'heure',
'settings.routeCalculation': 'Calcul d\'itinéraire',
'settings.blurBookingCodes': 'Masquer les codes de réservation',
'settings.notifications': 'Notifications',
'settings.notifyTripInvite': 'Invitations de voyage',
'settings.notifyBookingChange': 'Modifications de réservation',
'settings.notifyTripReminder': 'Rappels de voyage',
'settings.notifyVacayInvite': 'Invitations de fusion Vacay',
'settings.notifyPhotosShared': 'Photos partagées (Immich)',
'settings.notifyCollabMessage': 'Messages de chat (Collab)',
'settings.notifyPackingTagged': 'Liste de bagages : attributions',
'settings.notifyWebhook': 'Notifications webhook',
'admin.smtp.title': 'E-mail et notifications',
'admin.smtp.hint': 'Configuration SMTP pour les notifications par e-mail. Optionnel : URL webhook pour Discord, Slack, etc.',
'admin.smtp.testButton': 'Envoyer un e-mail de test',
'admin.smtp.testSuccess': 'E-mail de test envoyé avec succès',
'admin.smtp.testFailed': 'Échec de l\'e-mail de test',
'dayplan.icsTooltip': 'Exporter le calendrier (ICS)',
'share.linkTitle': 'Lien public',
'share.linkHint': 'Créez un lien que n\'importe qui peut utiliser pour consulter ce voyage sans se connecter. Lecture seule — aucune modification possible.',
'share.createLink': 'Créer un lien',
'share.deleteLink': 'Supprimer le lien',
'share.createError': 'Impossible de créer le lien',
'common.copy': 'Copier',
'common.copied': 'Copié',
'share.permMap': 'Carte et plan',
'share.permBookings': 'Réservations',
'share.permPacking': 'Bagages',
'shared.expired': 'Lien expiré ou invalide',
'shared.expiredHint': 'Ce lien de partage n\'est plus actif.',
'shared.readOnly': 'Vue en lecture seule',
'shared.tabPlan': 'Plan',
'shared.tabBookings': 'Réservations',
'shared.tabPacking': 'Bagages',
'shared.tabBudget': 'Budget',
'shared.tabChat': 'Chat',
'shared.days': 'jours',
'shared.places': 'lieux',
'shared.other': 'Autre',
'shared.totalBudget': 'Budget total',
'shared.messages': 'messages',
'shared.sharedVia': 'Partagé via',
'shared.confirmed': 'Confirmé',
'shared.pending': 'En attente',
'share.permBudget': 'Budget',
'share.permCollab': 'Chat',
'settings.on': 'Activé',
'settings.off': 'Désactivé',
'settings.account': 'Compte',
@@ -186,15 +230,15 @@ const fr: Record<string, string> = {
'settings.toast.keysSaved': 'Clés API enregistrées',
'settings.toast.displaySaved': 'Paramètres d\'affichage enregistrés',
'settings.toast.profileSaved': 'Profil enregistré',
'settings.uploadAvatar': 'Téléverser une photo de profil',
'settings.uploadAvatar': 'Importer une photo de profil',
'settings.removeAvatar': 'Supprimer la photo de profil',
'settings.avatarUploaded': 'Photo de profil mise à jour',
'settings.avatarRemoved': 'Photo de profil supprimée',
'settings.avatarError': 'Échec du téléversement',
'settings.avatarError': 'Échec de l\'import',
// Login
'login.error': 'Échec de la connexion. Veuillez vérifier vos identifiants.',
'login.tagline': 'Vos voyages.\nVotre plan.',
'login.tagline': 'Vos voyages.\nVotre organisation.',
'login.description': 'Planifiez vos voyages en collaboration avec des cartes interactives, des budgets et la synchronisation en temps réel.',
'login.features.maps': 'Cartes interactives',
'login.features.mapsDesc': 'Google Places, itinéraires et regroupement',
@@ -209,7 +253,7 @@ const fr: Record<string, string> = {
'login.features.bookings': 'Réservations',
'login.features.bookingsDesc': 'Vols, hôtels, restaurants et plus',
'login.features.files': 'Documents',
'login.features.filesDesc': 'Téléversez et gérez vos documents',
'login.features.filesDesc': 'Importez et gérez vos documents',
'login.features.routes': 'Itinéraires intelligents',
'login.features.routesDesc': 'Optimisation automatique et export Google Maps',
'login.selfHosted': 'Auto-hébergé · Open Source · Vos données restent les vôtres',
@@ -260,7 +304,7 @@ const fr: Record<string, string> = {
'register.minChars': 'Min. 6 caractères',
'register.confirmPassword': 'Confirmer le mot de passe',
'register.repeatPassword': 'Répéter le mot de passe',
'register.registering': 'Inscription en cours...',
'register.registering': 'Inscription en cours',
'register.register': 'S\'inscrire',
'register.hasAccount': 'Vous avez déjà un compte ?',
'register.signIn': 'Se connecter',
@@ -343,7 +387,7 @@ const fr: Record<string, string> = {
// File Types
'admin.fileTypes': 'Types de fichiers autorisés',
'admin.fileTypesHint': 'Configurez les types de fichiers que les utilisateurs peuvent téléverser.',
'admin.fileTypesHint': 'Configurez les types de fichiers que les utilisateurs peuvent importer.',
'admin.fileTypesFormat': 'Extensions séparées par des virgules (ex. jpg,png,pdf,doc). Utilisez * pour autoriser tous les types.',
'admin.fileTypesSaved': 'Paramètres des types de fichiers enregistrés',
@@ -381,11 +425,11 @@ const fr: Record<string, string> = {
'admin.addons.catalog.budget.description': 'Suivez les dépenses et planifiez votre budget de voyage',
'admin.addons.catalog.documents.name': 'Documents',
'admin.addons.catalog.documents.description': 'Stockez et gérez vos documents de voyage',
'admin.addons.catalog.vacay.name': 'Vacay',
'admin.addons.catalog.vacay.name': 'Vacances',
'admin.addons.catalog.vacay.description': 'Planificateur de vacances personnel avec vue calendrier',
'admin.addons.catalog.atlas.name': 'Atlas',
'admin.addons.catalog.atlas.description': 'Carte du monde avec pays visités et statistiques de voyage',
'admin.addons.catalog.collab.name': 'Collab',
'admin.addons.catalog.collab.name': 'Collaboration',
'admin.addons.catalog.collab.description': 'Notes en temps réel, sondages et chat pour la planification de voyage',
'admin.addons.subtitleBefore': 'Activez ou désactivez des fonctionnalités pour personnaliser votre expérience ',
'admin.addons.subtitleAfter': '.',
@@ -408,7 +452,21 @@ const fr: Record<string, string> = {
'admin.weather.climateDesc': 'Moyennes des 85 dernières années pour les jours au-delà des prévisions de 16 jours',
'admin.weather.requests': '10 000 requêtes / jour',
'admin.weather.requestsDesc': 'Gratuit, aucune clé API requise',
'admin.weather.locationHint': 'La météo est basée sur le premier lieu avec des coordonnées de chaque jour. Si aucun lieu n\'est assigné à un jour, un lieu de la liste est utilisé comme référence.',
'admin.weather.locationHint': 'La météo est basée sur le premier lieu avec des coordonnées de chaque jour. Si aucun lieu n\'est attribué à un jour, un lieu de la liste est utilisé comme référence.',
'admin.tabs.audit': 'Journal d\'audit',
'admin.audit.subtitle': 'Événements sensibles de sécurité et d\'administration (sauvegardes, utilisateurs, 2FA, paramètres).',
'admin.audit.empty': 'Aucune entrée d\'audit.',
'admin.audit.refresh': 'Actualiser',
'admin.audit.loadMore': 'Charger plus',
'admin.audit.showing': '{count} chargées · {total} au total',
'admin.audit.col.time': 'Heure',
'admin.audit.col.user': 'Utilisateur',
'admin.audit.col.action': 'Action',
'admin.audit.col.resource': 'Ressource',
'admin.audit.col.ip': 'IP',
'admin.audit.col.details': 'Détails',
// GitHub
'admin.tabs.github': 'GitHub',
@@ -419,8 +477,8 @@ const fr: Record<string, string> = {
'admin.github.showDetails': 'Afficher les détails',
'admin.github.hideDetails': 'Masquer les détails',
'admin.github.loadMore': 'Charger plus',
'admin.github.loading': 'Chargement...',
'admin.github.support': 'Aide à continuer le développement de TREK',
'admin.github.loading': 'Chargement',
'admin.github.support': 'Aidez à poursuivre le développement de TREK',
'admin.github.error': 'Impossible de charger les versions',
'admin.github.by': 'par',
@@ -430,7 +488,7 @@ const fr: Record<string, string> = {
'admin.update.install': 'Installer la mise à jour',
'admin.update.confirmTitle': 'Installer la mise à jour ?',
'admin.update.confirmText': 'TREK sera mis à jour de {current} vers {version}. Le serveur redémarrera automatiquement ensuite.',
'admin.update.dataInfo': 'Toutes vos données (voyages, utilisateurs, clés API, téléversements, Vacay, Atlas, budgets) seront préservées.',
'admin.update.dataInfo': 'Toutes vos données (voyages, utilisateurs, clés API, importations, Vacances, Atlas, budgets) seront préservées.',
'admin.update.warning': 'L\'application sera brièvement indisponible pendant le redémarrage.',
'admin.update.confirm': 'Mettre à jour maintenant',
'admin.update.installing': 'Mise à jour…',
@@ -443,7 +501,7 @@ const fr: Record<string, string> = {
'admin.update.reloadHint': 'Veuillez recharger la page dans quelques secondes.',
// Vacay addon
'vacay.subtitle': 'Planifiez et gérez vos jours de congé',
'vacay.subtitle': 'Planifiez et gérez vos jours de congés',
'vacay.settings': 'Paramètres',
'vacay.year': 'Année',
'vacay.addYear': 'Ajouter une année',
@@ -473,6 +531,14 @@ const fr: Record<string, string> = {
'vacay.used': 'Utilisés',
'vacay.remaining': 'Restants',
'vacay.carriedOver': 'de {year}',
'vacay.weekendDays': 'Jours de week-end',
'vacay.mon': 'Lun',
'vacay.tue': 'Mar',
'vacay.wed': 'Mer',
'vacay.thu': 'Jeu',
'vacay.fri': 'Ven',
'vacay.sat': 'Sam',
'vacay.sun': 'Dim',
'vacay.blockWeekends': 'Bloquer les week-ends',
'vacay.blockWeekendsHint': 'Empêcher les entrées de vacances les samedis et dimanches',
'vacay.publicHolidays': 'Jours fériés',
@@ -490,11 +556,11 @@ const fr: Record<string, string> = {
'vacay.shareEmailPlaceholder': 'E-mail de l\'utilisateur TREK',
'vacay.shareSuccess': 'Plan partagé avec succès',
'vacay.shareError': 'Impossible de partager le plan',
'vacay.dissolve': 'Dissoudre la fusion',
'vacay.dissolve': 'Séparer les calendriers',
'vacay.dissolveHint': 'Séparer à nouveau les calendriers. Vos entrées seront conservées.',
'vacay.dissolveAction': 'Dissoudre',
'vacay.dissolved': 'Calendrier séparé',
'vacay.fusedWith': 'Fusionné avec',
'vacay.fusedWith': 'Partagé avec',
'vacay.you': 'vous',
'vacay.noData': 'Aucune donnée',
'vacay.changeColor': 'Changer la couleur',
@@ -569,6 +635,10 @@ const fr: Record<string, string> = {
'atlas.markVisited': 'Marquer comme visité',
'atlas.markVisitedHint': 'Ajouter ce pays à votre liste de visités',
'atlas.addToBucket': 'Ajouter à la bucket list',
'atlas.addPoi': 'Ajouter un lieu',
'atlas.bucketNamePlaceholder': 'Nom (pays, ville, lieu…)',
'atlas.month': 'Mois',
'atlas.year': 'Année',
'atlas.addToBucketHint': 'Sauvegarder comme lieu à visiter',
'atlas.bucketWhen': 'Quand prévoyez-vous d\'y aller ?',
@@ -580,14 +650,14 @@ const fr: Record<string, string> = {
'trip.tabs.packingShort': 'Bagages',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Fichiers',
'trip.loading': 'Chargement du voyage...',
'trip.loading': 'Chargement du voyage',
'trip.mobilePlan': 'Plan',
'trip.mobilePlaces': 'Lieux',
'trip.toast.placeUpdated': 'Lieu mis à jour',
'trip.toast.placeAdded': 'Lieu ajouté',
'trip.toast.placeDeleted': 'Lieu supprimé',
'trip.toast.selectDay': 'Veuillez d\'abord sélectionner un jour',
'trip.toast.assignedToDay': 'Lieu assigné au jour',
'trip.toast.assignedToDay': 'Lieu attribué au planning',
'trip.toast.reorderError': 'Échec de la réorganisation',
'trip.toast.reservationUpdated': 'Réservation mise à jour',
'trip.toast.reservationAdded': 'Réservation ajoutée',
@@ -605,7 +675,7 @@ const fr: Record<string, string> = {
'dayplan.totalCost': 'Coût total',
'dayplan.days': 'Jours',
'dayplan.dayN': 'Jour {n}',
'dayplan.calculating': 'Calcul en cours...',
'dayplan.calculating': 'Calcul en cours',
'dayplan.route': 'Itinéraire',
'dayplan.optimize': 'Optimiser',
'dayplan.optimized': 'Itinéraire optimisé',
@@ -618,14 +688,26 @@ const fr: Record<string, string> = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Exporter le plan du jour en PDF',
'dayplan.pdfError': 'Échec de l\'export PDF',
'dayplan.cannotReorderTransport': 'Les réservations avec une heure fixe ne peuvent pas être réorganisées',
'dayplan.confirmRemoveTimeTitle': 'Supprimer l\'heure ?',
'dayplan.confirmRemoveTimeBody': 'Ce lieu a une heure fixe ({time}). Le déplacer supprimera l\'heure et permettra un tri libre.',
'dayplan.confirmRemoveTimeAction': 'Supprimer l\'heure et déplacer',
'dayplan.cannotDropOnTimed': 'Les éléments ne peuvent pas être placés entre des entrées à heure fixe',
'dayplan.cannotBreakChronology': 'Cela briserait l\'ordre chronologique des éléments et réservations planifiés',
// Places Sidebar
'places.addPlace': 'Ajouter un lieu/activité',
'places.importGpx': 'Importer GPX',
'places.gpxImported': '{count} lieux importés depuis GPX',
'places.gpxError': 'L\'import GPX a échoué',
'places.urlResolved': 'Lieu importé depuis l\'URL',
'places.assignToDay': 'Ajouter à quel jour ?',
'places.all': 'Tous',
'places.unplanned': 'Non planifiés',
'places.search': 'Rechercher des lieux...',
'places.search': 'Rechercher des lieux',
'places.allCategories': 'Toutes les catégories',
'places.categoriesSelected': 'catégories',
'places.clearFilter': 'Effacer le filtre',
'places.count': '{count} lieux',
'places.countSingular': '1 lieu',
'places.allPlanned': 'Tous les lieux sont planifiés',
@@ -634,7 +716,7 @@ const fr: Record<string, string> = {
'places.formName': 'Nom',
'places.formNamePlaceholder': 'ex. Tour Eiffel',
'places.formDescription': 'Description',
'places.formDescriptionPlaceholder': 'Brève description...',
'places.formDescriptionPlaceholder': 'Brève description',
'places.formAddress': 'Adresse',
'places.formAddressPlaceholder': 'Rue, ville, pays',
'places.formLat': 'Latitude (ex. 48.8566)',
@@ -648,10 +730,10 @@ const fr: Record<string, string> = {
'places.endTimeBeforeStart': 'L\'heure de fin est antérieure à l\'heure de début',
'places.timeCollision': 'Chevauchement horaire avec :',
'places.formWebsite': 'Site web',
'places.formNotesPlaceholder': 'Notes personnelles...',
'places.formNotesPlaceholder': 'Notes personnelles',
'places.formReservation': 'Réservation',
'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation...',
'places.mapsSearchPlaceholder': 'Rechercher des lieux...',
'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation',
'places.mapsSearchPlaceholder': 'Rechercher des lieux',
'places.mapsSearchError': 'La recherche de lieu a échoué.',
'places.osmHint': 'Recherche via OpenStreetMap (pas de photos, horaires ni notes). Ajoutez une clé API Google dans les paramètres pour plus de détails.',
'places.osmActive': 'Recherche via OpenStreetMap (pas de photos, notes ni horaires). Ajoutez une clé API Google dans les paramètres pour des données enrichies.',
@@ -696,7 +778,7 @@ const fr: Record<string, string> = {
'reservations.time': 'Heure',
'reservations.timeAlt': 'Heure (alternative, ex. 19h30)',
'reservations.notes': 'Notes',
'reservations.notesPlaceholder': 'Notes supplémentaires...',
'reservations.notesPlaceholder': 'Notes supplémentaires',
'reservations.meta.airline': 'Compagnie aérienne',
'reservations.meta.flightNumber': 'N° de vol',
'reservations.meta.from': 'De',
@@ -724,16 +806,18 @@ const fr: Record<string, string> = {
'reservations.type.tour': 'Visite',
'reservations.type.other': 'Autre',
'reservations.confirm.delete': 'Voulez-vous vraiment supprimer la réservation « {name} » ?',
'reservations.confirm.deleteTitle': 'Supprimer la réservation ?',
'reservations.confirm.deleteBody': '« {name} » sera définitivement supprimé.',
'reservations.toast.updated': 'Réservation mise à jour',
'reservations.toast.removed': 'Réservation supprimée',
'reservations.toast.fileUploaded': 'Fichier téléversé',
'reservations.toast.uploadError': 'Échec du téléversement',
'reservations.toast.fileUploaded': 'Fichier importé',
'reservations.toast.uploadError': 'Échec de l\'import',
'reservations.newTitle': 'Nouvelle réservation',
'reservations.bookingType': 'Type de réservation',
'reservations.titleLabel': 'Titre',
'reservations.titlePlaceholder': 'ex. Lufthansa LH123, Hôtel Adlon, ...',
'reservations.titlePlaceholder': 'ex. Lufthansa LH123, Hôtel Adlon, ',
'reservations.locationAddress': 'Lieu / Adresse',
'reservations.locationPlaceholder': 'Adresse, aéroport, hôtel...',
'reservations.locationPlaceholder': 'Adresse, aéroport, hôtel',
'reservations.confirmationCode': 'Code de réservation',
'reservations.confirmationPlaceholder': 'ex. ABC12345',
'reservations.day': 'Jour',
@@ -741,21 +825,22 @@ const fr: Record<string, string> = {
'reservations.place': 'Lieu',
'reservations.noPlace': 'Aucun lieu',
'reservations.pendingSave': 'sera enregistré…',
'reservations.uploading': 'Téléversement...',
'reservations.uploading': 'Importation…',
'reservations.attachFile': 'Joindre un fichier',
'reservations.linkExisting': 'Lier un fichier existant',
'reservations.toast.saveError': 'Échec de l\'enregistrement',
'reservations.toast.updateError': 'Échec de la mise à jour',
'reservations.toast.deleteError': 'Échec de la suppression',
'reservations.confirm.remove': 'Supprimer la réservation pour « {name} » ?',
'reservations.linkAssignment': 'Lier à l\'assignation du jour',
'reservations.pickAssignment': 'Sélectionnez une assignation de votre plan...',
'reservations.linkAssignment': 'Lier à l\'affectation du jour',
'reservations.pickAssignment': 'Sélectionnez une affectation de votre plan',
'reservations.noAssignment': 'Aucun lien (autonome)',
// Budget
'budget.title': 'Budget',
'budget.emptyTitle': 'Aucun budget créé',
'budget.emptyText': 'Créez des catégories et des entrées pour planifier votre budget de voyage',
'budget.emptyPlaceholder': 'Nom de la catégorie...',
'budget.emptyPlaceholder': 'Nom de la catégorie',
'budget.createCategory': 'Créer une catégorie',
'budget.category': 'Catégorie',
'budget.categoryName': 'Nom de la catégorie',
@@ -773,24 +858,27 @@ const fr: Record<string, string> = {
'budget.total': 'Total',
'budget.totalBudget': 'Budget total',
'budget.byCategory': 'Par catégorie',
'budget.editTooltip': 'Cliquer pour modifier',
'budget.editTooltip': 'Cliquez pour modifier',
'budget.confirm.deleteCategory': 'Voulez-vous vraiment supprimer la catégorie « {name} » avec {count} entrées ?',
'budget.deleteCategory': 'Supprimer la catégorie',
'budget.perPerson': 'Par personne',
'budget.paid': 'Payé',
'budget.open': 'Ouvert',
'budget.noMembers': 'Aucun membre assigné',
'budget.settlement': 'Règlement',
'budget.settlementInfo': 'Cliquez sur l\'avatar d\'un membre sur un poste budgétaire pour le marquer en vert — cela signifie qu\'il a payé. Le règlement indique ensuite qui doit combien à qui.',
'budget.netBalances': 'Soldes nets',
// Files
'files.title': 'Fichiers',
'files.count': '{count} fichiers',
'files.countSingular': '1 fichier',
'files.uploaded': '{count} téléversés',
'files.uploadError': 'Échec du téléversement',
'files.uploaded': '{count} importés',
'files.uploadError': 'Échec de l\'import',
'files.dropzone': 'Déposez les fichiers ici',
'files.dropzoneHint': 'ou cliquez pour parcourir',
'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 Mo',
'files.uploading': 'Téléversement...',
'files.uploading': 'Importation…',
'files.filterAll': 'Tous',
'files.filterPdf': 'PDF',
'files.filterImages': 'Images',
@@ -798,7 +886,7 @@ const fr: Record<string, string> = {
'files.filterCollab': 'Notes Collab',
'files.sourceCollab': 'Depuis les notes Collab',
'files.empty': 'Aucun fichier',
'files.emptyHint': 'Téléversez des fichiers pour les joindre à votre voyage',
'files.emptyHint': 'Importez des fichiers pour les joindre à votre voyage',
'files.openTab': 'Ouvrir dans un nouvel onglet',
'files.confirm.delete': 'Voulez-vous vraiment supprimer ce fichier ?',
'files.toast.deleted': 'Fichier supprimé',
@@ -817,22 +905,31 @@ const fr: Record<string, string> = {
'files.assignTitle': 'Assigner le fichier',
'files.assignPlace': 'Lieu',
'files.assignBooking': 'Réservation',
'files.unassigned': 'Non assigné',
'files.unassigned': 'Non attribué',
'files.unlink': 'Supprimer le lien',
'files.toast.trashed': 'Déplacé dans la corbeille',
'files.toast.restored': 'Fichier restauré',
'files.toast.trashEmptied': 'Corbeille vidée',
'files.toast.assigned': 'Fichier assigné',
'files.toast.assigned': 'Fichier attribué',
'files.toast.assignError': 'Échec de l\'assignation',
'files.toast.restoreError': 'Échec de la restauration',
'files.confirm.permanentDelete': 'Supprimer définitivement ce fichier ? Cette action est irréversible.',
'files.confirm.emptyTrash': 'Supprimer définitivement tous les fichiers de la corbeille ? Cette action est irréversible.',
'files.noteLabel': 'Note',
'files.notePlaceholder': 'Ajouter une note...',
'files.notePlaceholder': 'Ajouter une note',
// Packing
'packing.title': 'Liste de bagages',
'packing.empty': 'La liste de bagages est vide',
'packing.import': 'Importer',
'packing.importTitle': 'Importer la liste',
'packing.importHint': 'Un élément par ligne. Catégorie et quantité optionnelles séparées par virgule, point-virgule ou tabulation : Nom, Catégorie, Quantité',
'packing.importPlaceholder': 'Brosse à dents\nCrème solaire, Hygiène\nT-Shirts, Vêtements, 5\nPasseport, Documents',
'packing.importCsv': 'Charger CSV/TXT',
'packing.importAction': 'Importer {count}',
'packing.importSuccess': '{count} éléments importés',
'packing.importError': 'Échec de l\'import',
'packing.importEmpty': 'Aucun élément à importer',
'packing.progress': '{packed} sur {total} emballés ({percent} %)',
'packing.clearChecked': 'Supprimer {count} cochés',
'packing.clearCheckedShort': 'Supprimer {count}',
@@ -840,8 +937,8 @@ const fr: Record<string, string> = {
'packing.suggestionsTitle': 'Ajouter des suggestions',
'packing.allSuggested': 'Toutes les suggestions ajoutées',
'packing.allPacked': 'Tout est emballé !',
'packing.addPlaceholder': 'Ajouter un nouvel article...',
'packing.categoryPlaceholder': 'Catégorie...',
'packing.addPlaceholder': 'Ajouter un nouvel article',
'packing.categoryPlaceholder': 'Catégorie',
'packing.filterAll': 'Tous',
'packing.filterOpen': 'À faire',
'packing.filterDone': 'Fait',
@@ -955,10 +1052,10 @@ const fr: Record<string, string> = {
// Backup (Admin)
'backup.title': 'Sauvegarde des données',
'backup.subtitle': 'Base de données et tous les fichiers téléversés',
'backup.subtitle': 'Base de données et tous les fichiers importés',
'backup.refresh': 'Actualiser',
'backup.upload': 'Téléverser une sauvegarde',
'backup.uploading': 'Téléversement…',
'backup.upload': 'Importer une sauvegarde',
'backup.uploading': 'Importation…',
'backup.create': 'Créer une sauvegarde',
'backup.creating': 'Création…',
'backup.empty': 'Aucune sauvegarde',
@@ -966,14 +1063,14 @@ const fr: Record<string, string> = {
'backup.download': 'Télécharger',
'backup.restore': 'Restaurer',
'backup.confirm.restore': 'Restaurer la sauvegarde « {name} » ?\n\nToutes les données actuelles seront remplacées par la sauvegarde.',
'backup.confirm.uploadRestore': 'Téléverser et restaurer le fichier de sauvegarde « {name} » ?\n\nToutes les données actuelles seront écrasées.',
'backup.confirm.uploadRestore': 'Importer et restaurer le fichier de sauvegarde « {name} » ?\n\nToutes les données actuelles seront écrasées.',
'backup.confirm.delete': 'Supprimer la sauvegarde « {name} » ?',
'backup.toast.loadError': 'Impossible de charger les sauvegardes',
'backup.toast.created': 'Sauvegarde créée avec succès',
'backup.toast.createError': 'Impossible de créer la sauvegarde',
'backup.toast.restored': 'Sauvegarde restaurée. La page va se recharger…',
'backup.toast.restoreError': 'Échec de la restauration',
'backup.toast.uploadError': 'Échec du téléversement',
'backup.toast.uploadError': 'Échec de l\'import',
'backup.toast.deleted': 'Sauvegarde supprimée',
'backup.toast.deleteError': 'Échec de la suppression',
'backup.toast.downloadError': 'Échec du téléchargement',
@@ -984,7 +1081,27 @@ const fr: Record<string, string> = {
'backup.auto.enable': 'Activer la sauvegarde automatique',
'backup.auto.enableHint': 'Les sauvegardes seront créées automatiquement selon le calendrier choisi',
'backup.auto.interval': 'Intervalle',
'backup.auto.hour': 'Exécuter à l\'heure',
'backup.auto.hourHint': 'Heure locale du serveur (format {format})',
'backup.auto.dayOfWeek': 'Jour de la semaine',
'backup.auto.dayOfMonth': 'Jour du mois',
'backup.auto.dayOfMonthHint': 'Limité à 128 pour la compatibilité avec tous les mois',
'backup.auto.scheduleSummary': 'Planification',
'backup.auto.summaryDaily': 'Tous les jours à {hour}h00',
'backup.auto.summaryWeekly': 'Chaque {day} à {hour}h00',
'backup.auto.summaryMonthly': 'Le {day} de chaque mois à {hour}h00',
'backup.auto.envLocked': 'Docker',
'backup.auto.envLockedHint': 'La sauvegarde automatique est configurée via les variables d\'environnement Docker. Pour modifier ces paramètres, mettez à jour votre docker-compose.yml et redémarrez le conteneur.',
'backup.auto.copyEnv': 'Copier les variables d\'env Docker',
'backup.auto.envCopied': 'Variables d\'env Docker copiées dans le presse-papiers',
'backup.auto.keepLabel': 'Supprimer les anciennes sauvegardes après',
'backup.dow.sunday': 'Dim',
'backup.dow.monday': 'Lun',
'backup.dow.tuesday': 'Mar',
'backup.dow.wednesday': 'Mer',
'backup.dow.thursday': 'Jeu',
'backup.dow.friday': 'Ven',
'backup.dow.saturday': 'Sam',
'backup.interval.hourly': 'Toutes les heures',
'backup.interval.daily': 'Quotidien',
'backup.interval.weekly': 'Hebdomadaire',
@@ -999,15 +1116,15 @@ const fr: Record<string, string> = {
// Photos
'photos.allDays': 'Tous les jours',
'photos.noPhotos': 'Aucune photo',
'photos.uploadHint': 'Téléversez vos photos de voyage',
'photos.uploadHint': 'Importez vos photos de voyage',
'photos.clickToSelect': 'ou cliquez pour sélectionner',
'photos.linkPlace': 'Lier au lieu',
'photos.noPlace': 'Aucun lieu',
'photos.uploadN': '{n} photo(s) téléversées',
'photos.uploadN': '{n} photo(s) importée(s)',
// Backup restore modal
'backup.restoreConfirmTitle': 'Restaurer la sauvegarde ?',
'backup.restoreWarning': 'Toutes les données actuelles (voyages, lieux, utilisateurs, téléversements) seront définitivement remplacées par la sauvegarde. Cette action est irréversible.',
'backup.restoreWarning': 'Toutes les données actuelles (voyages, lieux, utilisateurs, importations) seront définitivement remplacées par la sauvegarde. Cette action est irréversible.',
'backup.restoreTip': 'Conseil : créez une sauvegarde de l\'état actuel avant de restaurer.',
'backup.restoreConfirm': 'Oui, restaurer',
@@ -1044,8 +1161,8 @@ const fr: Record<string, string> = {
'planner.placeN': '{n} lieux',
'planner.addNote': 'Ajouter une note',
'planner.noEntries': 'Aucune entrée pour ce jour',
'planner.addPlace': 'Ajouter un lieu/activité',
'planner.addPlaceShort': '+ Ajouter un lieu/activité',
'planner.addPlace': 'Ajouter un lieu ou une activité',
'planner.addPlaceShort': '+ Ajouter un lieu ou une activité',
'planner.resPending': 'Réservation en attente · ',
'planner.resConfirmed': 'Réservation confirmée · ',
'planner.notePlaceholder': 'Note…',
@@ -1075,7 +1192,7 @@ const fr: Record<string, string> = {
'planner.noDays': 'Aucun jour',
'planner.editTripToAddDays': 'Modifiez le voyage pour ajouter des jours',
'planner.dayCount': '{n} jours',
'planner.clickToUnlock': 'Cliquer pour déverrouiller',
'planner.clickToUnlock': 'Cliquez pour déverrouiller',
'planner.keepPosition': 'Maintenir la position lors de l\'optimisation de l\'itinéraire',
'planner.dayDetails': 'Détails du jour',
'planner.dayN': 'Jour {n}',
@@ -1111,8 +1228,47 @@ const fr: Record<string, string> = {
'day.editAccommodation': 'Modifier l\'hébergement',
'day.reservations': 'Réservations',
// Memories / Immich
'memories.title': 'Photos',
'memories.notConnected': 'Immich non connecté',
'memories.notConnectedHint': 'Connectez votre instance Immich dans les paramètres pour voir vos photos de voyage ici.',
'memories.noDates': 'Ajoutez des dates à votre voyage pour charger les photos.',
'memories.noPhotos': 'Aucune photo trouvée',
'memories.noPhotosHint': 'Aucune photo trouvée dans Immich pour la période de ce voyage.',
'memories.photosFound': 'photos',
'memories.fromOthers': 'd\'autres',
'memories.sharePhotos': 'Partager les photos',
'memories.sharing': 'Partagé',
'memories.reviewTitle': 'Vérifier vos photos',
'memories.reviewHint': 'Cliquez sur les photos pour les exclure du partage.',
'memories.shareCount': 'Partager {count} photos',
'memories.immichUrl': 'URL du serveur Immich',
'memories.immichApiKey': 'Clé API',
'memories.testConnection': 'Tester la connexion',
'memories.connected': 'Connecté',
'memories.disconnected': 'Non connecté',
'memories.connectionSuccess': 'Connecté à Immich',
'memories.connectionError': 'Impossible de se connecter à Immich',
'memories.saved': 'Paramètres Immich enregistrés',
'memories.oldest': 'Plus anciennes',
'memories.newest': 'Plus récentes',
'memories.allLocations': 'Tous les lieux',
'memories.addPhotos': 'Ajouter des photos',
'memories.selectPhotos': 'Sélectionner des photos depuis Immich',
'memories.selectHint': 'Appuyez sur les photos pour les sélectionner.',
'memories.selected': 'sélectionné(s)',
'memories.addSelected': 'Ajouter {count} photos',
'memories.alreadyAdded': 'Ajouté',
'memories.private': 'Privé',
'memories.stopSharing': 'Arrêter le partage',
'memories.tripDates': 'Dates du voyage',
'memories.allPhotos': 'Toutes les photos',
'memories.confirmShareTitle': 'Partager avec les membres du voyage ?',
'memories.confirmShareHint': '{count} photos seront visibles par tous les membres de ce voyage. Vous pourrez rendre des photos individuelles privées plus tard.',
'memories.confirmShareButton': 'Partager les photos',
// Collab Addon
'collab.tabs.chat': 'Chat',
'collab.tabs.chat': 'Discussion',
'collab.tabs.notes': 'Notes',
'collab.tabs.polls': 'Sondages',
'collab.whatsNext.title': 'À venir',
@@ -1122,7 +1278,7 @@ const fr: Record<string, string> = {
'collab.whatsNext.until': 'à',
'collab.whatsNext.emptyHint': 'Les activités avec des horaires apparaîtront ici',
'collab.chat.send': 'Envoyer',
'collab.chat.placeholder': 'Écrire un message...',
'collab.chat.placeholder': 'Écrire un message',
'collab.chat.empty': 'Commencez la conversation',
'collab.chat.emptyHint': 'Les messages sont partagés avec tous les membres du voyage',
'collab.chat.emptyDesc': 'Partagez des idées, des plans et des mises à jour avec votre groupe de voyage',
@@ -1139,9 +1295,9 @@ const fr: Record<string, string> = {
'collab.notes.emptyHint': 'Commencez à capturer vos idées et plans',
'collab.notes.all': 'Toutes',
'collab.notes.titlePlaceholder': 'Titre de la note',
'collab.notes.contentPlaceholder': 'Écrivez quelque chose...',
'collab.notes.contentPlaceholder': 'Écrivez quelque chose',
'collab.notes.categoryPlaceholder': 'Catégorie',
'collab.notes.newCategory': 'Nouvelle catégorie...',
'collab.notes.newCategory': 'Nouvelle catégorie',
'collab.notes.category': 'Catégorie',
'collab.notes.noCategory': 'Sans catégorie',
'collab.notes.color': 'Couleur',
@@ -1155,7 +1311,7 @@ const fr: Record<string, string> = {
'collab.notes.categorySettings': 'Gérer les catégories',
'collab.notes.create': 'Créer',
'collab.notes.website': 'Site web',
'collab.notes.websitePlaceholder': 'https://...',
'collab.notes.websitePlaceholder': 'https://',
'collab.notes.attachFiles': 'Joindre des fichiers',
'collab.notes.noCategoriesYet': 'Aucune catégorie',
'collab.notes.emptyDesc': 'Créez une note pour commencer',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+156
View File
@@ -139,6 +139,50 @@ const nl: Record<string, string> = {
'settings.temperature': 'Temperatuureenheid',
'settings.timeFormat': 'Tijdnotatie',
'settings.routeCalculation': 'Routeberekening',
'settings.blurBookingCodes': 'Boekingscodes vervagen',
'settings.notifications': 'Meldingen',
'settings.notifyTripInvite': 'Reisuitnodigingen',
'settings.notifyBookingChange': 'Boekingswijzigingen',
'settings.notifyTripReminder': 'Reisherinneringen',
'settings.notifyVacayInvite': 'Vacay-fusieuitnodigingen',
'settings.notifyPhotosShared': 'Gedeelde foto\'s (Immich)',
'settings.notifyCollabMessage': 'Chatberichten (Collab)',
'settings.notifyPackingTagged': 'Paklijst: toewijzingen',
'settings.notifyWebhook': 'Webhook-meldingen',
'admin.smtp.title': 'E-mail en meldingen',
'admin.smtp.hint': 'SMTP-configuratie voor e-mailmeldingen. Optioneel: Webhook-URL voor Discord, Slack, etc.',
'admin.smtp.testButton': 'Test-e-mail verzenden',
'admin.smtp.testSuccess': 'Test-e-mail succesvol verzonden',
'admin.smtp.testFailed': 'Test-e-mail mislukt',
'dayplan.icsTooltip': 'Kalender exporteren (ICS)',
'share.linkTitle': 'Openbare link',
'share.linkHint': 'Maak een link die iedereen kan gebruiken om deze reis te bekijken zonder in te loggen. Alleen-lezen — bewerken niet mogelijk.',
'share.createLink': 'Link aanmaken',
'share.deleteLink': 'Link verwijderen',
'share.createError': 'Kon link niet aanmaken',
'common.copy': 'Kopiëren',
'common.copied': 'Gekopieerd',
'share.permMap': 'Kaart en plan',
'share.permBookings': 'Boekingen',
'share.permPacking': 'Paklijst',
'shared.expired': 'Link verlopen of ongeldig',
'shared.expiredHint': 'Deze gedeelde reislink is niet meer actief.',
'shared.readOnly': 'Alleen-lezen weergave',
'shared.tabPlan': 'Plan',
'shared.tabBookings': 'Boekingen',
'shared.tabPacking': 'Paklijst',
'shared.tabBudget': 'Budget',
'shared.tabChat': 'Chat',
'shared.days': 'dagen',
'shared.places': 'plaatsen',
'shared.other': 'Overig',
'shared.totalBudget': 'Totaal budget',
'shared.messages': 'berichten',
'shared.sharedVia': 'Gedeeld via',
'shared.confirmed': 'Bevestigd',
'shared.pending': 'In afwachting',
'share.permBudget': 'Budget',
'share.permCollab': 'Chat',
'settings.on': 'Aan',
'settings.off': 'Uit',
'settings.account': 'Account',
@@ -271,6 +315,7 @@ const nl: Record<string, string> = {
'admin.tabs.users': 'Gebruikers',
'admin.tabs.categories': 'Categorieën',
'admin.tabs.backup': 'Back-up',
'admin.tabs.audit': 'Auditlog',
'admin.stats.users': 'Gebruikers',
'admin.stats.trips': 'Reizen',
'admin.stats.places': 'Plaatsen',
@@ -412,6 +457,19 @@ const nl: Record<string, string> = {
// GitHub
'admin.tabs.github': 'GitHub',
'admin.audit.subtitle': 'Beveiligingsgevoelige en beheerdersgebeurtenissen (back-ups, gebruikers, MFA, instellingen).',
'admin.audit.empty': 'Nog geen auditregistraties.',
'admin.audit.refresh': 'Vernieuwen',
'admin.audit.loadMore': 'Meer laden',
'admin.audit.showing': '{count} geladen · {total} totaal',
'admin.audit.col.time': 'Tijd',
'admin.audit.col.user': 'Gebruiker',
'admin.audit.col.action': 'Actie',
'admin.audit.col.resource': 'Bron',
'admin.audit.col.ip': 'IP',
'admin.audit.col.details': 'Details',
'admin.github.title': 'Release-geschiedenis',
'admin.github.subtitle': 'Laatste updates van {repo}',
'admin.github.latest': 'Nieuwste',
@@ -475,6 +533,14 @@ const nl: Record<string, string> = {
'vacay.carriedOver': 'van {year}',
'vacay.blockWeekends': 'Weekenden blokkeren',
'vacay.blockWeekendsHint': 'Voorkom vakantie-invoeren op zaterdag en zondag',
'vacay.weekendDays': 'Weekenddagen',
'vacay.mon': 'Ma',
'vacay.tue': 'Di',
'vacay.wed': 'Wo',
'vacay.thu': 'Do',
'vacay.fri': 'Vr',
'vacay.sat': 'Za',
'vacay.sun': 'Zo',
'vacay.publicHolidays': 'Feestdagen',
'vacay.publicHolidaysHint': 'Markeer feestdagen in de kalender',
'vacay.selectCountry': 'Selecteer land',
@@ -569,6 +635,10 @@ const nl: Record<string, string> = {
'atlas.markVisited': 'Markeren als bezocht',
'atlas.markVisitedHint': 'Dit land toevoegen aan je bezochte lijst',
'atlas.addToBucket': 'Aan bucket list toevoegen',
'atlas.addPoi': 'Plaats toevoegen',
'atlas.bucketNamePlaceholder': 'Naam (land, stad, plek…)',
'atlas.month': 'Maand',
'atlas.year': 'Jaar',
'atlas.addToBucketHint': 'Opslaan als plek die je wilt bezoeken',
'atlas.bucketWhen': 'Wanneer ben je van plan te gaan?',
@@ -618,14 +688,26 @@ const nl: Record<string, string> = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Dagplan exporteren als PDF',
'dayplan.pdfError': 'PDF-export mislukt',
'dayplan.cannotReorderTransport': 'Boekingen met een vast tijdstip kunnen niet worden verplaatst',
'dayplan.confirmRemoveTimeTitle': 'Tijd verwijderen?',
'dayplan.confirmRemoveTimeBody': 'Deze plek heeft een vast tijdstip ({time}). Verplaatsen verwijdert het tijdstip en maakt vrije sortering mogelijk.',
'dayplan.confirmRemoveTimeAction': 'Tijd verwijderen en verplaatsen',
'dayplan.cannotDropOnTimed': 'Items kunnen niet tussen tijdgebonden items worden geplaatst',
'dayplan.cannotBreakChronology': 'Dit zou de chronologische volgorde van geplande items en boekingen doorbreken',
// Places Sidebar
'places.addPlace': 'Plaats/activiteit toevoegen',
'places.importGpx': 'GPX importeren',
'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX',
'places.gpxError': 'GPX-import mislukt',
'places.urlResolved': 'Plaats geïmporteerd van URL',
'places.assignToDay': 'Aan welke dag toevoegen?',
'places.all': 'Alle',
'places.unplanned': 'Ongepland',
'places.search': 'Plaatsen zoeken...',
'places.allCategories': 'Alle categorieën',
'places.categoriesSelected': 'categorieën',
'places.clearFilter': 'Filter wissen',
'places.count': '{count} plaatsen',
'places.countSingular': '1 plaats',
'places.allPlanned': 'Alle plaatsen zijn gepland',
@@ -724,6 +806,8 @@ const nl: Record<string, string> = {
'reservations.type.tour': 'Rondleiding',
'reservations.type.other': 'Overig',
'reservations.confirm.delete': 'Weet je zeker dat je de reservering "{name}" wilt verwijderen?',
'reservations.confirm.deleteTitle': 'Boeking verwijderen?',
'reservations.confirm.deleteBody': '"{name}" wordt permanent verwijderd.',
'reservations.toast.updated': 'Reservering bijgewerkt',
'reservations.toast.removed': 'Reservering verwijderd',
'reservations.toast.fileUploaded': 'Bestand geüpload',
@@ -743,6 +827,7 @@ const nl: Record<string, string> = {
'reservations.pendingSave': 'wordt opgeslagen…',
'reservations.uploading': 'Uploaden...',
'reservations.attachFile': 'Bestand bijvoegen',
'reservations.linkExisting': 'Bestaand bestand koppelen',
'reservations.toast.saveError': 'Opslaan mislukt',
'reservations.toast.updateError': 'Bijwerken mislukt',
'reservations.toast.deleteError': 'Verwijderen mislukt',
@@ -780,6 +865,9 @@ const nl: Record<string, string> = {
'budget.paid': 'Betaald',
'budget.open': 'Open',
'budget.noMembers': 'Geen leden toegewezen',
'budget.settlement': 'Afrekening',
'budget.settlementInfo': 'Klik op de avatar van een lid bij een budgetpost om deze groen te markeren — dit betekent dat diegene heeft betaald. De afrekening toont vervolgens wie wie hoeveel verschuldigd is.',
'budget.netBalances': 'Nettosaldi',
// Files
'files.title': 'Bestanden',
@@ -833,6 +921,15 @@ const nl: Record<string, string> = {
// Packing
'packing.title': 'Paklijst',
'packing.empty': 'Paklijst is leeg',
'packing.import': 'Importeren',
'packing.importTitle': 'Paklijst importeren',
'packing.importHint': 'Eén item per regel. Optioneel categorie en aantal gescheiden door komma, puntkomma of tab: Naam, Categorie, Aantal',
'packing.importPlaceholder': 'Tandenborstel\nZonnebrand, Hygiëne\nT-Shirts, Kleding, 5\nPaspoort, Documenten',
'packing.importCsv': 'CSV/TXT laden',
'packing.importAction': '{count} importeren',
'packing.importSuccess': '{count} items geïmporteerd',
'packing.importError': 'Import mislukt',
'packing.importEmpty': 'Geen items om te importeren',
'packing.progress': '{packed} van {total} ingepakt ({percent}%)',
'packing.clearChecked': '{count} aangevinkte verwijderen',
'packing.clearCheckedShort': '{count} verwijderen',
@@ -984,7 +1081,27 @@ const nl: Record<string, string> = {
'backup.auto.enable': 'Auto-back-up inschakelen',
'backup.auto.enableHint': 'Back-ups worden automatisch aangemaakt volgens het gekozen schema',
'backup.auto.interval': 'Interval',
'backup.auto.hour': 'Uitvoeren om',
'backup.auto.hourHint': 'Lokale servertijd ({format}-notatie)',
'backup.auto.dayOfWeek': 'Dag van de week',
'backup.auto.dayOfMonth': 'Dag van de maand',
'backup.auto.dayOfMonthHint': 'Beperkt tot 128 voor compatibiliteit met alle maanden',
'backup.auto.scheduleSummary': 'Planning',
'backup.auto.summaryDaily': 'Elke dag om {hour}:00',
'backup.auto.summaryWeekly': 'Elke {day} om {hour}:00',
'backup.auto.summaryMonthly': 'Dag {day} van elke maand om {hour}:00',
'backup.auto.envLocked': 'Docker',
'backup.auto.envLockedHint': 'Auto-back-up is geconfigureerd via Docker-omgevingsvariabelen. Pas je docker-compose.yml aan en herstart de container om deze instellingen te wijzigen.',
'backup.auto.copyEnv': 'Docker-omgevingsvariabelen kopiëren',
'backup.auto.envCopied': 'Docker-omgevingsvariabelen gekopieerd naar klembord',
'backup.auto.keepLabel': 'Oude back-ups verwijderen na',
'backup.dow.sunday': 'Zo',
'backup.dow.monday': 'Ma',
'backup.dow.tuesday': 'Di',
'backup.dow.wednesday': 'Wo',
'backup.dow.thursday': 'Do',
'backup.dow.friday': 'Vr',
'backup.dow.saturday': 'Za',
'backup.interval.hourly': 'Elk uur',
'backup.interval.daily': 'Dagelijks',
'backup.interval.weekly': 'Wekelijks',
@@ -1111,6 +1228,45 @@ const nl: Record<string, string> = {
'day.editAccommodation': 'Accommodatie bewerken',
'day.reservations': 'Reserveringen',
// Memories / Immich
'memories.title': 'Foto\'s',
'memories.notConnected': 'Immich niet verbonden',
'memories.notConnectedHint': 'Verbind je Immich-instantie in Instellingen om je reisfoto\'s hier te zien.',
'memories.noDates': 'Voeg data toe aan je reis om foto\'s te laden.',
'memories.noPhotos': 'Geen foto\'s gevonden',
'memories.noPhotosHint': 'Geen foto\'s gevonden in Immich voor de datumreeks van deze reis.',
'memories.photosFound': 'foto\'s',
'memories.fromOthers': 'van anderen',
'memories.sharePhotos': 'Foto\'s delen',
'memories.sharing': 'Wordt gedeeld',
'memories.reviewTitle': 'Je foto\'s bekijken',
'memories.reviewHint': 'Klik op foto\'s om ze uit te sluiten van delen.',
'memories.shareCount': '{count} foto\'s delen',
'memories.immichUrl': 'Immich Server URL',
'memories.immichApiKey': 'API-sleutel',
'memories.testConnection': 'Verbinding testen',
'memories.connected': 'Verbonden',
'memories.disconnected': 'Niet verbonden',
'memories.connectionSuccess': 'Verbonden met Immich',
'memories.connectionError': 'Kon niet verbinden met Immich',
'memories.saved': 'Immich-instellingen opgeslagen',
'memories.oldest': 'Oudste eerst',
'memories.newest': 'Nieuwste eerst',
'memories.allLocations': 'Alle locaties',
'memories.addPhotos': 'Foto\'s toevoegen',
'memories.selectPhotos': 'Selecteer foto\'s uit Immich',
'memories.selectHint': 'Tik op foto\'s om ze te selecteren.',
'memories.selected': 'geselecteerd',
'memories.addSelected': '{count} foto\'s toevoegen',
'memories.alreadyAdded': 'Toegevoegd',
'memories.private': 'Privé',
'memories.stopSharing': 'Delen stoppen',
'memories.tripDates': 'Reisdata',
'memories.allPhotos': 'Alle foto\'s',
'memories.confirmShareTitle': 'Delen met reisgenoten?',
'memories.confirmShareHint': '{count} foto\'s worden zichtbaar voor alle leden van deze reis. Je kunt individuele foto\'s later privé maken.',
'memories.confirmShareButton': 'Foto\'s delen',
// Collab Addon
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notities',
+156
View File
@@ -139,6 +139,50 @@ const ru: Record<string, string> = {
'settings.temperature': 'Единица температуры',
'settings.timeFormat': 'Формат времени',
'settings.routeCalculation': 'Расчёт маршрута',
'settings.blurBookingCodes': 'Скрыть коды бронирования',
'settings.notifications': 'Уведомления',
'settings.notifyTripInvite': 'Приглашения в поездку',
'settings.notifyBookingChange': 'Изменения бронирований',
'settings.notifyTripReminder': 'Напоминания о поездке',
'settings.notifyVacayInvite': 'Приглашения слияния Vacay',
'settings.notifyPhotosShared': 'Общие фото (Immich)',
'settings.notifyCollabMessage': 'Сообщения чата (Collab)',
'settings.notifyPackingTagged': 'Список вещей: назначения',
'settings.notifyWebhook': 'Webhook-уведомления',
'admin.smtp.title': 'Почта и уведомления',
'admin.smtp.hint': 'Настройка SMTP для уведомлений по почте. Необязательно: Webhook URL для Discord, Slack и т.д.',
'admin.smtp.testButton': 'Отправить тестовое письмо',
'admin.smtp.testSuccess': 'Тестовое письмо успешно отправлено',
'admin.smtp.testFailed': 'Ошибка отправки тестового письма',
'dayplan.icsTooltip': 'Экспорт календаря (ICS)',
'share.linkTitle': 'Публичная ссылка',
'share.linkHint': 'Создайте ссылку, по которой любой сможет просмотреть эту поездку без входа в систему. Только чтение — редактирование невозможно.',
'share.createLink': 'Создать ссылку',
'share.deleteLink': 'Удалить ссылку',
'share.createError': 'Не удалось создать ссылку',
'common.copy': 'Копировать',
'common.copied': 'Скопировано',
'share.permMap': 'Карта и план',
'share.permBookings': 'Бронирования',
'share.permPacking': 'Вещи',
'shared.expired': 'Ссылка устарела или недействительна',
'shared.expiredHint': 'Эта ссылка на поездку больше не активна.',
'shared.readOnly': 'Режим только для чтения',
'shared.tabPlan': 'План',
'shared.tabBookings': 'Бронирования',
'shared.tabPacking': 'Багаж',
'shared.tabBudget': 'Бюджет',
'shared.tabChat': 'Чат',
'shared.days': 'дней',
'shared.places': 'мест',
'shared.other': 'Прочее',
'shared.totalBudget': 'Общий бюджет',
'shared.messages': 'сообщений',
'shared.sharedVia': 'Поделено через',
'shared.confirmed': 'Подтверждено',
'shared.pending': 'Ожидает',
'share.permBudget': 'Бюджет',
'share.permCollab': 'Чат',
'settings.on': 'Вкл.',
'settings.off': 'Выкл.',
'settings.account': 'Аккаунт',
@@ -271,6 +315,7 @@ const ru: Record<string, string> = {
'admin.tabs.users': 'Пользователи',
'admin.tabs.categories': 'Категории',
'admin.tabs.backup': 'Резервная копия',
'admin.tabs.audit': 'Журнал аудита',
'admin.stats.users': 'Пользователи',
'admin.stats.trips': 'Поездки',
'admin.stats.places': 'Места',
@@ -412,6 +457,19 @@ const ru: Record<string, string> = {
// GitHub
'admin.tabs.github': 'GitHub',
'admin.audit.subtitle': 'События, связанные с безопасностью и администрированием (резервные копии, пользователи, MFA, настройки).',
'admin.audit.empty': 'Записей аудита пока нет.',
'admin.audit.refresh': 'Обновить',
'admin.audit.loadMore': 'Загрузить ещё',
'admin.audit.showing': 'Загружено: {count} · всего {total}',
'admin.audit.col.time': 'Время',
'admin.audit.col.user': 'Пользователь',
'admin.audit.col.action': 'Действие',
'admin.audit.col.resource': 'Объект',
'admin.audit.col.ip': 'IP',
'admin.audit.col.details': 'Подробности',
'admin.github.title': 'История релизов',
'admin.github.subtitle': 'Последние обновления из {repo}',
'admin.github.latest': 'Последний',
@@ -475,6 +533,14 @@ const ru: Record<string, string> = {
'vacay.carriedOver': 'из {year}',
'vacay.blockWeekends': 'Блокировать выходные',
'vacay.blockWeekendsHint': 'Запретить записи об отпуске в субботу и воскресенье',
'vacay.weekendDays': 'Выходные дни',
'vacay.mon': 'Пн',
'vacay.tue': 'Вт',
'vacay.wed': 'Ср',
'vacay.thu': 'Чт',
'vacay.fri': 'Пт',
'vacay.sat': 'Сб',
'vacay.sun': 'Вс',
'vacay.publicHolidays': 'Государственные праздники',
'vacay.publicHolidaysHint': 'Отмечать государственные праздники в календаре',
'vacay.selectCountry': 'Выберите страну',
@@ -569,6 +635,10 @@ const ru: Record<string, string> = {
'atlas.markVisited': 'Отметить как посещённую',
'atlas.markVisitedHint': 'Добавить эту страну в список посещённых',
'atlas.addToBucket': 'В список желаний',
'atlas.addPoi': 'Добавить место',
'atlas.bucketNamePlaceholder': 'Название (страна, город, место…)',
'atlas.month': 'Месяц',
'atlas.year': 'Год',
'atlas.addToBucketHint': 'Сохранить как место для посещения',
'atlas.bucketWhen': 'Когда вы планируете поехать?',
@@ -618,14 +688,26 @@ const ru: Record<string, string> = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Экспортировать план дня в PDF',
'dayplan.pdfError': 'Ошибка экспорта PDF',
'dayplan.cannotReorderTransport': 'Бронирования с фиксированным временем нельзя перемещать',
'dayplan.confirmRemoveTimeTitle': 'Удалить время?',
'dayplan.confirmRemoveTimeBody': 'У этого места фиксированное время ({time}). При перемещении время будет удалено, и станет доступна свободная сортировка.',
'dayplan.confirmRemoveTimeAction': 'Удалить время и переместить',
'dayplan.cannotDropOnTimed': 'Элементы нельзя размещать между записями с фиксированным временем',
'dayplan.cannotBreakChronology': 'Это нарушит хронологический порядок запланированных элементов и бронирований',
// Places Sidebar
'places.addPlace': 'Добавить место/активность',
'places.importGpx': 'Импорт GPX',
'places.gpxImported': '{count} мест импортировано из GPX',
'places.gpxError': 'Ошибка импорта GPX',
'places.urlResolved': 'Место импортировано из URL',
'places.assignToDay': 'Добавить в какой день?',
'places.all': 'Все',
'places.unplanned': 'Незапланированные',
'places.search': 'Поиск мест...',
'places.allCategories': 'Все категории',
'places.categoriesSelected': 'категорий',
'places.clearFilter': 'Сбросить фильтр',
'places.count': '{count} мест',
'places.countSingular': '1 место',
'places.allPlanned': 'Все места запланированы',
@@ -724,6 +806,8 @@ const ru: Record<string, string> = {
'reservations.type.tour': 'Экскурсия',
'reservations.type.other': 'Другое',
'reservations.confirm.delete': 'Вы уверены, что хотите удалить бронирование «{name}»?',
'reservations.confirm.deleteTitle': 'Удалить бронирование?',
'reservations.confirm.deleteBody': '«{name}» будет удалено навсегда.',
'reservations.toast.updated': 'Бронирование обновлено',
'reservations.toast.removed': 'Бронирование удалено',
'reservations.toast.fileUploaded': 'Файл загружен',
@@ -743,6 +827,7 @@ const ru: Record<string, string> = {
'reservations.pendingSave': 'будет сохранено…',
'reservations.uploading': 'Загрузка...',
'reservations.attachFile': 'Прикрепить файл',
'reservations.linkExisting': 'Привязать существующий файл',
'reservations.toast.saveError': 'Ошибка сохранения',
'reservations.toast.updateError': 'Ошибка обновления',
'reservations.toast.deleteError': 'Ошибка удаления',
@@ -780,6 +865,9 @@ const ru: Record<string, string> = {
'budget.paid': 'Оплачено',
'budget.open': 'Не оплачено',
'budget.noMembers': 'Участники не назначены',
'budget.settlement': 'Взаиморасчёт',
'budget.settlementInfo': 'Нажмите на аватар участника в строке бюджета, чтобы отметить его зелёным — это значит, что он заплатил. Взаиморасчёт покажет, кто кому и сколько должен.',
'budget.netBalances': 'Чистые балансы',
// Files
'files.title': 'Файлы',
@@ -833,6 +921,15 @@ const ru: Record<string, string> = {
// Packing
'packing.title': 'Список вещей',
'packing.empty': 'Список вещей пуст',
'packing.import': 'Импорт',
'packing.importTitle': 'Импорт списка вещей',
'packing.importHint': 'Один предмет на строку. Категория и количество — через запятую, точку с запятой или табуляцию: Название, Категория, Количество',
'packing.importPlaceholder': 'Зубная щётка\nСолнцезащитный крем, Гигиена\nФутболки, Одежда, 5\nПаспорт, Документы',
'packing.importCsv': 'Загрузить CSV/TXT',
'packing.importAction': 'Импортировать {count}',
'packing.importSuccess': '{count} предметов импортировано',
'packing.importError': 'Ошибка импорта',
'packing.importEmpty': 'Нет предметов для импорта',
'packing.progress': '{packed} из {total} собрано ({percent}%)',
'packing.clearChecked': 'Удалить {count} отмеченных',
'packing.clearCheckedShort': 'Удалить {count}',
@@ -984,7 +1081,27 @@ const ru: Record<string, string> = {
'backup.auto.enable': 'Включить автокопирование',
'backup.auto.enableHint': 'Резервные копии будут создаваться автоматически по выбранному расписанию',
'backup.auto.interval': 'Интервал',
'backup.auto.hour': 'Запуск в час',
'backup.auto.hourHint': 'Местное время сервера (формат {format})',
'backup.auto.dayOfWeek': 'День недели',
'backup.auto.dayOfMonth': 'День месяца',
'backup.auto.dayOfMonthHint': 'Ограничено 1–28 для совместимости со всеми месяцами',
'backup.auto.scheduleSummary': 'Расписание',
'backup.auto.summaryDaily': 'Каждый день в {hour}:00',
'backup.auto.summaryWeekly': 'Каждый {day} в {hour}:00',
'backup.auto.summaryMonthly': '{day}-го числа каждого месяца в {hour}:00',
'backup.auto.envLocked': 'Docker',
'backup.auto.envLockedHint': 'Автокопирование настроено через переменные окружения Docker. Чтобы изменить параметры, обновите docker-compose.yml и перезапустите контейнер.',
'backup.auto.copyEnv': 'Скопировать переменные окружения Docker',
'backup.auto.envCopied': 'Переменные окружения Docker скопированы в буфер обмена',
'backup.auto.keepLabel': 'Удалять старые копии через',
'backup.dow.sunday': 'Вс',
'backup.dow.monday': 'Пн',
'backup.dow.tuesday': 'Вт',
'backup.dow.wednesday': 'Ср',
'backup.dow.thursday': 'Чт',
'backup.dow.friday': 'Пт',
'backup.dow.saturday': 'Сб',
'backup.interval.hourly': 'Каждый час',
'backup.interval.daily': 'Ежедневно',
'backup.interval.weekly': 'Еженедельно',
@@ -1111,6 +1228,45 @@ const ru: Record<string, string> = {
'day.editAccommodation': 'Редактировать жильё',
'day.reservations': 'Бронирования',
// Memories / Immich
'memories.title': 'Фото',
'memories.notConnected': 'Immich не подключён',
'memories.notConnectedHint': 'Подключите Immich в настройках, чтобы видеть фотографии из поездок.',
'memories.noDates': 'Добавьте даты поездки для загрузки фотографий.',
'memories.noPhotos': 'Фотографии не найдены',
'memories.noPhotosHint': 'В Immich нет фотографий за период этой поездки.',
'memories.photosFound': 'фото',
'memories.fromOthers': 'от других',
'memories.sharePhotos': 'Поделиться фото',
'memories.sharing': 'Общий доступ',
'memories.reviewTitle': 'Проверьте ваши фото',
'memories.reviewHint': 'Нажмите на фото, чтобы исключить его из общего доступа.',
'memories.shareCount': 'Поделиться ({count} фото)',
'memories.immichUrl': 'URL сервера Immich',
'memories.immichApiKey': 'API-ключ',
'memories.testConnection': 'Проверить подключение',
'memories.connected': 'Подключено',
'memories.disconnected': 'Не подключено',
'memories.connectionSuccess': 'Подключение к Immich установлено',
'memories.connectionError': 'Не удалось подключиться к Immich',
'memories.saved': 'Настройки Immich сохранены',
'memories.oldest': 'Сначала старые',
'memories.newest': 'Сначала новые',
'memories.allLocations': 'Все места',
'memories.addPhotos': 'Добавить фото',
'memories.selectPhotos': 'Выбрать фото из Immich',
'memories.selectHint': 'Нажмите на фото, чтобы выбрать их.',
'memories.selected': 'выбрано',
'memories.addSelected': 'Добавить {count} фото',
'memories.alreadyAdded': 'Добавлено',
'memories.private': 'Приватное',
'memories.stopSharing': 'Прекратить доступ',
'memories.tripDates': 'Даты поездки',
'memories.allPhotos': 'Все фото',
'memories.confirmShareTitle': 'Поделиться с участниками поездки?',
'memories.confirmShareHint': '{count} фото станут видны всем участникам этой поездки. Вы сможете сделать отдельные фото приватными позже.',
'memories.confirmShareButton': 'Поделиться фото',
// Collab Addon
'collab.tabs.chat': 'Чат',
'collab.tabs.notes': 'Заметки',
+156
View File
@@ -139,6 +139,50 @@ const zh: Record<string, string> = {
'settings.temperature': '温度单位',
'settings.timeFormat': '时间格式',
'settings.routeCalculation': '路线计算',
'settings.blurBookingCodes': '模糊预订代码',
'settings.notifications': '通知',
'settings.notifyTripInvite': '旅行邀请',
'settings.notifyBookingChange': '预订变更',
'settings.notifyTripReminder': '旅行提醒',
'settings.notifyVacayInvite': 'Vacay 融合邀请',
'settings.notifyPhotosShared': '共享照片 (Immich)',
'settings.notifyCollabMessage': '聊天消息 (Collab)',
'settings.notifyPackingTagged': '行李清单:分配',
'settings.notifyWebhook': 'Webhook 通知',
'admin.smtp.title': '邮件与通知',
'admin.smtp.hint': '用于邮件通知的 SMTP 配置。可选:Discord、Slack 等的 Webhook URL。',
'admin.smtp.testButton': '发送测试邮件',
'admin.smtp.testSuccess': '测试邮件发送成功',
'admin.smtp.testFailed': '测试邮件发送失败',
'dayplan.icsTooltip': '导出日历 (ICS)',
'share.linkTitle': '公开链接',
'share.linkHint': '创建一个链接,任何人无需登录即可查看此旅行。仅可查看,无法编辑。',
'share.createLink': '创建链接',
'share.deleteLink': '删除链接',
'share.createError': '无法创建链接',
'common.copy': '复制',
'common.copied': '已复制',
'share.permMap': '地图与计划',
'share.permBookings': '预订',
'share.permPacking': '行李',
'shared.expired': '链接已过期或无效',
'shared.expiredHint': '此共享旅行链接已失效。',
'shared.readOnly': '只读共享视图',
'shared.tabPlan': '计划',
'shared.tabBookings': '预订',
'shared.tabPacking': '行李',
'shared.tabBudget': '预算',
'shared.tabChat': '聊天',
'shared.days': '天',
'shared.places': '个地点',
'shared.other': '其他',
'shared.totalBudget': '总预算',
'shared.messages': '条消息',
'shared.sharedVia': '通过以下分享',
'shared.confirmed': '已确认',
'shared.pending': '待确认',
'share.permBudget': '预算',
'share.permCollab': '聊天',
'settings.on': '开',
'settings.off': '关',
'settings.account': '账户',
@@ -271,6 +315,7 @@ const zh: Record<string, string> = {
'admin.tabs.users': '用户',
'admin.tabs.categories': '分类',
'admin.tabs.backup': '备份',
'admin.tabs.audit': '审计日志',
'admin.stats.users': '用户',
'admin.stats.trips': '旅行',
'admin.stats.places': '地点',
@@ -412,6 +457,19 @@ const zh: Record<string, string> = {
// GitHub
'admin.tabs.github': 'GitHub',
'admin.audit.subtitle': '安全与管理员操作记录(备份、用户、MFA、设置)。',
'admin.audit.empty': '暂无审计记录。',
'admin.audit.refresh': '刷新',
'admin.audit.loadMore': '加载更多',
'admin.audit.showing': '已加载 {count} 条 · 共 {total} 条',
'admin.audit.col.time': '时间',
'admin.audit.col.user': '用户',
'admin.audit.col.action': '操作',
'admin.audit.col.resource': '资源',
'admin.audit.col.ip': 'IP',
'admin.audit.col.details': '详情',
'admin.github.title': '版本历史',
'admin.github.subtitle': '{repo} 的最新更新',
'admin.github.latest': '最新',
@@ -475,6 +533,14 @@ const zh: Record<string, string> = {
'vacay.carriedOver': '从 {year} 结转',
'vacay.blockWeekends': '锁定周末',
'vacay.blockWeekendsHint': '禁止在周六和周日安排假期',
'vacay.weekendDays': '周末',
'vacay.mon': '周一',
'vacay.tue': '周二',
'vacay.wed': '周三',
'vacay.thu': '周四',
'vacay.fri': '周五',
'vacay.sat': '周六',
'vacay.sun': '周日',
'vacay.publicHolidays': '公共假日',
'vacay.publicHolidaysHint': '在日历中标记公共假日',
'vacay.selectCountry': '选择国家',
@@ -569,6 +635,10 @@ const zh: Record<string, string> = {
'atlas.markVisited': '标记为已访问',
'atlas.markVisitedHint': '将此国家添加到已访问列表',
'atlas.addToBucket': '添加到心愿单',
'atlas.addPoi': '添加地点',
'atlas.bucketNamePlaceholder': '名称(国家、城市、地点…)',
'atlas.month': '月份',
'atlas.year': '年份',
'atlas.addToBucketHint': '保存为想去的地方',
'atlas.bucketWhen': '你计划什么时候去?',
@@ -618,14 +688,26 @@ const zh: Record<string, string> = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': '导出当天计划为 PDF',
'dayplan.pdfError': 'PDF 导出失败',
'dayplan.cannotReorderTransport': '有固定时间的预订无法重新排序',
'dayplan.confirmRemoveTimeTitle': '移除时间?',
'dayplan.confirmRemoveTimeBody': '此地点有固定时间({time})。移动后将移除时间并允许自由排序。',
'dayplan.confirmRemoveTimeAction': '移除时间并移动',
'dayplan.cannotDropOnTimed': '无法将项目放置在有固定时间的条目之间',
'dayplan.cannotBreakChronology': '这将打乱已计划项目和预订的时间顺序',
// Places Sidebar
'places.addPlace': '添加地点/活动',
'places.importGpx': '导入 GPX',
'places.gpxImported': '已从 GPX 导入 {count} 个地点',
'places.gpxError': 'GPX 导入失败',
'places.urlResolved': '已从 URL 导入地点',
'places.assignToDay': '添加到哪一天?',
'places.all': '全部',
'places.unplanned': '未规划',
'places.search': '搜索地点...',
'places.allCategories': '所有分类',
'places.categoriesSelected': '个分类',
'places.clearFilter': '清除筛选',
'places.count': '{count} 个地点',
'places.countSingular': '1 个地点',
'places.allPlanned': '所有地点已规划',
@@ -724,6 +806,8 @@ const zh: Record<string, string> = {
'reservations.type.tour': '旅游团',
'reservations.type.other': '其他',
'reservations.confirm.delete': '确定要删除预订「{name}」吗?',
'reservations.confirm.deleteTitle': '删除预订?',
'reservations.confirm.deleteBody': '"{name}" 将被永久删除。',
'reservations.toast.updated': '预订已更新',
'reservations.toast.removed': '预订已删除',
'reservations.toast.fileUploaded': '文件已上传',
@@ -743,6 +827,7 @@ const zh: Record<string, string> = {
'reservations.pendingSave': '将被保存…',
'reservations.uploading': '上传中...',
'reservations.attachFile': '附加文件',
'reservations.linkExisting': '关联已有文件',
'reservations.toast.saveError': '保存失败',
'reservations.toast.updateError': '更新失败',
'reservations.toast.deleteError': '删除失败',
@@ -780,6 +865,9 @@ const zh: Record<string, string> = {
'budget.paid': '已支付',
'budget.open': '未支付',
'budget.noMembers': '未分配成员',
'budget.settlement': '结算',
'budget.settlementInfo': '点击预算项目上的成员头像将其标记为绿色——表示该成员已付款。结算会显示谁欠谁多少。',
'budget.netBalances': '净余额',
// Files
'files.title': '文件',
@@ -833,6 +921,15 @@ const zh: Record<string, string> = {
// Packing
'packing.title': '行李清单',
'packing.empty': '行李清单为空',
'packing.import': '导入',
'packing.importTitle': '导入装箱清单',
'packing.importHint': '每行一个物品。可选用逗号、分号或制表符分隔类别和数量:名称, 类别, 数量',
'packing.importPlaceholder': '牙刷\n防晒霜, 卫生\nT恤, 衣物, 5\n护照, 证件',
'packing.importCsv': '加载 CSV/TXT',
'packing.importAction': '导入 {count}',
'packing.importSuccess': '已导入 {count} 项',
'packing.importError': '导入失败',
'packing.importEmpty': '没有可导入的项目',
'packing.progress': '已打包 {packed}/{total}{percent}%',
'packing.clearChecked': '移除 {count} 个已勾选',
'packing.clearCheckedShort': '移除 {count} 个',
@@ -984,7 +1081,27 @@ const zh: Record<string, string> = {
'backup.auto.enable': '启用自动备份',
'backup.auto.enableHint': '将按所选计划自动创建备份',
'backup.auto.interval': '间隔',
'backup.auto.hour': '执行时间',
'backup.auto.hourHint': '服务器本地时间({format} 格式)',
'backup.auto.dayOfWeek': '星期几',
'backup.auto.dayOfMonth': '每月几号',
'backup.auto.dayOfMonthHint': '限 128 以兼容所有月份',
'backup.auto.scheduleSummary': '计划',
'backup.auto.summaryDaily': '每天 {hour}:00',
'backup.auto.summaryWeekly': '每{day} {hour}:00',
'backup.auto.summaryMonthly': '每月 {day} 号 {hour}:00',
'backup.auto.envLocked': 'Docker',
'backup.auto.envLockedHint': '自动备份通过 Docker 环境变量配置。要更改设置,请更新 docker-compose.yml 并重启容器。',
'backup.auto.copyEnv': '复制 Docker 环境变量',
'backup.auto.envCopied': 'Docker 环境变量已复制到剪贴板',
'backup.auto.keepLabel': '自动删除旧备份',
'backup.dow.sunday': '周日',
'backup.dow.monday': '周一',
'backup.dow.tuesday': '周二',
'backup.dow.wednesday': '周三',
'backup.dow.thursday': '周四',
'backup.dow.friday': '周五',
'backup.dow.saturday': '周六',
'backup.interval.hourly': '每小时',
'backup.interval.daily': '每天',
'backup.interval.weekly': '每周',
@@ -1111,6 +1228,45 @@ const zh: Record<string, string> = {
'day.editAccommodation': '编辑住宿',
'day.reservations': '预订',
// Memories / Immich
'memories.title': '照片',
'memories.notConnected': 'Immich 未连接',
'memories.notConnectedHint': '在设置中连接您的 Immich 实例以在此查看旅行照片。',
'memories.noDates': '为旅行添加日期以加载照片。',
'memories.noPhotos': '未找到照片',
'memories.noPhotosHint': 'Immich 中未找到此旅行日期范围内的照片。',
'memories.photosFound': '张照片',
'memories.fromOthers': '来自他人',
'memories.sharePhotos': '分享照片',
'memories.sharing': '分享中',
'memories.reviewTitle': '审查您的照片',
'memories.reviewHint': '点击照片以将其从分享中排除。',
'memories.shareCount': '分享 {count} 张照片',
'memories.immichUrl': 'Immich 服务器地址',
'memories.immichApiKey': 'API 密钥',
'memories.testConnection': '测试连接',
'memories.connected': '已连接',
'memories.disconnected': '未连接',
'memories.connectionSuccess': '已连接到 Immich',
'memories.connectionError': '无法连接到 Immich',
'memories.saved': 'Immich 设置已保存',
'memories.oldest': '最早优先',
'memories.newest': '最新优先',
'memories.allLocations': '所有地点',
'memories.addPhotos': '添加照片',
'memories.selectPhotos': '从 Immich 选择照片',
'memories.selectHint': '点击照片以选择。',
'memories.selected': '已选择',
'memories.addSelected': '添加 {count} 张照片',
'memories.alreadyAdded': '已添加',
'memories.private': '私密',
'memories.stopSharing': '停止分享',
'memories.tripDates': '旅行日期',
'memories.allPhotos': '所有照片',
'memories.confirmShareTitle': '与旅行成员分享?',
'memories.confirmShareHint': '{count} 张照片将对本次旅行的所有成员可见。你可以稍后将单张照片设为私密。',
'memories.confirmShareButton': '分享照片',
// Collab Addon
'collab.tabs.chat': '聊天',
'collab.tabs.notes': '笔记',
+65 -6
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'
@@ -13,6 +13,7 @@ import BackupPanel from '../components/Admin/BackupPanel'
import GitHubPanel from '../components/Admin/GitHubPanel'
import AddonManager from '../components/Admin/AddonManager'
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'
@@ -52,7 +53,7 @@ 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 = [
@@ -61,6 +62,7 @@ export default function AdminPage(): React.ReactElement {
{ 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') },
]
@@ -93,6 +95,16 @@ export default function AdminPage(): React.ReactElement {
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>('')
@@ -333,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>
@@ -512,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">
@@ -584,7 +596,7 @@ export default function AdminPage(): React.ReactElement {
</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)}`}
{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>
@@ -918,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>
+155 -19
View File
@@ -3,9 +3,9 @@ import { useNavigate } from 'react-router-dom'
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../i18n'
import { useSettingsStore } from '../store/settingsStore'
import Navbar from '../components/Layout/Navbar'
import apiClient from '../api/client'
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 } from 'lucide-react'
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'
@@ -154,14 +154,19 @@ export default function AtlasPage(): React.ReactElement {
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(new Date().getMonth() + 1)
const [bucketYear, setBucketYear] = useState(new Date().getFullYear())
const [bucketMonth, setBucketMonth] = useState(0)
const [bucketYear, setBucketYear] = useState(0)
// Bucket list
interface BucketItem { id: number; name: string; lat: number | null; lng: number | null; country_code: string | null; notes: string | null }
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: '' })
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)
@@ -179,7 +184,7 @@ export default function AtlasPage(): React.ReactElement {
// 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 => {
// Dynamically build A2→A3 mapping from GeoJSON
@@ -397,9 +402,15 @@ export default function AtlasPage(): React.ReactElement {
const handleAddBucketItem = async (): Promise<void> => {
if (!bucketForm.name.trim()) return
try {
const r = await apiClient.post('/addons/atlas/bucket-list', { name: bucketForm.name.trim(), notes: bucketForm.notes.trim() || null })
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: '' })
setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' })
setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0)
setShowBucketAdd(false)
} catch { /* */ }
}
@@ -411,6 +422,28 @@ export default function AtlasPage(): React.ReactElement {
} 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
@@ -517,6 +550,10 @@ export default function AtlasPage(): React.ReactElement {
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>
@@ -594,7 +631,11 @@ export default function AtlasPage(): React.ReactElement {
<CustomSelect
value={bucketMonth}
onChange={v => setBucketMonth(Number(v))}
options={Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'long' }) }))}
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>
@@ -602,22 +643,27 @@ export default function AtlasPage(): React.ReactElement {
<CustomSelect
value={bucketYear}
onChange={v => setBucketYear(Number(v))}
options={Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) }))}
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' }}>
<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 monthStr = new Date(bucketYear, bucketMonth - 1).toLocaleString(language, { month: 'short', year: 'numeric' })
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, notes: monthStr })
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' }}>
@@ -664,15 +710,26 @@ interface SidebarContentProps {
setBucketTab: (tab: 'stats' | 'bucket') => void
showBucketAdd: boolean
setShowBucketAdd: (v: boolean) => void
bucketForm: { name: string; notes: string }
setBucketForm: (f: { name: string; notes: string }) => 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, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, 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'
@@ -722,6 +779,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
// 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 }}>
@@ -732,7 +790,12 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
) : <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.notes && <span className="text-[9px] mt-0.5 text-center" style={{ color: tf, maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.notes}</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' }}>
@@ -740,12 +803,85 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
</button>
</div>
))}
{bucketList.length === 0 && (
{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 (
+23 -17
View File
@@ -33,18 +33,12 @@ export default function LoginPage(): React.ReactElement {
const navigate = useNavigate()
useEffect(() => {
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
if (config) {
setAppConfig(config)
if (!config.has_users) setMode('register')
}
})
// Handle query params (invite token, OIDC callback)
const params = new URLSearchParams(window.location.search)
// Check for invite token in URL (/register?invite=xxx or /login?invite=xxx)
const invite = params.get('invite')
const oidcCode = params.get('oidc_code')
const oidcError = params.get('oidc_error')
if (invite) {
setInviteToken(invite)
setMode('register')
@@ -54,26 +48,27 @@ export default function LoginPage(): React.ReactElement {
setError('Invalid or expired invite link')
})
window.history.replaceState({}, '', window.location.pathname)
return
}
// Handle OIDC callback via short-lived auth code (secure exchange)
const oidcCode = params.get('oidc_code')
const oidcError = params.get('oidc_error')
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'),
@@ -83,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('')
@@ -490,7 +496,7 @@ export default function LoginPage(): React.ReactElement {
{error}
</div>
)}
<a href="/api/auth/oidc/login"
<a href={`/api/auth/oidc/login${inviteToken ? '?invite=' + encodeURIComponent(inviteToken) : ''}`}
style={{
width: '100%', padding: '12px',
background: '#111827', color: 'white',
@@ -651,7 +657,7 @@ export default function LoginPage(): React.ReactElement {
<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',
+190 -4
View File
@@ -7,7 +7,8 @@ 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, KeyRound } from 'lucide-react'
import { authApi, adminApi } from '../api/client'
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,6 +46,60 @@ 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, loadUser, demoMode } = useAuthStore()
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
@@ -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)
@@ -166,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>
@@ -385,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>
@@ -795,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>
)
}
+17 -3
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'
@@ -54,7 +55,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
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)
@@ -67,6 +68,7 @@ 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.memories ? [{ id: 'memories', label: t('memories.title') }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name') }] : []),
]
@@ -444,6 +446,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
accommodations={tripAccommodations}
onNavigateToFiles={() => handleTabChange('dateien')}
/>
{!leftCollapsed && (
<div
@@ -492,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}
@@ -540,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}
/>
)
})()}
@@ -586,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}
/>
)}
@@ -600,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 onCategoryFilterChange={setMapCategoryFilter} />
? <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>
@@ -656,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} />
+4
View File
@@ -23,6 +23,7 @@ interface AuthState {
error: string | null
demoMode: boolean
hasMapsKey: boolean
serverTimezone: string
login: (email: string, password: string) => Promise<LoginResult>
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
@@ -36,6 +37,7 @@ interface AuthState {
deleteAvatar: () => Promise<void>
setDemoMode: (val: boolean) => void
setHasMapsKey: (val: boolean) => void
setServerTimezone: (tz: string) => void
demoLogin: () => Promise<AuthResponse>
}
@@ -47,6 +49,7 @@ 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 })
@@ -201,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 {}
}
+14 -4
View File
@@ -118,15 +118,22 @@ export interface Reservation {
trip_id: number
name: string
title?: string
type: string | null
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
metadata?: Record<string, string> | null
day_plan_position?: number | null
metadata?: Record<string, string> | string | null
created_at: string
}
@@ -148,6 +155,7 @@ export interface TripFile {
deleted_at?: string | null
created_at: string
reservation_title?: string
linked_reservation_ids?: number[]
url?: string
}
@@ -163,6 +171,7 @@ export interface Settings {
time_format: string
show_place_description: boolean
route_calculation?: boolean
blur_booking_codes?: boolean
}
export interface AssignmentsMap {
@@ -271,6 +280,7 @@ export interface AppConfig {
oidc_display_name?: string
has_maps_key?: boolean
allowed_file_types?: string
timezone?: string
}
// Translation function type
@@ -361,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
}
+5 -3
View File
@@ -6,11 +6,13 @@ export function currencyDecimals(currency: string): number {
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2
}
export function formatDate(dateStr: string | null | undefined, locale: string): string | null {
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 {
+1
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/],
+17
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/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
+23 -2
View File
@@ -1,12 +1,12 @@
{
"name": "trek-server",
"version": "2.6.2",
"version": "2.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-server",
"version": "2.6.2",
"version": "2.7.0",
"dependencies": {
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
@@ -19,6 +19,7 @@
"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",
@@ -37,6 +38,7 @@
"@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",
@@ -653,6 +655,16 @@
"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",
@@ -2520,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",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "trek-server",
"version": "2.7.0",
"version": "2.7.1",
"main": "src/index.ts",
"scripts": {
"start": "node --import tsx src/index.ts",
@@ -17,9 +17,10 @@
"jsonwebtoken": "^9.0.2",
"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",
"node-fetch": "^2.7.0",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"unzipper": "^0.12.3",
@@ -36,6 +37,7 @@
"@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",
+109
View File
@@ -285,6 +285,115 @@ function runMigrations(db: Database.Database): void {
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) {
+11
View File
@@ -380,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);
`);
}
+52 -4
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) {
@@ -79,10 +80,40 @@ if (shouldForceHttps) {
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'];
@@ -152,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'));
});
}
@@ -179,6 +226,7 @@ const PORT = process.env.PORT || 3001;
const server = app.listen(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.');
+164 -13
View File
@@ -7,11 +7,17 @@ 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'
@@ -21,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 });
});
@@ -52,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 });
});
@@ -90,6 +110,19 @@ 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 });
});
@@ -103,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 });
});
@@ -115,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');
@@ -135,16 +216,25 @@ router.put('/oidc', (req: Request, res: Response) => {
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);
@@ -169,11 +259,26 @@ 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',
'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 });
@@ -186,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');
@@ -209,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(() => {
@@ -245,24 +357,39 @@ router.post('/invites', (req: Request, res: Response) => {
? new Date(Date.now() + parseInt(expires_in_days) * 86400000).toISOString()
: null;
db.prepare(
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 = last_insert_rowid()
`).get();
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);
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);
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 });
});
@@ -276,6 +403,13 @@ router.get('/bag-tracking', (_req: Request, res: Response) => {
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 });
});
@@ -322,10 +456,19 @@ router.put('/packing-templates/:id', (req: Request, res: Response) => {
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);
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);
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 });
});
@@ -393,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 || '{}') } });
});
+25 -7
View File
@@ -9,7 +9,7 @@ router.use(authenticate);
const COUNTRY_BOXES: Record<string, [number, number, number, number]> = {
AF:[60.5,29.4,75,38.5],AL:[19,39.6,21.1,42.7],DZ:[-8.7,19,12,37.1],AD:[1.4,42.4,1.8,42.7],AO:[11.7,-18.1,24.1,-4.4],
AR:[-73.6,-55.1,-53.6,-21.8],AM:[43.4,38.8,46.6,41.3],AU:[112.9,-43.6,153.6,-10.7],AT:[9.5,46.4,17.2,49],AZ:[44.8,38.4,50.4,41.9],
BR:[-73.9,-33.8,-34.8,5.3],BE:[2.5,49.5,6.4,51.5],BG:[22.4,41.2,28.6,44.2],CA:[-141,41.7,-52.6,83.1],CL:[-75.6,-55.9,-66.9,-17.5],
BD:[88.0,20.7,92.7,26.6],BR:[-73.9,-33.8,-34.8,5.3],BE:[2.5,49.5,6.4,51.5],BG:[22.4,41.2,28.6,44.2],CA:[-141,41.7,-52.6,83.1],CL:[-75.6,-55.9,-66.9,-17.5],
CN:[73.6,18.2,134.8,53.6],CO:[-79.1,-4.3,-66.9,12.5],HR:[13.5,42.4,19.5,46.6],CZ:[12.1,48.6,18.9,51.1],DK:[8,54.6,15.2,57.8],
EG:[24.7,22,37,31.7],EE:[21.8,57.5,28.2,59.7],FI:[20.6,59.8,31.6,70.1],FR:[-5.1,41.3,9.6,51.1],DE:[5.9,47.3,15.1,55.1],
GR:[19.4,34.8,29.7,41.8],HU:[16,45.7,22.9,48.6],IS:[-24.5,63.4,-13.5,66.6],IN:[68.2,6.7,97.4,35.5],ID:[95.3,-11,141,5.9],
@@ -96,7 +96,10 @@ router.get('/stats', (req: Request, res: Response) => {
const tripIds = trips.map(t => t.id);
if (tripIds.length === 0) {
return res.json({ countries: [], trips: [], stats: { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 } });
// Still include manually marked countries even without trips
const manualCountries = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').all(userId) as { country_code: string }[];
const countries = manualCountries.map(mc => ({ code: mc.country_code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }));
return res.json({ countries, trips: [], stats: { totalTrips: 0, totalPlaces: 0, totalCountries: countries.length, totalDays: 0 } });
}
const placeholders = tripIds.map(() => '?').join(',');
@@ -274,10 +277,10 @@ router.get('/bucket-list', (req: Request, res: Response) => {
router.post('/bucket-list', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { name, lat, lng, country_code, notes } = req.body;
const { name, lat, lng, country_code, notes, target_date } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
const result = db.prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes) VALUES (?, ?, ?, ?, ?, ?)').run(
authReq.user.id, name.trim(), lat ?? null, lng ?? null, country_code ?? null, notes ?? null
const result = db.prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date) VALUES (?, ?, ?, ?, ?, ?, ?)').run(
authReq.user.id, name.trim(), lat ?? null, lng ?? null, country_code ?? null, notes ?? null, target_date ?? null
);
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ item });
@@ -285,10 +288,25 @@ router.post('/bucket-list', (req: Request, res: Response) => {
router.put('/bucket-list/:id', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { name, notes } = req.body;
const { name, notes, lat, lng, country_code, target_date } = req.body;
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(req.params.id, authReq.user.id);
if (!item) return res.status(404).json({ error: 'Item not found' });
db.prepare('UPDATE bucket_list SET name = COALESCE(?, name), notes = COALESCE(?, notes) WHERE id = ?').run(name?.trim() || null, notes ?? null, req.params.id);
db.prepare(`UPDATE bucket_list SET
name = COALESCE(?, name),
notes = CASE WHEN ? THEN ? ELSE notes END,
lat = CASE WHEN ? THEN ? ELSE lat END,
lng = CASE WHEN ? THEN ? ELSE lng END,
country_code = CASE WHEN ? THEN ? ELSE country_code END,
target_date = CASE WHEN ? THEN ? ELSE target_date END
WHERE id = ?`).run(
name?.trim() || null,
notes !== undefined ? 1 : 0, notes !== undefined ? (notes || null) : null,
lat !== undefined ? 1 : 0, lat !== undefined ? (lat || null) : null,
lng !== undefined ? 1 : 0, lng !== undefined ? (lng || null) : null,
country_code !== undefined ? 1 : 0, country_code !== undefined ? (country_code || null) : null,
target_date !== undefined ? 1 : 0, target_date !== undefined ? (target_date || null) : null,
req.params.id
);
res.json({ item: db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(req.params.id) });
});
+44 -7
View File
@@ -13,6 +13,7 @@ import { authenticate, demoUploadBlock } from '../middleware/auth';
import { JWT_SECRET } from '../config';
import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto';
import { AuthRequest, User } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
authenticator.options = { window: 1 };
@@ -28,6 +29,11 @@ function getPendingMfaSecret(userId: number): string | null {
return row.secret;
}
function utcSuffix(ts: string | null | undefined): string | null {
if (!ts) return null;
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
}
function stripUserForClient(user: User): Record<string, unknown> {
const {
password_hash: _p,
@@ -39,6 +45,9 @@ function stripUserForClient(user: User): Record<string, unknown> {
} = user;
return {
...rest,
created_at: utcSuffix(rest.created_at),
updated_at: utcSuffix(rest.updated_at),
last_login: utcSuffix(rest.last_login),
mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true),
};
}
@@ -146,6 +155,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
demo_mode: isDemo,
demo_email: isDemo ? 'demo@trek.app' : undefined,
demo_password: isDemo ? 'demo12345' : undefined,
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
});
});
@@ -179,7 +189,7 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
if (invite_token) {
validInvite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(invite_token);
if (!validInvite) return res.status(400).json({ error: 'Invalid invite link' });
if (validInvite.used_count >= validInvite.max_uses) return res.status(410).json({ error: 'Invite link has been fully used' });
if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) return res.status(410).json({ error: 'Invite link has been fully used' });
if (validInvite.expires_at && new Date(validInvite.expires_at) < new Date()) return res.status(410).json({ error: 'Invite link has expired' });
}
@@ -506,18 +516,43 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) =
res.json(result);
});
const ADMIN_SETTINGS_KEYS = ['allow_registration', 'allowed_file_types', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'notification_webhook_url', 'app_url'];
router.get('/app-settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined;
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
const result: Record<string, string> = {};
for (const key of ADMIN_SETTINGS_KEYS) {
const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined;
if (row) result[key] = key === 'smtp_pass' ? '••••••••' : row.value;
}
res.json(result);
});
router.put('/app-settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined;
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
const { allow_registration, allowed_file_types } = req.body;
if (allow_registration !== undefined) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', ?)").run(String(allow_registration));
}
if (allowed_file_types !== undefined) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', ?)").run(String(allowed_file_types));
for (const key of ADMIN_SETTINGS_KEYS) {
if (req.body[key] !== undefined) {
const val = String(req.body[key]);
// Don't save masked password
if (key === 'smtp_pass' && val === '••••••••') continue;
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val);
}
}
writeAudit({
userId: authReq.user.id,
action: 'settings.app_update',
ip: getClientIp(req),
details: {
allow_registration: allow_registration !== undefined ? Boolean(allow_registration) : undefined,
allowed_file_types_changed: allowed_file_types !== undefined,
},
});
res.json({ success: true });
});
@@ -673,6 +708,7 @@ router.post('/mfa/enable', authenticate, (req: Request, res: Response) => {
authReq.user.id
);
mfaSetupPending.delete(authReq.user.id);
writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
res.json({ success: true, mfa_enabled: true });
});
@@ -702,6 +738,7 @@ router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (re
authReq.user.id
);
mfaSetupPending.delete(authReq.user.id);
writeAudit({ userId: authReq.user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
res.json({ success: true, mfa_enabled: false });
});
+69 -15
View File
@@ -7,6 +7,10 @@ import fs from 'fs';
import { authenticate, adminOnly } from '../middleware/auth';
import * as scheduler from '../scheduler';
import { db, closeDb, reinitialize } from '../db/database';
import { AuthRequest } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
type RestoreAuditInfo = { userId: number; ip: string | null; source: 'backup.restore' | 'backup.upload_restore'; label: string };
const router = express.Router();
@@ -103,6 +107,14 @@ router.post('/create', backupRateLimiter(3, BACKUP_RATE_WINDOW), async (_req: Re
});
const stat = fs.statSync(outputPath);
const authReq = _req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'backup.create',
resource: filename,
ip: getClientIp(_req),
details: { size: stat.size },
});
res.json({
success: true,
backup: {
@@ -134,7 +146,7 @@ router.get('/download/:filename', (req: Request, res: Response) => {
res.download(filePath, filename);
});
async function restoreFromZip(zipPath: string, res: Response) {
async function restoreFromZip(zipPath: string, res: Response, audit?: RestoreAuditInfo) {
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
try {
await fs.createReadStream(zipPath)
@@ -174,6 +186,14 @@ async function restoreFromZip(zipPath: string, res: Response) {
fs.rmSync(extractDir, { recursive: true, force: true });
if (audit) {
writeAudit({
userId: audit.userId,
action: audit.source,
resource: audit.label,
ip: audit.ip,
});
}
res.json({ success: true });
} catch (err: unknown) {
console.error('Restore error:', err);
@@ -191,7 +211,13 @@ router.post('/restore/:filename', async (req: Request, res: Response) => {
if (!fs.existsSync(zipPath)) {
return res.status(404).json({ error: 'Backup not found' });
}
await restoreFromZip(zipPath, res);
const authReq = req as AuthRequest;
await restoreFromZip(zipPath, res, {
userId: authReq.user.id,
ip: getClientIp(req),
source: 'backup.restore',
label: filename,
});
});
const uploadTmp = multer({
@@ -206,23 +232,43 @@ const uploadTmp = multer({
router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request, res: Response) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const zipPath = req.file.path;
await restoreFromZip(zipPath, res);
const authReq = req as AuthRequest;
const origName = req.file.originalname || 'upload.zip';
await restoreFromZip(zipPath, res, {
userId: authReq.user.id,
ip: getClientIp(req),
source: 'backup.upload_restore',
label: origName,
});
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
});
router.get('/auto-settings', (_req: Request, res: Response) => {
try {
res.json({ settings: scheduler.loadSettings() });
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
res.json({ settings: scheduler.loadSettings(), timezone: tz });
} catch (err: unknown) {
console.error('[backup] GET auto-settings:', err);
res.status(500).json({ error: 'Could not load backup settings' });
}
});
function parseIntField(raw: unknown, fallback: number): number {
if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
if (typeof raw === 'string' && raw.trim() !== '') {
const n = parseInt(raw, 10);
if (Number.isFinite(n)) return n;
}
return fallback;
}
function parseAutoBackupBody(body: Record<string, unknown>): {
enabled: boolean;
interval: string;
keep_days: number;
hour: number;
day_of_week: number;
day_of_month: number;
} {
const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1;
const rawInterval = body.interval;
@@ -230,17 +276,11 @@ function parseAutoBackupBody(body: Record<string, unknown>): {
typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval)
? rawInterval
: 'daily';
const rawKeep = body.keep_days;
let keepNum: number;
if (typeof rawKeep === 'number' && Number.isFinite(rawKeep)) {
keepNum = Math.floor(rawKeep);
} else if (typeof rawKeep === 'string' && rawKeep.trim() !== '') {
keepNum = parseInt(rawKeep, 10);
} else {
keepNum = NaN;
}
const keep_days = Number.isFinite(keepNum) && keepNum >= 0 ? keepNum : 7;
return { enabled, interval, keep_days };
const keep_days = Math.max(0, parseIntField(body.keep_days, 7));
const hour = Math.min(23, Math.max(0, parseIntField(body.hour, 2)));
const day_of_week = Math.min(6, Math.max(0, parseIntField(body.day_of_week, 0)));
const day_of_month = Math.min(28, Math.max(1, parseIntField(body.day_of_month, 1)));
return { enabled, interval, keep_days, hour, day_of_week, day_of_month };
}
router.put('/auto-settings', (req: Request, res: Response) => {
@@ -248,6 +288,13 @@ router.put('/auto-settings', (req: Request, res: Response) => {
const settings = parseAutoBackupBody((req.body || {}) as Record<string, unknown>);
scheduler.saveSettings(settings);
scheduler.start();
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'backup.auto_settings',
ip: getClientIp(req),
details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days },
});
res.json({ settings });
} catch (err: unknown) {
console.error('[backup] PUT auto-settings:', err);
@@ -272,6 +319,13 @@ router.delete('/:filename', (req: Request, res: Response) => {
}
fs.unlinkSync(filePath);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'backup.delete',
resource: filename,
ip: getClientIp(req),
});
res.json({ success: true });
});
+71
View File
@@ -195,6 +195,77 @@ router.put('/:id/members/:userId/paid', authenticate, (req: Request, res: Respon
broadcast(Number(tripId), 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, req.headers['x-socket-id'] as string);
});
// Settlement calculation: who owes whom
router.get('/settlement', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
const allMembers = db.prepare(`
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
`).all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
// Calculate net balance per user: positive = is owed money, negative = owes money
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> = {};
for (const item of items) {
const members = allMembers.filter(m => m.budget_item_id === item.id);
if (members.length === 0) continue;
const payers = members.filter(m => m.paid);
if (payers.length === 0) continue; // no one marked as paid
const sharePerMember = item.total_price / members.length;
const paidPerPayer = item.total_price / payers.length;
for (const m of members) {
if (!balances[m.user_id]) {
balances[m.user_id] = { user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), balance: 0 };
}
// Everyone owes their share
balances[m.user_id].balance -= sharePerMember;
// Payers get credited what they paid
if (m.paid) balances[m.user_id].balance += paidPerPayer;
}
}
// Calculate optimized payment flows (greedy algorithm)
const people = Object.values(balances).filter(b => Math.abs(b.balance) > 0.01);
const debtors = people.filter(p => p.balance < -0.01).map(p => ({ ...p, amount: -p.balance }));
const creditors = people.filter(p => p.balance > 0.01).map(p => ({ ...p, amount: p.balance }));
// Sort by amount descending for efficient matching
debtors.sort((a, b) => b.amount - a.amount);
creditors.sort((a, b) => b.amount - a.amount);
const flows: { from: { user_id: number; username: string; avatar_url: string | null }; to: { user_id: number; username: string; avatar_url: string | null }; amount: number }[] = [];
let di = 0, ci = 0;
while (di < debtors.length && ci < creditors.length) {
const transfer = Math.min(debtors[di].amount, creditors[ci].amount);
if (transfer > 0.01) {
flows.push({
from: { user_id: debtors[di].user_id, username: debtors[di].username, avatar_url: debtors[di].avatar_url },
to: { user_id: creditors[ci].user_id, username: creditors[ci].username, avatar_url: creditors[ci].avatar_url },
amount: Math.round(transfer * 100) / 100,
});
}
debtors[di].amount -= transfer;
creditors[ci].amount -= transfer;
if (debtors[di].amount < 0.01) di++;
if (creditors[ci].amount < 0.01) ci++;
}
res.json({
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })),
flows,
});
});
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
+7
View File
@@ -419,6 +419,13 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r
const formatted = formatMessage(message);
res.status(201).json({ message: formatted });
broadcast(tripId, 'collab:message:created', { message: formatted }, req.headers['x-socket-id'] as string);
// Notify trip members about new chat message
import('../services/notifications').then(({ notifyTripMembers }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
const preview = text.trim().length > 80 ? text.trim().substring(0, 80) + '...' : text.trim();
notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, preview }).catch(() => {});
});
});
router.post('/messages/:id/react', authenticate, (req: Request, res: Response) => {
+72 -1
View File
@@ -82,7 +82,27 @@ router.get('/', authenticate, (req: Request, res: Response) => {
const where = showTrash ? 'f.trip_id = ? AND f.deleted_at IS NOT NULL' : 'f.trip_id = ? AND f.deleted_at IS NULL';
const files = db.prepare(`${FILE_SELECT} WHERE ${where} ORDER BY f.starred DESC, f.created_at DESC`).all(tripId) as TripFile[];
res.json({ files: files.map(formatFile) });
// Get all file_links for this trip's files
const fileIds = files.map(f => f.id);
let linksMap: Record<number, number[]> = {};
if (fileIds.length > 0) {
const placeholders = fileIds.map(() => '?').join(',');
const links = db.prepare(`SELECT file_id, reservation_id, place_id FROM file_links WHERE file_id IN (${placeholders})`).all(...fileIds) as { file_id: number; reservation_id: number | null; place_id: number | null }[];
for (const link of links) {
if (!linksMap[link.file_id]) linksMap[link.file_id] = [];
linksMap[link.file_id].push(link);
}
}
res.json({ files: files.map(f => {
const fileLinks = linksMap[f.id] || [];
return {
...formatFile(f),
linked_reservation_ids: fileLinks.filter(l => l.reservation_id).map(l => l.reservation_id),
linked_place_ids: fileLinks.filter(l => l.place_id).map(l => l.place_id),
};
})});
});
// Upload file
@@ -239,4 +259,55 @@ router.delete('/trash/empty', authenticate, (req: Request, res: Response) => {
res.json({ success: true, deleted: trashed.length });
});
// Link a file to a reservation (many-to-many)
router.post('/:id/link', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const { reservation_id, assignment_id, place_id } = req.body;
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!file) return res.status(404).json({ error: 'File not found' });
try {
db.prepare('INSERT OR IGNORE INTO file_links (file_id, reservation_id, assignment_id, place_id) VALUES (?, ?, ?, ?)').run(
id, reservation_id || null, assignment_id || null, place_id || null
);
} catch {}
const links = db.prepare('SELECT * FROM file_links WHERE file_id = ?').all(id);
res.json({ success: true, links });
});
// Unlink a file from a reservation
router.delete('/:id/link/:linkId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id, linkId } = req.params;
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
db.prepare('DELETE FROM file_links WHERE id = ? AND file_id = ?').run(linkId, id);
res.json({ success: true });
});
// Get all links for a file
router.get('/:id/links', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const links = db.prepare(`
SELECT fl.*, r.title as reservation_title
FROM file_links fl
LEFT JOIN reservations r ON fl.reservation_id = r.id
WHERE fl.file_id = ?
`).all(id);
res.json({ links });
});
export default router;
+288
View File
@@ -0,0 +1,288 @@
import express, { Request, Response } from 'express';
import { db } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { AuthRequest } from '../types';
const router = express.Router();
// ── Immich Connection Settings ──────────────────────────────────────────────
router.get('/settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
res.json({
immich_url: user?.immich_url || '',
connected: !!(user?.immich_url && user?.immich_api_key),
});
});
router.put('/settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { immich_url, immich_api_key } = req.body;
db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
immich_url?.trim() || null,
immich_api_key?.trim() || null,
authReq.user.id
);
res.json({ success: true });
});
router.get('/status', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
if (!user?.immich_url || !user?.immich_api_key) {
return res.json({ connected: false, error: 'Not configured' });
}
try {
const resp = await fetch(`${user.immich_url}/api/users/me`, {
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` });
const data = await resp.json() as { name?: string; email?: string };
res.json({ connected: true, user: { name: data.name, email: data.email } });
} catch (err: unknown) {
res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' });
}
});
// ── Browse Immich Library (for photo picker) ────────────────────────────────
router.get('/browse', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { page = '1', size = '50' } = req.query;
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' });
try {
const resp = await fetch(`${user.immich_url}/api/timeline/buckets`, {
method: 'GET',
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch from Immich' });
const buckets = await resp.json();
res.json({ buckets });
} catch (err: unknown) {
res.status(502).json({ error: 'Could not reach Immich' });
}
});
// Search photos by date range (for the date-filter in picker)
router.post('/search', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { from, to } = req.body;
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' });
try {
// Paginate through all results (Immich limits per-page to 1000)
const allAssets: any[] = [];
let page = 1;
const pageSize = 1000;
while (true) {
const resp = await fetch(`${user.immich_url}/api/search/metadata`, {
method: 'POST',
headers: { 'x-api-key': user.immich_api_key, 'Content-Type': 'application/json' },
body: JSON.stringify({
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
type: 'IMAGE',
size: pageSize,
page,
}),
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) return res.status(resp.status).json({ error: 'Search failed' });
const data = await resp.json() as { assets?: { items?: any[] } };
const items = data.assets?.items || [];
allAssets.push(...items);
if (items.length < pageSize) break; // Last page
page++;
if (page > 20) break; // Safety limit (20k photos max)
}
const assets = allAssets.map((a: any) => ({
id: a.id,
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
country: a.exifInfo?.country || null,
}));
res.json({ assets });
} catch {
res.status(502).json({ error: 'Could not reach Immich' });
}
});
// ── Trip Photos (selected by user) ──────────────────────────────────────────
// Get all photos for a trip (own + shared by others)
router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const photos = db.prepare(`
SELECT tp.immich_asset_id, tp.user_id, tp.shared, tp.added_at,
u.username, u.avatar, u.immich_url
FROM trip_photos tp
JOIN users u ON tp.user_id = u.id
WHERE tp.trip_id = ?
AND (tp.user_id = ? OR tp.shared = 1)
ORDER BY tp.added_at ASC
`).all(tripId, authReq.user.id);
res.json({ photos });
});
// Add photos to a trip
router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { asset_ids, shared = true } = req.body;
if (!Array.isArray(asset_ids) || asset_ids.length === 0) {
return res.status(400).json({ error: 'asset_ids required' });
}
const insert = db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, ?)'
);
let added = 0;
for (const assetId of asset_ids) {
const result = insert.run(tripId, authReq.user.id, assetId, shared ? 1 : 0);
if (result.changes > 0) added++;
}
res.json({ success: true, added });
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
// Notify trip members about shared photos
if (shared && added > 0) {
import('../services/notifications').then(({ notifyTripMembers }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, count: String(added) }).catch(() => {});
});
}
});
// Remove a photo from a trip (own photos only)
router.delete('/trips/:tripId/photos/:assetId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
.run(req.params.tripId, authReq.user.id, req.params.assetId);
res.json({ success: true });
broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
});
// Toggle sharing for a specific photo
router.put('/trips/:tripId/photos/:assetId/sharing', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { shared } = req.body;
db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
.run(shared ? 1 : 0, req.params.tripId, authReq.user.id, req.params.assetId);
res.json({ success: true });
broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
});
// ── Asset Details ───────────────────────────────────────────────────────────
router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { assetId } = req.params;
const { userId } = req.query;
const targetUserId = userId ? Number(userId) : authReq.user.id;
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any;
if (!user?.immich_url || !user?.immich_api_key) return res.status(404).json({ error: 'Not found' });
try {
const resp = await fetch(`${user.immich_url}/api/assets/${assetId}`, {
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return res.status(resp.status).json({ error: 'Failed' });
const asset = await resp.json() as any;
res.json({
id: asset.id,
takenAt: asset.fileCreatedAt || asset.createdAt,
width: asset.exifInfo?.exifImageWidth || null,
height: asset.exifInfo?.exifImageHeight || null,
camera: asset.exifInfo?.make && asset.exifInfo?.model ? `${asset.exifInfo.make} ${asset.exifInfo.model}` : null,
lens: asset.exifInfo?.lensModel || null,
focalLength: asset.exifInfo?.focalLength ? `${asset.exifInfo.focalLength}mm` : null,
aperture: asset.exifInfo?.fNumber ? `f/${asset.exifInfo.fNumber}` : null,
shutter: asset.exifInfo?.exposureTime || null,
iso: asset.exifInfo?.iso || null,
city: asset.exifInfo?.city || null,
state: asset.exifInfo?.state || null,
country: asset.exifInfo?.country || null,
lat: asset.exifInfo?.latitude || null,
lng: asset.exifInfo?.longitude || null,
fileSize: asset.exifInfo?.fileSizeInByte || null,
fileName: asset.originalFileName || null,
});
} catch {
res.status(502).json({ error: 'Proxy error' });
}
});
// ── Proxy Immich Assets ─────────────────────────────────────────────────────
// Asset proxy routes accept token via query param (for <img> src usage)
function authFromQuery(req: Request, res: Response, next: Function) {
const token = req.query.token as string;
if (token && !req.headers.authorization) {
req.headers.authorization = `Bearer ${token}`;
}
return (authenticate as any)(req, res, next);
}
router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { assetId } = req.params;
const { userId } = req.query;
const targetUserId = userId ? Number(userId) : authReq.user.id;
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any;
if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found');
try {
const resp = await fetch(`${user.immich_url}/api/assets/${assetId}/thumbnail`, {
headers: { 'x-api-key': user.immich_api_key },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return res.status(resp.status).send('Failed');
res.set('Content-Type', resp.headers.get('content-type') || 'image/webp');
res.set('Cache-Control', 'public, max-age=86400');
const buffer = Buffer.from(await resp.arrayBuffer());
res.send(buffer);
} catch {
res.status(502).send('Proxy error');
}
});
router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { assetId } = req.params;
const { userId } = req.query;
const targetUserId = userId ? Number(userId) : authReq.user.id;
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any;
if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found');
try {
const resp = await fetch(`${user.immich_url}/api/assets/${assetId}/original`, {
headers: { 'x-api-key': user.immich_api_key },
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) return res.status(resp.status).send('Failed');
res.set('Content-Type', resp.headers.get('content-type') || 'image/jpeg');
res.set('Cache-Control', 'public, max-age=86400');
const buffer = Buffer.from(await resp.arrayBuffer());
res.send(buffer);
} catch {
res.status(502).send('Proxy error');
}
});
export default router;
+64
View File
@@ -474,4 +474,68 @@ router.get('/reverse', authenticate, async (req: Request, res: Response) => {
}
});
// Resolve a Google Maps URL to place data (coordinates, name, address)
router.post('/resolve-url', authenticate, async (req: Request, res: Response) => {
const { url } = req.body;
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
try {
let resolvedUrl = url;
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl)
if (url.includes('goo.gl') || url.includes('maps.app')) {
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
resolvedUrl = redirectRes.url;
}
// Extract coordinates from Google Maps URL patterns:
// /@48.8566,2.3522,15z or /place/.../@48.8566,2.3522
// ?q=48.8566,2.3522 or ?ll=48.8566,2.3522
let lat: number | null = null;
let lng: number | null = null;
let placeName: string | null = null;
// Pattern: /@lat,lng
const atMatch = resolvedUrl.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
if (atMatch) { lat = parseFloat(atMatch[1]); lng = parseFloat(atMatch[2]); }
// Pattern: !3dlat!4dlng (Google Maps data params)
if (!lat) {
const dataMatch = resolvedUrl.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/);
if (dataMatch) { lat = parseFloat(dataMatch[1]); lng = parseFloat(dataMatch[2]); }
}
// Pattern: ?q=lat,lng or &q=lat,lng
if (!lat) {
const qMatch = resolvedUrl.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/);
if (qMatch) { lat = parseFloat(qMatch[1]); lng = parseFloat(qMatch[2]); }
}
// Extract place name from URL path: /place/Place+Name/@...
const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/);
if (placeMatch) {
placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
}
if (!lat || !lng || isNaN(lat) || isNaN(lng)) {
return res.status(400).json({ error: 'Could not extract coordinates from URL' });
}
// Reverse geocode to get address
const nominatimRes = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`,
{ headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, signal: AbortSignal.timeout(8000) }
);
const nominatim = await nominatimRes.json() as { display_name?: string; name?: string; address?: Record<string, string> };
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
const address = nominatim.display_name || null;
res.json({ lat, lng, name, address });
} catch (err: unknown) {
console.error('[Maps] URL resolve error:', err instanceof Error ? err.message : err);
res.status(400).json({ error: 'Failed to resolve URL' });
}
});
export default router;
+58
View File
@@ -0,0 +1,58 @@
import express, { Request, Response } from 'express';
import { db } from '../db/database';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import { testSmtp } from '../services/notifications';
const router = express.Router();
// Get user's notification preferences
router.get('/preferences', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
let prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
if (!prefs) {
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(authReq.user.id);
prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
}
res.json({ preferences: prefs });
});
// Update user's notification preferences
router.put('/preferences', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook } = req.body;
// Ensure row exists
const existing = db.prepare('SELECT id FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
if (!existing) {
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(authReq.user.id);
}
db.prepare(`UPDATE notification_preferences SET
notify_trip_invite = COALESCE(?, notify_trip_invite),
notify_booking_change = COALESCE(?, notify_booking_change),
notify_trip_reminder = COALESCE(?, notify_trip_reminder),
notify_webhook = COALESCE(?, notify_webhook)
WHERE user_id = ?`).run(
notify_trip_invite !== undefined ? (notify_trip_invite ? 1 : 0) : null,
notify_booking_change !== undefined ? (notify_booking_change ? 1 : 0) : null,
notify_trip_reminder !== undefined ? (notify_trip_reminder ? 1 : 0) : null,
notify_webhook !== undefined ? (notify_webhook ? 1 : 0) : null,
authReq.user.id
);
const prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
res.json({ preferences: prefs });
});
// Admin: test SMTP configuration
router.post('/test-smtp', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (authReq.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
const { email } = req.body;
const result = await testSmtp(email || authReq.user.email);
res.json(result);
});
export default router;
+51 -4
View File
@@ -24,6 +24,9 @@ interface OidcUserInfo {
email?: string;
name?: string;
preferred_username?: string;
groups?: string[];
roles?: string[];
[key: string]: unknown;
}
const router = express.Router();
@@ -41,7 +44,7 @@ setInterval(() => {
}
}, AUTH_CODE_CLEANUP);
const pendingStates = new Map<string, { createdAt: number; redirectUri: string }>();
const pendingStates = new Map<string, { createdAt: number; redirectUri: string; inviteToken?: string }>();
setInterval(() => {
const now = Date.now();
@@ -85,6 +88,23 @@ function generateToken(user: { id: number; username: string; email: string; role
);
}
// Check if user should be admin based on OIDC claims
// Env: OIDC_ADMIN_CLAIM (default: "groups"), OIDC_ADMIN_VALUE (required, e.g. "app-trek-admins")
function resolveOidcRole(userInfo: OidcUserInfo, isFirstUser: boolean): 'admin' | 'user' {
if (isFirstUser) return 'admin';
const adminValue = process.env.OIDC_ADMIN_VALUE;
if (!adminValue) return 'user'; // No claim mapping configured
const claimKey = process.env.OIDC_ADMIN_CLAIM || 'groups';
const claimData = userInfo[claimKey];
if (Array.isArray(claimData)) {
return claimData.some(v => String(v) === adminValue) ? 'admin' : 'user';
}
if (typeof claimData === 'string') {
return claimData === adminValue ? 'admin' : 'user';
}
return 'user';
}
function frontendUrl(path: string): string {
const base = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:5173';
return base + path;
@@ -104,8 +124,9 @@ router.get('/login', async (req: Request, res: Response) => {
const proto = (req.headers['x-forwarded-proto'] as string) || req.protocol;
const host = (req.headers['x-forwarded-host'] as string) || req.headers.host;
const redirectUri = `${proto}://${host}/api/auth/oidc/callback`;
const inviteToken = req.query.invite as string | undefined;
pendingStates.set(state, { createdAt: Date.now(), redirectUri });
pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken });
const params = new URLSearchParams({
response_type: 'code',
@@ -190,18 +211,35 @@ router.get('/callback', async (req: Request, res: Response) => {
if (!user.oidc_sub) {
db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id);
}
// Update role based on OIDC claims on every login (if claim mapping is configured)
if (process.env.OIDC_ADMIN_VALUE) {
const newRole = resolveOidcRole(userInfo, false);
if (user.role !== newRole) {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id);
user = { ...user, role: newRole } as User;
}
}
} else {
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
const isFirstUser = userCount === 0;
if (!isFirstUser) {
let validInvite: any = null;
if (pending.inviteToken) {
validInvite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(pending.inviteToken);
if (validInvite) {
if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) validInvite = null;
if (validInvite?.expires_at && new Date(validInvite.expires_at) < new Date()) validInvite = null;
}
}
if (!isFirstUser && !validInvite) {
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
if (setting?.value === 'false') {
return res.redirect(frontendUrl('/login?oidc_error=registration_disabled'));
}
}
const role = isFirstUser ? 'admin' : 'user';
const role = resolveOidcRole(userInfo, isFirstUser);
const randomPass = crypto.randomBytes(32).toString('hex');
const bcrypt = require('bcryptjs');
const hash = bcrypt.hashSync(randomPass, 10);
@@ -214,6 +252,15 @@ router.get('/callback', async (req: Request, res: Response) => {
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer) VALUES (?, ?, ?, ?, ?, ?)'
).run(username, email, hash, role, sub, config.issuer);
if (validInvite) {
const updated = db.prepare(
'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)'
).run(validInvite.id);
if (updated.changes === 0) {
console.warn(`[OIDC] Invite token ${pending.inviteToken?.slice(0, 8)}... exceeded max_uses (race condition)`);
}
}
user = { id: Number(result.lastInsertRowid), username, email, role } as User;
}
+59
View File
@@ -24,6 +24,53 @@ router.get('/', authenticate, (req: Request, res: Response) => {
res.json({ items });
});
// Bulk import packing items (must be before /:id)
router.post('/import', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { items } = req.body; // [{ name, category?, quantity? }]
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!Array.isArray(items) || items.length === 0) return res.status(400).json({ error: 'items must be a non-empty array' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const stmt = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
const created: any[] = [];
const insertAll = db.transaction(() => {
for (const item of items) {
if (!item.name?.trim()) continue;
const checked = item.checked ? 1 : 0;
const weight = item.weight_grams ? parseInt(item.weight_grams) || null : null;
// Resolve bag by name if provided
let bagId = null;
if (item.bag?.trim()) {
const bagName = item.bag.trim();
const existing = db.prepare('SELECT id FROM packing_bags WHERE trip_id = ? AND name = ?').get(tripId, bagName) as { id: number } | undefined;
if (existing) {
bagId = existing.id;
} else {
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
const bagCount = (db.prepare('SELECT COUNT(*) as c FROM packing_bags WHERE trip_id = ?').get(tripId) as { c: number }).c;
const newBag = db.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(tripId, bagName, BAG_COLORS[bagCount % BAG_COLORS.length]);
bagId = newBag.lastInsertRowid;
}
}
const result = stmt.run(tripId, item.name.trim(), checked, item.category?.trim() || 'Other', weight, bagId, sortOrder++);
created.push(db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid));
}
});
insertAll();
res.status(201).json({ items: created, count: created.length });
for (const item of created) {
broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id'] as string);
}
});
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
@@ -231,6 +278,18 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
res.json({ assignees: rows });
broadcast(tripId, 'packing:assignees', { category: cat, assignees: rows }, req.headers['x-socket-id'] as string);
// Notify newly assigned users
if (Array.isArray(user_ids) && user_ids.length > 0) {
import('../services/notifications').then(({ notify }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
for (const uid of user_ids) {
if (uid !== authReq.user.id) {
notify({ userId: uid, event: 'packing_tagged', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, category: cat } }).catch(() => {});
}
}
});
}
});
router.put('/reorder', authenticate, (req: Request, res: Response) => {
+91
View File
@@ -1,5 +1,6 @@
import express, { Request, Response } from 'express';
import fetch from 'node-fetch';
import multer from 'multer';
import { db, getPlaceWithTags } from '../db/database';
import { authenticate } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess';
@@ -8,6 +9,8 @@ import { loadTagsByPlaceIds } from '../services/queryHelpers';
import { validateStringLengths } from '../middleware/validate';
import { AuthRequest, Place } from '../types';
const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
interface PlaceWithCategory extends Place {
category_name: string | null;
category_color: string | null;
@@ -112,6 +115,94 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
});
// Import places from GPX file (must be before /:id)
router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('file'), (req: Request, res: Response) => {
const { tripId } = req.params;
const file = (req as any).file;
if (!file) return res.status(400).json({ error: 'No file uploaded' });
const xml = file.buffer.toString('utf-8');
const parseCoords = (attrs: string): { lat: number; lng: number } | null => {
const latMatch = attrs.match(/lat=["']([^"']+)["']/i);
const lonMatch = attrs.match(/lon=["']([^"']+)["']/i);
if (!latMatch || !lonMatch) return null;
const lat = parseFloat(latMatch[1]);
const lng = parseFloat(lonMatch[1]);
return (!isNaN(lat) && !isNaN(lng)) ? { lat, lng } : null;
};
const stripCdata = (s: string) => s.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').trim();
const extractName = (body: string) => { const m = body.match(/<name[^>]*>([\s\S]*?)<\/name>/i); return m ? stripCdata(m[1]) : null };
const extractDesc = (body: string) => { const m = body.match(/<desc[^>]*>([\s\S]*?)<\/desc>/i); return m ? stripCdata(m[1]) : null };
const waypoints: { name: string; lat: number; lng: number; description: string | null }[] = [];
// 1) Parse <wpt> elements (named waypoints / POIs)
const wptRegex = /<wpt\s([^>]+)>([\s\S]*?)<\/wpt>/gi;
let match;
while ((match = wptRegex.exec(xml)) !== null) {
const coords = parseCoords(match[1]);
if (!coords) continue;
const name = extractName(match[2]) || `Waypoint ${waypoints.length + 1}`;
waypoints.push({ ...coords, name, description: extractDesc(match[2]) });
}
// 2) If no <wpt>, try <rtept> (route points)
if (waypoints.length === 0) {
const rteptRegex = /<rtept\s([^>]+)>([\s\S]*?)<\/rtept>/gi;
while ((match = rteptRegex.exec(xml)) !== null) {
const coords = parseCoords(match[1]);
if (!coords) continue;
const name = extractName(match[2]) || `Route Point ${waypoints.length + 1}`;
waypoints.push({ ...coords, name, description: extractDesc(match[2]) });
}
}
// 3) If still nothing, extract track name + start/end points from <trkpt>
if (waypoints.length === 0) {
const trackNameMatch = xml.match(/<trk[^>]*>[\s\S]*?<name[^>]*>([\s\S]*?)<\/name>/i);
const trackName = trackNameMatch?.[1]?.trim() || 'GPX Track';
const trkptRegex = /<trkpt\s([^>]*?)(?:\/>|>([\s\S]*?)<\/trkpt>)/gi;
const trackPoints: { lat: number; lng: number }[] = [];
while ((match = trkptRegex.exec(xml)) !== null) {
const coords = parseCoords(match[1]);
if (coords) trackPoints.push(coords);
}
if (trackPoints.length > 0) {
const start = trackPoints[0];
waypoints.push({ ...start, name: `${trackName} — Start`, description: null });
if (trackPoints.length > 1) {
const end = trackPoints[trackPoints.length - 1];
waypoints.push({ ...end, name: `${trackName} — End`, description: null });
}
}
}
if (waypoints.length === 0) {
return res.status(400).json({ error: 'No waypoints found in GPX file' });
}
const insertStmt = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, transport_mode)
VALUES (?, ?, ?, ?, ?, 'walking')
`);
const created: any[] = [];
const insertAll = db.transaction(() => {
for (const wp of waypoints) {
const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng);
const place = getPlaceWithTags(Number(result.lastInsertRowid));
created.push(place);
}
});
insertAll();
res.status(201).json({ places: created, count: created.length });
for (const place of created) {
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
}
});
router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params
+29
View File
@@ -101,6 +101,35 @@ router.post('/', authenticate, (req: Request, res: Response) => {
res.status(201).json({ reservation });
broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id'] as string);
// Notify trip members about new booking
import('../services/notifications').then(({ notifyTripMembers }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, booking: title, type: type || 'booking' }).catch(() => {});
});
});
// Batch update day_plan_position for multiple reservations (must be before /:id)
router.put('/positions', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { positions } = req.body;
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' });
const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?');
const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => {
for (const item of items) {
stmt.run(item.day_plan_position, item.id, tripId);
}
});
updateMany(positions);
res.json({ success: true });
broadcast(tripId, 'reservation:positions', { positions }, req.headers['x-socket-id'] as string);
});
router.put('/:id', authenticate, (req: Request, res: Response) => {
+165
View File
@@ -0,0 +1,165 @@
import express, { Request, Response } from 'express';
import crypto from 'crypto';
import { db, canAccessTrip } from '../db/database';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import { loadTagsByPlaceIds } from '../services/queryHelpers';
const router = express.Router();
// Create a share link for a trip (owner/member only)
router.post('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const { share_map = true, share_bookings = true, share_packing = false, share_budget = false, share_collab = false } = req.body || {};
// Check if token already exists
const existing = db.prepare('SELECT token FROM share_tokens WHERE trip_id = ?').get(tripId) as { token: string } | undefined;
if (existing) {
// Update permissions
db.prepare('UPDATE share_tokens SET share_map = ?, share_bookings = ?, share_packing = ?, share_budget = ?, share_collab = ? WHERE trip_id = ?')
.run(share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, tripId);
return res.json({ token: existing.token });
}
const token = crypto.randomBytes(24).toString('base64url');
db.prepare('INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
.run(tripId, token, authReq.user.id, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0);
res.status(201).json({ token });
});
// Get share link status
router.get('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const row = db.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any;
res.json(row ? { token: row.token, created_at: row.created_at, share_map: !!row.share_map, share_bookings: !!row.share_bookings, share_packing: !!row.share_packing, share_budget: !!row.share_budget, share_collab: !!row.share_collab } : { token: null });
});
// Delete share link
router.delete('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId);
res.json({ success: true });
});
// Public read-only trip data (no auth required)
router.get('/shared/:token', (req: Request, res: Response) => {
const { token } = req.params;
const shareRow = db.prepare('SELECT * FROM share_tokens WHERE token = ?').get(token) as any;
if (!shareRow) return res.status(404).json({ error: 'Invalid or expired link' });
const tripId = shareRow.trip_id;
// Trip
const trip = db.prepare('SELECT id, title, description, start_date, end_date, cover_image, currency FROM trips WHERE id = ?').get(tripId);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
// Days with assignments
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as any[];
const dayIds = days.map(d => d.id);
let assignments = {};
let dayNotes = {};
if (dayIds.length > 0) {
const ph = dayIds.map(() => '?').join(',');
const allAssignments = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes, p.image_url, p.transport_mode,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.day_id IN (${ph})
ORDER BY da.order_index ASC
`).all(...dayIds);
const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))];
const tagsByPlace = loadTagsByPlaceIds(placeIds, { compact: true });
const byDay: Record<number, any[]> = {};
for (const a of allAssignments as any[]) {
if (!byDay[a.day_id]) byDay[a.day_id] = [];
byDay[a.day_id].push({
id: a.id, day_id: a.day_id, order_index: a.order_index, notes: a.notes,
place: {
id: a.place_id, name: a.place_name, description: a.place_description,
lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id,
price: a.price, place_time: a.place_time, end_time: a.end_time,
image_url: a.image_url, transport_mode: a.transport_mode,
category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null,
tags: tagsByPlace[a.place_id] || [],
}
});
}
assignments = byDay;
const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC`).all(...dayIds);
const notesByDay: Record<number, any[]> = {};
for (const n of allNotes as any[]) {
if (!notesByDay[n.day_id]) notesByDay[n.day_id] = [];
notesByDay[n.day_id].push(n);
}
dayNotes = notesByDay;
}
// Places
const places = db.prepare(`
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p LEFT JOIN categories c ON p.category_id = c.id
WHERE p.trip_id = ? ORDER BY p.created_at DESC
`).all(tripId);
// Reservations
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId);
// Accommodations
const accommodations = db.prepare(`
SELECT a.*, p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng
FROM day_accommodations a JOIN places p ON a.place_id = p.id
WHERE a.trip_id = ?
`).all(tripId);
// Packing
const packing = db.prepare('SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC').all(tripId);
// Budget
const budget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC').all(tripId);
// Categories
const categories = db.prepare('SELECT * FROM categories').all();
const permissions = {
share_map: !!shareRow.share_map,
share_bookings: !!shareRow.share_bookings,
share_packing: !!shareRow.share_packing,
share_budget: !!shareRow.share_budget,
share_collab: !!shareRow.share_collab,
};
// Only include data the owner chose to share
const collabMessages = permissions.share_collab
? db.prepare('SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? ORDER BY m.created_at ASC').all(tripId)
: [];
res.json({
trip, days, assignments, dayNotes, places, categories, permissions,
reservations: permissions.share_bookings ? reservations : [],
accommodations: permissions.share_bookings ? accommodations : [],
packing: permissions.share_packing ? packing : [],
budget: permissions.share_budget ? budget : [],
collab: collabMessages,
});
});
export default router;
+85
View File
@@ -284,6 +284,12 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => {
db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(req.params.id, target.id, authReq.user.id);
// Notify invited user
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(req.params.id) as { title: string } | undefined;
import('../services/notifications').then(({ notify }) => {
notify({ userId: target.id, event: 'trip_invite', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username } }).catch(() => {});
});
res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } });
});
@@ -301,4 +307,83 @@ router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response
res.json({ success: true });
});
// ICS calendar export
router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!canAccessTrip(req.params.id, authReq.user.id))
return res.status(404).json({ error: 'Trip not found' });
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as any;
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(req.params.id) as any[];
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(req.params.id) as any[];
const esc = (s: string) => s.replace(/[\\;,\n]/g, m => m === '\n' ? '\\n' : '\\' + m);
const fmtDate = (d: string) => d.replace(/-/g, '');
const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
const uid = (id: number, type: string) => `trek-${type}-${id}@trek`;
// Format datetime: handles full ISO "2026-03-30T09:00" and time-only "10:00"
const fmtDateTime = (d: string, refDate?: string) => {
if (d.includes('T')) return d.replace(/[-:]/g, '').split('.')[0];
// Time-only: combine with reference date
if (refDate && d.match(/^\d{2}:\d{2}/)) {
const datePart = refDate.split('T')[0];
return `${datePart}T${d.replace(/:/g, '')}00`.replace(/-/g, '');
}
return d.replace(/[-:]/g, '');
};
let ics = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n';
ics += `X-WR-CALNAME:${esc(trip.title || 'TREK Trip')}\r\n`;
// Trip as all-day event
if (trip.start_date && trip.end_date) {
const endNext = new Date(trip.end_date + 'T00:00:00');
endNext.setDate(endNext.getDate() + 1);
const endStr = endNext.toISOString().split('T')[0].replace(/-/g, '');
ics += `BEGIN:VEVENT\r\nUID:${uid(trip.id, 'trip')}\r\nDTSTAMP:${now}\r\nDTSTART;VALUE=DATE:${fmtDate(trip.start_date)}\r\nDTEND;VALUE=DATE:${endStr}\r\nSUMMARY:${esc(trip.title || 'Trip')}\r\n`;
if (trip.description) ics += `DESCRIPTION:${esc(trip.description)}\r\n`;
ics += `END:VEVENT\r\n`;
}
// Reservations as events
for (const r of reservations) {
if (!r.reservation_time) continue;
const hasTime = r.reservation_time.includes('T');
const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {};
ics += `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`;
if (hasTime) {
ics += `DTSTART:${fmtDateTime(r.reservation_time)}\r\n`;
if (r.reservation_end_time) {
const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time);
if (endDt.length >= 15) ics += `DTEND:${endDt}\r\n`;
}
} else {
ics += `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`;
}
ics += `SUMMARY:${esc(r.title)}\r\n`;
let desc = r.type ? `Type: ${r.type}` : '';
if (r.confirmation_number) desc += `\\nConfirmation: ${r.confirmation_number}`;
if (meta.airline) desc += `\\nAirline: ${meta.airline}`;
if (meta.flight_number) desc += `\\nFlight: ${meta.flight_number}`;
if (meta.departure_airport) desc += `\\nFrom: ${meta.departure_airport}`;
if (meta.arrival_airport) desc += `\\nTo: ${meta.arrival_airport}`;
if (meta.train_number) desc += `\\nTrain: ${meta.train_number}`;
if (r.notes) desc += `\\n${r.notes}`;
if (desc) ics += `DESCRIPTION:${desc}\r\n`;
if (r.location) ics += `LOCATION:${esc(r.location)}\r\n`;
ics += `END:VEVENT\r\n`;
}
ics += 'END:VCALENDAR\r\n';
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${esc(trip.title || 'trek-trip')}.ics"`);
res.send(ics);
});
export default router;
+7 -1
View File
@@ -196,7 +196,7 @@ router.get('/plan', (req: Request, res: Response) => {
router.put('/plan', async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const planId = getActivePlanId(authReq.user.id);
const { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled } = req.body;
const { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled, weekend_days } = req.body;
const updates: string[] = [];
const params: (string | number)[] = [];
@@ -205,6 +205,7 @@ router.put('/plan', async (req: Request, res: Response) => {
if (holidays_region !== undefined) { updates.push('holidays_region = ?'); params.push(holidays_region); }
if (company_holidays_enabled !== undefined) { updates.push('company_holidays_enabled = ?'); params.push(company_holidays_enabled ? 1 : 0); }
if (carry_over_enabled !== undefined) { updates.push('carry_over_enabled = ?'); params.push(carry_over_enabled ? 1 : 0); }
if (weekend_days !== undefined) { updates.push('weekend_days = ?'); params.push(String(weekend_days)); }
if (updates.length > 0) {
params.push(planId);
@@ -348,6 +349,11 @@ router.post('/invite', (req: Request, res: Response) => {
});
} catch { /* websocket not available */ }
// Notify invited user
import('../services/notifications').then(({ notify }) => {
notify({ userId: user_id, event: 'vacay_invite', params: { actor: authReq.user.username } }).catch(() => {});
});
res.json({ success: true });
});
+32 -13
View File
@@ -8,30 +8,48 @@ const backupsDir = path.join(dataDir, 'backups');
const uploadsDir = path.join(__dirname, '../uploads');
const settingsFile = path.join(dataDir, 'backup-settings.json');
const CRON_EXPRESSIONS: Record<string, string> = {
hourly: '0 * * * *',
daily: '0 2 * * *',
weekly: '0 2 * * 0',
monthly: '0 2 1 * *',
};
const VALID_INTERVALS = Object.keys(CRON_EXPRESSIONS);
const VALID_INTERVALS = ['hourly', 'daily', 'weekly', 'monthly'];
const VALID_DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6]; // 0=Sunday
const VALID_HOURS = Array.from({ length: 24 }, (_, i) => i);
interface BackupSettings {
enabled: boolean;
interval: string;
keep_days: number;
hour: number;
day_of_week: number;
day_of_month: number;
}
function buildCronExpression(settings: BackupSettings): string {
const hour = VALID_HOURS.includes(settings.hour) ? settings.hour : 2;
const dow = VALID_DAYS_OF_WEEK.includes(settings.day_of_week) ? settings.day_of_week : 0;
const dom = settings.day_of_month >= 1 && settings.day_of_month <= 28 ? settings.day_of_month : 1;
switch (settings.interval) {
case 'hourly': return '0 * * * *';
case 'daily': return `0 ${hour} * * *`;
case 'weekly': return `0 ${hour} * * ${dow}`;
case 'monthly': return `0 ${hour} ${dom} * *`;
default: return `0 ${hour} * * *`;
}
}
let currentTask: ScheduledTask | null = null;
function getDefaults(): BackupSettings {
return { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 };
}
function loadSettings(): BackupSettings {
let settings = getDefaults();
try {
if (fs.existsSync(settingsFile)) {
return JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
const saved = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
settings = { ...settings, ...saved };
}
} catch (e) {}
return { enabled: false, interval: 'daily', keep_days: 7 };
return settings;
}
function saveSettings(settings: BackupSettings): void {
@@ -104,9 +122,10 @@ function start(): void {
return;
}
const expression = CRON_EXPRESSIONS[settings.interval] || CRON_EXPRESSIONS.daily;
currentTask = cron.schedule(expression, runBackup);
console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
const expression = buildCronExpression(settings);
const tz = process.env.TZ || 'UTC';
currentTask = cron.schedule(expression, runBackup, { timezone: tz });
console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
}
// Demo mode: hourly reset of demo user data
+30
View File
@@ -0,0 +1,30 @@
import { Request } from 'express';
import { db } from '../db/database';
export function getClientIp(req: Request): string | null {
const xff = req.headers['x-forwarded-for'];
if (typeof xff === 'string') {
const first = xff.split(',')[0]?.trim();
return first || null;
}
if (Array.isArray(xff) && xff[0]) return String(xff[0]).trim() || null;
return req.socket?.remoteAddress || null;
}
/** Best-effort; never throws — failures are logged only. */
export function writeAudit(entry: {
userId: number | null;
action: string;
resource?: string | null;
details?: Record<string, unknown>;
ip?: string | null;
}): void {
try {
const detailsJson = entry.details && Object.keys(entry.details).length > 0 ? JSON.stringify(entry.details) : null;
db.prepare(
`INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)`
).run(entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null);
} catch (e) {
console.error('[audit] write failed:', e instanceof Error ? e.message : e);
}
}
+297
View File
@@ -0,0 +1,297 @@
import nodemailer from 'nodemailer';
import fetch from 'node-fetch';
import { db } from '../db/database';
// ── Types ──────────────────────────────────────────────────────────────────
type EventType = 'trip_invite' | 'booking_change' | 'trip_reminder' | 'vacay_invite' | 'photos_shared' | 'collab_message' | 'packing_tagged';
interface NotificationPayload {
userId: number;
event: EventType;
params: Record<string, string>;
}
interface SmtpConfig {
host: string;
port: number;
user: string;
pass: string;
from: string;
secure: boolean;
}
// ── Settings helpers ───────────────────────────────────────────────────────
function getAppSetting(key: string): string | null {
return (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null;
}
function getSmtpConfig(): SmtpConfig | null {
const host = process.env.SMTP_HOST || getAppSetting('smtp_host');
const port = process.env.SMTP_PORT || getAppSetting('smtp_port');
const user = process.env.SMTP_USER || getAppSetting('smtp_user');
const pass = process.env.SMTP_PASS || getAppSetting('smtp_pass');
const from = process.env.SMTP_FROM || getAppSetting('smtp_from');
if (!host || !port || !from) return null;
return { host, port: parseInt(port, 10), user: user || '', pass: pass || '', from, secure: parseInt(port, 10) === 465 };
}
function getWebhookUrl(): string | null {
return process.env.NOTIFICATION_WEBHOOK_URL || getAppSetting('notification_webhook_url');
}
function getAppUrl(): string {
return process.env.APP_URL || getAppSetting('app_url') || '';
}
function getUserEmail(userId: number): string | null {
return (db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined)?.email || null;
}
function getUserLanguage(userId: number): string {
return (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'language'").get(userId) as { value: string } | undefined)?.value || 'en';
}
function getUserPrefs(userId: number): Record<string, number> {
const row = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId) as any;
return row || { notify_trip_invite: 1, notify_booking_change: 1, notify_trip_reminder: 1, notify_vacay_invite: 1, notify_photos_shared: 1, notify_collab_message: 1, notify_packing_tagged: 1, notify_webhook: 0 };
}
// Event → preference column mapping
const EVENT_PREF_MAP: Record<EventType, string> = {
trip_invite: 'notify_trip_invite',
booking_change: 'notify_booking_change',
trip_reminder: 'notify_trip_reminder',
vacay_invite: 'notify_vacay_invite',
photos_shared: 'notify_photos_shared',
collab_message: 'notify_collab_message',
packing_tagged: 'notify_packing_tagged',
};
// ── Email i18n strings ─────────────────────────────────────────────────────
interface EmailStrings { footer: string; manage: string; madeWith: string; openTrek: string }
const I18N: Record<string, EmailStrings> = {
en: { footer: 'You received this because you have notifications enabled in TREK.', manage: 'Manage preferences in Settings', madeWith: 'Made with', openTrek: 'Open TREK' },
de: { footer: 'Du erhältst diese E-Mail, weil du Benachrichtigungen in TREK aktiviert hast.', manage: 'Einstellungen verwalten', madeWith: 'Made with', openTrek: 'TREK öffnen' },
fr: { footer: 'Vous recevez cet e-mail car les notifications sont activées dans TREK.', manage: 'Gérer les préférences', madeWith: 'Made with', openTrek: 'Ouvrir TREK' },
es: { footer: 'Recibiste esto porque tienes las notificaciones activadas en TREK.', manage: 'Gestionar preferencias', madeWith: 'Made with', openTrek: 'Abrir TREK' },
nl: { footer: 'Je ontvangt dit omdat je meldingen hebt ingeschakeld in TREK.', manage: 'Voorkeuren beheren', madeWith: 'Made with', openTrek: 'TREK openen' },
ru: { footer: 'Вы получили это, потому что у вас включены уведомления в TREK.', manage: 'Управление настройками', madeWith: 'Made with', openTrek: 'Открыть TREK' },
zh: { footer: '您收到此邮件是因为您在 TREK 中启用了通知。', manage: '管理偏好设置', madeWith: 'Made with', openTrek: '打开 TREK' },
ar: { footer: 'تلقيت هذا لأنك قمت بتفعيل الإشعارات في TREK.', manage: 'إدارة التفضيلات', madeWith: 'Made with', openTrek: 'فتح TREK' },
};
// Translated notification texts per event type
interface EventText { title: string; body: string }
type EventTextFn = (params: Record<string, string>) => EventText
const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
en: {
trip_invite: p => ({ title: `You've been invited to "${p.trip}"`, body: `${p.actor} invited you to the trip "${p.trip}". Open TREK to view and start planning!` }),
booking_change: p => ({ title: `New booking: ${p.booking}`, body: `${p.actor} added a new ${p.type} "${p.booking}" to "${p.trip}".` }),
trip_reminder: p => ({ title: `Trip reminder: ${p.trip}`, body: `Your trip "${p.trip}" is coming up soon!` }),
vacay_invite: p => ({ title: 'Vacay Fusion Invite', body: `${p.actor} invited you to fuse vacation plans. Open TREK to accept or decline.` }),
photos_shared: p => ({ title: `${p.count} photos shared`, body: `${p.actor} shared ${p.count} photo(s) in "${p.trip}".` }),
collab_message: p => ({ title: `New message in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Packing: ${p.category}`, body: `${p.actor} assigned you to the "${p.category}" packing category in "${p.trip}".` }),
},
de: {
trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat dich zur Reise "${p.trip}" eingeladen. Öffne TREK um die Planung zu starten!` }),
booking_change: p => ({ title: `Neue Buchung: ${p.booking}`, body: `${p.actor} hat eine neue Buchung "${p.booking}" (${p.type}) zu "${p.trip}" hinzugefügt.` }),
trip_reminder: p => ({ title: `Reiseerinnerung: ${p.trip}`, body: `Deine Reise "${p.trip}" steht bald an!` }),
vacay_invite: p => ({ title: 'Vacay Fusion-Einladung', body: `${p.actor} hat dich eingeladen, Urlaubspläne zu fusionieren. Öffne TREK um anzunehmen oder abzulehnen.` }),
photos_shared: p => ({ title: `${p.count} Fotos geteilt`, body: `${p.actor} hat ${p.count} Foto(s) in "${p.trip}" geteilt.` }),
collab_message: p => ({ title: `Neue Nachricht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Packliste: ${p.category}`, body: `${p.actor} hat dich der Kategorie "${p.category}" in der Packliste von "${p.trip}" zugewiesen.` }),
},
fr: {
trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} vous a invité au voyage "${p.trip}". Ouvrez TREK pour commencer la planification !` }),
booking_change: p => ({ title: `Nouvelle réservation : ${p.booking}`, body: `${p.actor} a ajouté une réservation "${p.booking}" (${p.type}) à "${p.trip}".` }),
trip_reminder: p => ({ title: `Rappel de voyage : ${p.trip}`, body: `Votre voyage "${p.trip}" approche !` }),
vacay_invite: p => ({ title: 'Invitation Vacay Fusion', body: `${p.actor} vous invite à fusionner les plans de vacances. Ouvrez TREK pour accepter ou refuser.` }),
photos_shared: p => ({ title: `${p.count} photos partagées`, body: `${p.actor} a partagé ${p.count} photo(s) dans "${p.trip}".` }),
collab_message: p => ({ title: `Nouveau message dans "${p.trip}"`, body: `${p.actor} : ${p.preview}` }),
packing_tagged: p => ({ title: `Bagages : ${p.category}`, body: `${p.actor} vous a assigné à la catégorie "${p.category}" dans "${p.trip}".` }),
},
es: {
trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} te invitó al viaje "${p.trip}". ¡Abre TREK para comenzar a planificar!` }),
booking_change: p => ({ title: `Nueva reserva: ${p.booking}`, body: `${p.actor} añadió una reserva "${p.booking}" (${p.type}) a "${p.trip}".` }),
trip_reminder: p => ({ title: `Recordatorio: ${p.trip}`, body: `¡Tu viaje "${p.trip}" se acerca!` }),
vacay_invite: p => ({ title: 'Invitación Vacay Fusion', body: `${p.actor} te invitó a fusionar planes de vacaciones. Abre TREK para aceptar o rechazar.` }),
photos_shared: p => ({ title: `${p.count} fotos compartidas`, body: `${p.actor} compartió ${p.count} foto(s) en "${p.trip}".` }),
collab_message: p => ({ title: `Nuevo mensaje en "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Equipaje: ${p.category}`, body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".` }),
},
nl: {
trip_invite: p => ({ title: `Uitgenodigd voor "${p.trip}"`, body: `${p.actor} heeft je uitgenodigd voor de reis "${p.trip}". Open TREK om te beginnen met plannen!` }),
booking_change: p => ({ title: `Nieuwe boeking: ${p.booking}`, body: `${p.actor} heeft een boeking "${p.booking}" (${p.type}) toegevoegd aan "${p.trip}".` }),
trip_reminder: p => ({ title: `Reisherinnering: ${p.trip}`, body: `Je reis "${p.trip}" komt eraan!` }),
vacay_invite: p => ({ title: 'Vacay Fusion uitnodiging', body: `${p.actor} nodigt je uit om vakantieplannen te fuseren. Open TREK om te accepteren of af te wijzen.` }),
photos_shared: p => ({ title: `${p.count} foto's gedeeld`, body: `${p.actor} heeft ${p.count} foto('s) gedeeld in "${p.trip}".` }),
collab_message: p => ({ title: `Nieuw bericht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Paklijst: ${p.category}`, body: `${p.actor} heeft je toegewezen aan de categorie "${p.category}" in "${p.trip}".` }),
},
ru: {
trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил вас в поездку "${p.trip}". Откройте TREK чтобы начать планирование!` }),
booking_change: p => ({ title: `Новое бронирование: ${p.booking}`, body: `${p.actor} добавил бронирование "${p.booking}" (${p.type}) в "${p.trip}".` }),
trip_reminder: p => ({ title: `Напоминание: ${p.trip}`, body: `Ваша поездка "${p.trip}" скоро начнётся!` }),
vacay_invite: p => ({ title: 'Приглашение Vacay Fusion', body: `${p.actor} приглашает вас объединить планы отпуска. Откройте TREK для подтверждения.` }),
photos_shared: p => ({ title: `${p.count} фото`, body: `${p.actor} поделился ${p.count} фото в "${p.trip}".` }),
collab_message: p => ({ title: `Новое сообщение в "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Список вещей: ${p.category}`, body: `${p.actor} назначил вас в категорию "${p.category}" в "${p.trip}".` }),
},
zh: {
trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请你加入旅行"${p.trip}"。打开 TREK 开始规划!` }),
booking_change: p => ({ title: `新预订:${p.booking}`, body: `${p.actor} 在"${p.trip}"中添加了预订"${p.booking}"${p.type})。` }),
trip_reminder: p => ({ title: `旅行提醒:${p.trip}`, body: `你的旅行"${p.trip}"即将开始!` }),
vacay_invite: p => ({ title: 'Vacay 融合邀请', body: `${p.actor} 邀请你合并假期计划。打开 TREK 接受或拒绝。` }),
photos_shared: p => ({ title: `${p.count} 张照片已分享`, body: `${p.actor} 在"${p.trip}"中分享了 ${p.count} 张照片。` }),
collab_message: p => ({ title: `"${p.trip}"中的新消息`, body: `${p.actor}${p.preview}` }),
packing_tagged: p => ({ title: `行李清单:${p.category}`, body: `${p.actor} 将你分配到"${p.trip}"中的"${p.category}"类别。` }),
},
ar: {
trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعاك إلى الرحلة "${p.trip}". افتح TREK لبدء التخطيط!` }),
booking_change: p => ({ title: `حجز جديد: ${p.booking}`, body: `${p.actor} أضاف حجز "${p.booking}" (${p.type}) إلى "${p.trip}".` }),
trip_reminder: p => ({ title: `تذكير: ${p.trip}`, body: `رحلتك "${p.trip}" تقترب!` }),
vacay_invite: p => ({ title: 'دعوة دمج الإجازة', body: `${p.actor} يدعوك لدمج خطط الإجازة. افتح TREK للقبول أو الرفض.` }),
photos_shared: p => ({ title: `${p.count} صور مشتركة`, body: `${p.actor} شارك ${p.count} صورة في "${p.trip}".` }),
collab_message: p => ({ title: `رسالة جديدة في "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `قائمة التعبئة: ${p.category}`, body: `${p.actor} عيّنك في فئة "${p.category}" في "${p.trip}".` }),
},
};
// Get localized event text
function getEventText(lang: string, event: EventType, params: Record<string, string>): EventText {
const texts = EVENT_TEXTS[lang] || EVENT_TEXTS.en;
return texts[event](params);
}
// ── Email HTML builder ─────────────────────────────────────────────────────
function buildEmailHtml(subject: string, body: string, lang: string): string {
const s = I18N[lang] || I18N.en;
const appUrl = getAppUrl();
const ctaHref = appUrl || '#';
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin: 0; padding: 0; background-color: #f3f4f6; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f3f4f6; padding: 40px 20px;">
<tr><td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px; background: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 24px rgba(0,0,0,0.06);">
<!-- Header -->
<tr><td style="background: linear-gradient(135deg, #000000 0%, #1a1a2e 100%); padding: 32px 32px 28px; text-align: center;">
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj4NCiAgPGRlZnM+DQogICAgPGxpbmVhckdyYWRpZW50IGlkPSJiZyIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iIzFlMjkzYiIvPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMGYxNzJhIi8+DQogICAgPC9saW5lYXJHcmFkaWVudD4NCiAgICA8Y2xpcFBhdGggaWQ9Imljb24iPg0KICAgICAgPHBhdGggZD0iTSA4NTUuNjM2NzE5IDY5OS4yMDMxMjUgTCAyMjIuMjQ2MDk0IDY5OS4yMDMxMjUgQyAxOTcuNjc5Njg4IDY5OS4yMDMxMjUgMTc5LjkwNjI1IDY3NS43NSAxODYuNTM5MDYyIDY1Mi4xMDE1NjIgTCAzNjAuNDI5Njg4IDMyLjM5MDYyNSBDIDM2NC45MjE4NzUgMTYuMzg2NzE5IDM3OS41MTE3MTkgNS4zMjgxMjUgMzk2LjEzMjgxMiA1LjMyODEyNSBMIDEwMjkuNTI3MzQ0IDUuMzI4MTI1IEMgMTA1NC4wODk4NDQgNS4zMjgxMjUgMTA3MS44NjcxODggMjguNzc3MzQ0IDEwNjUuMjMwNDY5IDUyLjQyOTY4OCBMIDg5MS4zMzk4NDQgNjcyLjEzNjcxOSBDIDg4Ni44NTE1NjIgNjg4LjE0MDYyNSA4NzIuMjU3ODEyIDY5OS4yMDMxMjUgODU1LjYzNjcxOSA2OTkuMjAzMTI1IFogTSA0NDQuMjM4MjgxIDExNjYuOTgwNDY5IEwgNTMzLjc3MzQzOCA4NDcuODk4NDM4IEMgNTQwLjQxMDE1NiA4MjQuMjQ2MDk0IDUyMi42MzI4MTIgODAwLjc5Njg3NSA0OTguMDcwMzEyIDgwMC43OTY4NzUgTCAxNzIuNDcyNjU2IDgwMC43OTY4NzUgQyAxNTUuODUxNTYyIDgwMC43OTY4NzUgMTQxLjI2MTcxOSA4MTEuODU1NDY5IDEzNi43Njk1MzEgODI3Ljg1OTM3NSBMIDQ3LjIzNDM3NSAxMTQ2Ljk0MTQwNiBDIDQwLjU5NzY1NiAxMTcwLjU5Mzc1IDU4LjM3NSAxMTk0LjA0Mjk2OSA4Mi45Mzc1IDExOTQuMDQyOTY5IEwgNDA4LjUzNTE1NiAxMTk0LjA0Mjk2OSBDIDQyNS4xNTYyNSAxMTk0LjA0Mjk2OSA0MzkuNzUgMTE4Mi45ODQzNzUgNDQ0LjIzODI4MSAxMTY2Ljk4MDQ2OSBaIE0gNjA5LjAwMzkwNiA4MjcuODU5Mzc1IEwgNDM1LjExMzI4MSAxNDQ3LjU3MDMxMiBDIDQyOC40NzY1NjIgMTQ3MS4yMTg3NSA0NDYuMjUzOTA2IDE0OTQuNjcxODc1IDQ3MC44MTY0MDYgMTQ5NC42NzE4NzUgTCAxMTA0LjIxMDkzOCAxNDk0LjY3MTg3NSBDIDExMjAuODMyMDMxIDE0OTQuNjcxODc1IDExMzUuNDIxODc1IDE0ODMuNjA5Mzc1IDExMzkuOTE0MDYyIDE0NjcuNjA1NDY5IEwgMTMxMy44MDQ2ODggODQ3Ljg5ODQzOCBDIDEzMjAuNDQxNDA2IDgyNC4yNDYwOTQgMTMwMi42NjQwNjIgODAwLjc5Njg3NSAxMjc4LjEwMTU2MiA4MDAuNzk2ODc1IEwgNjQ0LjcwNzAzMSA4MDAuNzk2ODc1IEMgNjI4LjA4NTkzOCA4MDAuNzk2ODc1IDYxMy40OTIxODggODExLjg1NTQ2OSA2MDkuMDAzOTA2IDgyNy44NTkzNzUgWiBNIDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgTCA5NjYuNTcwMzEyIDY1Mi4xMDE1NjIgQyA5NTkuOTMzNTk0IDY3NS43NSA5NzcuNzEwOTM4IDY5OS4yMDMxMjUgMTAwMi4yNzM0MzggNjk5LjIwMzEyNSBMIDEzMjcuODcxMDk0IDY5OS4yMDMxMjUgQyAxMzQ0LjQ5MjE4OCA2OTkuMjAzMTI1IDEzNTkuMDg1OTM4IDY4OC4xNDA2MjUgMTM2My41NzQyMTkgNjcyLjEzNjcxOSBMIDE0NTMuMTA5Mzc1IDM1My4wNTQ2ODggQyAxNDU5Ljc0NjA5NCAzMjkuNDA2MjUgMTQ0MS45Njg3NSAzMDUuOTUzMTI1IDE0MTcuNDA2MjUgMzA1Ljk1MzEyNSBMIDEwOTEuODA4NTk0IDMwNS45NTMxMjUgQyAxMDc1LjE4NzUgMzA1Ljk1MzEyNSAxMDYwLjU5NzY1NiAzMTcuMDE1NjI1IDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgWiIvPg0KICAgIDwvY2xpcFBhdGg+DQogIDwvZGVmcz4NCiAgPHJlY3Qgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIGZpbGw9InVybCgjYmcpIi8+DQogIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDU2LDUxKSBzY2FsZSgwLjI2NykiPg0KICAgIDxyZWN0IHdpZHRoPSIxNTAwIiBoZWlnaHQ9IjE1MDAiIGZpbGw9IiNmZmZmZmYiIGNsaXAtcGF0aD0idXJsKCNpY29uKSIvPg0KICA8L2c+DQo8L3N2Zz4NCg==" alt="TREK" width="48" height="48" style="border-radius: 14px; margin-bottom: 14px; display: block; margin-left: auto; margin-right: auto;" />
<div style="color: #ffffff; font-size: 24px; font-weight: 700; letter-spacing: -0.5px;">TREK</div>
<div style="color: rgba(255,255,255,0.4); font-size: 10px; font-weight: 500; letter-spacing: 2px; text-transform: uppercase; margin-top: 4px;">Travel Resource &amp; Exploration Kit</div>
</td></tr>
<!-- Content -->
<tr><td style="padding: 32px 32px 16px;">
<h1 style="margin: 0 0 8px; font-size: 18px; font-weight: 700; color: #111827; line-height: 1.3;">${subject}</h1>
<div style="width: 32px; height: 3px; background: #111827; border-radius: 2px; margin-bottom: 20px;"></div>
<p style="margin: 0; font-size: 14px; color: #4b5563; line-height: 1.7; white-space: pre-wrap;">${body}</p>
</td></tr>
<!-- CTA -->
${appUrl ? `<tr><td style="padding: 8px 32px 32px; text-align: center;">
<a href="${ctaHref}" style="display: inline-block; padding: 12px 28px; background: #111827; color: #ffffff; font-size: 13px; font-weight: 600; text-decoration: none; border-radius: 10px; letter-spacing: 0.2px;">${s.openTrek}</a>
</td></tr>` : ''}
<!-- Footer -->
<tr><td style="padding: 20px 32px; background: #f9fafb; border-top: 1px solid #f3f4f6; text-align: center;">
<p style="margin: 0 0 8px; font-size: 11px; color: #9ca3af; line-height: 1.5;">${s.footer}<br>${s.manage}</p>
<p style="margin: 0; font-size: 10px; color: #d1d5db;">${s.madeWith} <span style="color: #ef4444;">&hearts;</span> by Maurice &middot; <a href="https://github.com/mauriceboe/TREK" style="color: #9ca3af; text-decoration: none;">GitHub</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
// ── Send functions ─────────────────────────────────────────────────────────
async function sendEmail(to: string, subject: string, body: string, userId?: number): Promise<boolean> {
const config = getSmtpConfig();
if (!config) return false;
const lang = userId ? getUserLanguage(userId) : 'en';
try {
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.user ? { user: config.user, pass: config.pass } : undefined,
});
await transporter.sendMail({
from: config.from,
to,
subject: `TREK — ${subject}`,
text: body,
html: buildEmailHtml(subject, body, lang),
});
return true;
} catch (err) {
console.error('[Notifications] Email send failed:', err instanceof Error ? err.message : err);
return false;
}
}
async function sendWebhook(payload: { event: string; title: string; body: string; tripName?: string }): Promise<boolean> {
const url = getWebhookUrl();
if (!url) return false;
try {
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' }),
signal: AbortSignal.timeout(10000),
});
return true;
} catch (err) {
console.error('[Notifications] Webhook failed:', err instanceof Error ? err.message : err);
return false;
}
}
// ── Public API ─────────────────────────────────────────────────────────────
export async function notify(payload: NotificationPayload): Promise<void> {
const prefs = getUserPrefs(payload.userId);
const prefKey = EVENT_PREF_MAP[payload.event];
if (prefKey && !prefs[prefKey]) return;
const lang = getUserLanguage(payload.userId);
const { title, body } = getEventText(lang, payload.event, payload.params);
const email = getUserEmail(payload.userId);
if (email) await sendEmail(email, title, body, payload.userId);
if (prefs.notify_webhook) await sendWebhook({ event: payload.event, title, body, tripName: payload.params.trip });
}
export async function notifyTripMembers(tripId: number, actorUserId: number, event: EventType, params: Record<string, string>): Promise<void> {
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined;
if (!trip) return;
const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(tripId) as { user_id: number }[];
const allIds = [trip.user_id, ...members.map(m => m.user_id)].filter(id => id !== actorUserId);
const unique = [...new Set(allIds)];
for (const userId of unique) {
await notify({ userId, event, params });
}
}
export async function testSmtp(to: string): Promise<{ success: boolean; error?: string }> {
try {
const sent = await sendEmail(to, 'Test Notification', 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.');
return sent ? { success: true } : { success: false, error: 'SMTP not configured' };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
}
}