Compare commits

..

411 Commits

Author SHA1 Message Date
Maurice be248e1ad4 Update Discord link in README.md 2026-04-10 14:13:01 +02:00
github-actions[bot] e290c7c522 chore: bump version to 2.9.12 [skip ci] 2026-04-10 05:51:22 +00:00
jubnl f20eb6639f chore(workflow): remove delete tag workflow 2026-04-10 07:50:51 +02:00
github-actions[bot] d0176d7ed6 chore: bump version to 2.9.12 [skip ci] 2026-04-10 05:44:33 +00:00
jubnl 8402f3bcfd chore: add workflow to delete Docker tags 2026-04-10 07:44:10 +02:00
github-actions[bot] 6caa966a52 chore: bump version to 2.10.0 [skip ci] 2026-04-10 05:36:13 +00:00
Julien G. 098918b416 Merge pull request #514 from gravitysc/chart-releaser
Chart releaser
2026-04-10 07:36:00 +02:00
github-actions[bot] 28c7013252 chore: bump version to 2.9.12 [skip ci] 2026-04-09 15:48:10 +00:00
Maurice fa810c3bab Merge pull request #530 from mauriceboe/ci/contributor-workflow-automation-main
ci: add contributor workflow automation
2026-04-09 17:47:56 +02:00
Julien G. 93d5ab7fcd Merge pull request #532 from luojiyin1987/fix/force-https-documentation
docs: Clarify FORCE_HTTPS and TRUST_PROXY configuration
2026-04-09 13:53:22 +02:00
luojiyin 729526bd34 docs: Clarify FORCE_HTTPS and TRUST_PROXY configuration
- Add explicit warning about FORCE_HTTPS when accessing directly on http://host:3000
- Explain that FORCE_HTTPS=false is required for direct access without reverse proxy
- Clarify TRUST_PROXY usage only when behind actual reverse proxy
- Prevent common configuration issues causing infinite redirects

This resolves potential confusion where users might experience 301 redirects
to non-existent HTTPS endpoints when accessing the Docker container directly.
2026-04-09 11:49:53 +08:00
jubnl c13b28ae8f ci: add contributor workflow automation
- Add PR template with description, type of change, and contributing checklist
- Enforce target branch: label + comment + 24h auto-close for PRs targeting main
- Flag bad issue titles: label + comment + 24h auto-close instead of instant close
- Redirect feature requests to Discussions (instant close, unchanged)
- Add two scheduled workflows to close stale labeled issues and PRs after 24h
- Update CONTRIBUTING.md with tests and branch up-to-date requirements
2026-04-09 01:23:21 +02:00
Kessler Dev 8c7d1f8fa6 chore: use helm-publisher action for chart release 2026-04-08 13:28:22 +02:00
Kessler Dev dba655d6e8 chore: implement helm chart release automation to gh-pages 2026-04-08 13:01:14 +02:00
Kessler Dev cb8280249f chore(chart): use appVersion as default image tag 2026-04-08 12:45:16 +02:00
github-actions[bot] 504195a324 chore: bump version to 2.9.11 [skip ci] 2026-04-07 11:18:45 +00:00
jubnl 47b880221d fix(oidc): resolve login/logout loop in OIDC-only mode
Three distinct bugs caused infinite OIDC redirect loops:

1. After logout, navigating to /login with no signal to suppress the
   auto-redirect caused the login page to immediately re-trigger the
   OIDC flow. Fixed by passing `{ state: { noRedirect: true } }` via
   React Router's navigation state (not URL params, which were fragile
   due to async cleanup timing) from all logout call sites.

2. On the OIDC callback page (/login?oidc_code=...), App.tsx's
   mount-level loadUser() fired concurrently with the LoginPage's
   exchange fetch. The App-level call had no cookie yet and got a 401,
   which (if it resolved after the successful exchange loadUser()) would
   overwrite isAuthenticated back to false. Fixed by skipping loadUser()
   in App.tsx when the initial path is /login.

3. React 18 StrictMode double-invokes useEffect. The first run called
   window.history.replaceState to clean the oidc_code from the URL
   before starting the async exchange, so the second run saw no
   oidc_code and fell through to the getAppConfig auto-redirect, firing
   window.location.href = '/api/auth/oidc/login' before the exchange
   could complete. Fixed by adding a useRef guard to prevent
   double-execution and moving replaceState into the fetch callbacks so
   the URL is only cleaned after the exchange resolves.

Also adds login.oidcLoggedOut translation key in all 14 languages to
show "You have been logged out" instead of the generic OIDC-only
message when landing on /login after an intentional logout.

Closes #491
2026-04-07 13:18:24 +02:00
github-actions[bot] a6ea73eab6 chore: bump version to 2.9.10 [skip ci] 2026-04-06 10:57:06 +00:00
Maurice 4ba6005ca3 fix(dayplan): resolve duplicate reservation display, date off-by-one, and missing day_id on edit
- Exclude place-assigned reservations from timeline to prevent duplicate display
- Use selected day's date instead of today when entering time without date
- Pass day_id when updating reservations, not only when creating
2026-04-06 12:56:54 +02:00
github-actions[bot] 09ab829b17 chore: bump version to 2.9.9 [skip ci] 2026-04-06 09:32:20 +00:00
Maurice 66a057a070 fix(bookings): resolve date handling and file auth bugs
- Clear reservation_time fields when switching booking type to hotel (#459)
- Parse date-only reservation_end_time correctly on edit (#455)
- Show end date on booking cards for date-only values (#455)
- Add auth token to file download links in bookings (#454)
- Account for timezone offsets in flight time validation (#456)
2026-04-06 11:32:06 +02:00
github-actions[bot] f2ffea5ba4 chore: bump version to 2.9.8 [skip ci] 2026-04-05 22:09:41 +00:00
jubnl b0dee4dafb feat(mcp): add MCP_MAX_SESSION_PER_USER env var and document it everywhere 2026-04-06 00:09:22 +02:00
github-actions[bot] beb48af8ed chore: bump version to 2.9.7 [skip ci] 2026-04-05 21:38:56 +00:00
jubnl e2be3ec191 fix(atlas): replace fuzzy region matching with exact name_en check
Bidirectional substring matching in isVisitedFeature caused unrelated
regions to be highlighted as visited (e.g. selecting Nordrhein-Westfalen
also marked Nord France due to "nord" being a substring match).

Replace the fuzzy loop with an additional exact check against the Natural
Earth name_en property to cover English-vs-native name mismatches.
Also fix Nominatim field priority to prefer state over county so
reverse-geocoded places resolve to the correct admin-1 level.

Adds integration tests ATLAS-009 through ATLAS-011 covering mark/unmark
region endpoints and user isolation.

Fixes #446
2026-04-05 23:38:34 +02:00
github-actions[bot] 68a1f9683e chore: bump version to 2.9.6 [skip ci] 2026-04-05 21:26:44 +00:00
Maurice 5c57116a68 fix(dayplan): restore time-based auto-sort for places and free reorder for untimed
Timed places now auto-sort chronologically when a time is set.
Untimed places can be freely dragged between timed items.
Transports are inserted by time with per-day position override.
Fixes regression from multi-day spanning PR that removed timed/untimed split.
2026-04-05 23:26:35 +02:00
github-actions[bot] 48508b9df4 chore: bump version to 2.9.5 [skip ci] 2026-04-05 21:12:19 +00:00
jubnl c8250256a7 fix(streaming): end response on client disconnect during asset pipe
When a client disconnects mid-stream, headers are already sent so the
catch block now calls response.end() before returning, preventing the
socket from being left open and crashing the server. Fixes #445.
2026-04-05 23:11:57 +02:00
github-actions[bot] 6491e1f986 chore: bump version to 2.9.4 [skip ci] 2026-04-05 21:02:53 +00:00
Maurice 03757ed0af fix(dayplan): per-day transport positions for multi-day reservations
Reordering places on one day of a multi-day reservation no longer
affects the order on other days. Transport positions are now stored
per-day in a new reservation_day_positions table instead of a single
global day_plan_position on the reservation.
2026-04-05 23:02:42 +02:00
github-actions[bot] a676dbe881 chore: bump version to 2.9.3 [skip ci] 2026-04-05 20:46:34 +00:00
jubnl 411d8620ba fix(reservations): reset stale budget category when it no longer exists
If the budget category stored in reservation metadata was deleted, the
form would re-submit it on next save, resurrecting the deleted category.
Now validates against live budget items on form init and falls back to
auto-generation when the stored category is gone.

Closes #442
2026-04-05 22:46:16 +02:00
github-actions[bot] f45f56318a chore: bump version to 2.9.2 [skip ci] 2026-04-05 20:36:00 +00:00
jubnl 3ae0f3f819 Merge remote-tracking branch 'origin/main' 2026-04-05 22:35:41 +02:00
jubnl 306626ee1c fix(trip): redirect to plan tab when active tab's addon is disabled
If a user's last visited tab belongs to an addon that gets disabled while
they are away, re-opening the trip now resets the active tab to 'plan'
instead of rendering the inaccessible addon page.

Closes #441
2026-04-05 22:30:22 +02:00
jubnl 7e0fe3b1b9 fix(reservations): hide price/budget fields when budget addon is disabled
Closes #440
2026-04-05 22:30:13 +02:00
jubnl fdbc015dbf fix(memories): re-fetch EXIF info when navigating between lightbox photos
The navigateTo function was clearing lightboxInfo without re-fetching it,
causing the EXIF sidebar to disappear and nav button placement to break.
Mirrors the fetch logic already present in the thumbnail click handler.

Fixes #439
2026-04-05 22:30:05 +02:00
github-actions[bot] 7d8e3912b4 chore: bump version to 2.9.1 [skip ci] 2026-04-05 20:20:56 +00:00
jubnl 9ebca725ae fix(CSP): Paths that end in / match any path they are a prefix of. 2026-04-05 22:20:40 +02:00
github-actions[bot] 9718187490 chore: bump version to 2.9.0 [skip ci] 2026-04-05 19:38:21 +00:00
Julien G. aa0620e01f Merge pull request #421 from mauriceboe/dev
v2.9.0
2026-04-05 21:38:11 +02:00
jubnl 955776b492 fix(LF): Normalize file to LF 2026-04-05 21:30:32 +02:00
Julien G. 9b11abbf4a Merge pull request #434 from jerryhuangyu/feat/support-zh
feat(i18n): add Traditional Chinese (zh-TW) language support
2026-04-05 21:18:02 +02:00
Julien G. cc613771fa Merge pull request #437 from mauriceboe/feat/migrate-node-fetch-to-native
refactor(server): replace node-fetch with native fetch + undici, fix photo integrations
2026-04-05 21:15:03 +02:00
jubnl 5cc81ae4b0 refactor(server): replace node-fetch with native fetch + undici, fix photo integrations
Replace node-fetch v2 with Node 22's built-in fetch API across the entire server.
Add undici as an explicit dependency to provide the dispatcher API needed for
DNS pinning (SSRF rebinding prevention) in ssrfGuard.ts. All seven service files
that used a plain `import fetch from 'node-fetch'` are updated to use the global.
The ssrfGuard safeFetch/createPinnedAgent is rewritten as createPinnedDispatcher
using an undici Agent, with correct handling of the `all: true` lookup callback
required by Node 18+. The collabService dynamic require() and notifications agent
option are updated to use the dispatcher pattern. Test mocks are migrated from
vi.mock('node-fetch') to vi.stubGlobal('fetch'), and streaming test fixtures are
updated to use Web ReadableStream instead of Node Readable.

Fix several bugs in the Synology and Immich photo integrations:
- pipeAsset: guard against setting headers after stream has already started
- _getSynologySession: clear stale SID and re-login when decrypt_api_key returns null
  instead of propagating success(null) downstream
- _requestSynologyApi: return retrySession error (not stale session) on retry failure;
  also retry on error codes 106 (timeout) and 107 (duplicate login), not only 119
- searchSynologyPhotos: fix incorrect total field type (Synology list_item returns no
  total); hasMore correctly uses allItems.length === limit
- _splitPackedSynologyId: validate cache_key format before use; callers return 400
- getImmichCredentials / _getSynologyCredentials: treat null from decrypt_api_key as
  a missing-credentials condition rather than casting null to string
- Synology size param: enforce allowlist ['sm', 'm', 'xl'] per API documentation
2026-04-05 21:12:51 +02:00
Maurice 94b74f96a3 fix(ical): pad datetime to 15 chars for valid iCal DTSTART/DTEND format
Times like 09:00 were exported as YYYYMMDDTHHMM (13 chars) instead of
YYYYMMDDTHHMMSS (15 chars). Google Calendar couldn't parse the short
format and defaulted all events to 12:00 AM. Closes #432
2026-04-05 20:17:22 +02:00
Maurice 48bf149d01 feat(packing): item quantity, bag rename, multi-user bags, save as template
- Add quantity field to packing items (persisted, visible per item)
- Bags are now renamable (click to edit in sidebar)
- Bags support multiple user assignments with avatar display
- New packing_bag_members table for multi-user bag ownership
- Save current packing list as reusable template
- Add bag members API endpoint (PUT /bags/:bagId/members)
- Migration 74: quantity on packing_items, user_id on packing_bags, packing_bag_members table
2026-04-05 19:28:33 +02:00
Maurice f3679739d8 fix(reservations): format check-in/out times with user's time format setting
Respects 12h/24h preference for hotel check-in and check-out display.
2026-04-05 18:19:46 +02:00
Maurice 38206883ff feat(budget): bidirectional sync between reservations and budget items
- Link budget items to reservations via reservation_id column
- Update budget entry when reservation price changes (not create duplicate)
- Delete budget entry when reservation price is cleared
- Sync price back to reservation when edited in budget panel
- Lock budget item name when linked to a reservation
- Add migration 73 for reservation_id on budget_items
2026-04-05 18:16:02 +02:00
jerryhuangyu dd21074c27 feat: Add Traditional Chinese (zh-TW) translations support 2026-04-05 23:53:26 +08:00
Maurice cd5a6c7491 ui(settings): add about text, community links and bug/feature/wiki cards
- Add TREK description and "Made with heart" text to About tab (all 13 languages)
- Add Report Bug, Feature Request and Wiki cards to About tab and Admin GitHub panel
- Version shown as inline badge
2026-04-05 17:53:15 +02:00
Maurice 6e6e0a370e ui(settings): add Ko-fi, Buy Me a Coffee and Discord cards to About tab 2026-04-05 17:33:16 +02:00
Maurice 83bac11173 ui(trip): replace plane loading animation with TREK logo GIF
- Use animated TREK logo instead of plane SVG on trip loading screen
- Dark/light mode aware (switches GIF based on theme)
2026-04-05 17:28:04 +02:00
Julien G. ecf69225e1 Merge pull request #433 from mauriceboe/fix/mfa-qr-svg
fix(mfa): generate SVG QR code
2026-04-05 17:16:50 +02:00
jubnl c6148ba4f2 fix(mfa): generate SVG QR code
Replace the rasterized 180px PNG QR code with a crisp 250px SVG
2026-04-05 17:15:19 +02:00
Maurice 9ee5d21c3a test(trips): update TRIP-002 for dateless trips and add day_count test
- TRIP-002 now expects null dates and 7 placeholder days instead of forced date window
- Add TRIP-002b to verify custom day_count creates correct number of days
2026-04-05 16:29:29 +02:00
Maurice d5cc2432c4 fix(i18n): escape apostrophes in French dayCountHint translation 2026-04-05 16:25:32 +02:00
Maurice 7f077d949d feat(trips): add configurable day count for trips without dates
- Show day count input in trip form when no start/end date is set
- Backend accepts day_count param for create and update
- Remove forced date assignment for dateless trips (was always setting tomorrow + 7)
- Fix off-by-one: single-date fallback now creates 7 days instead of 8
- Add dayCount/dayCountHint translations for all 13 languages
2026-04-05 16:25:09 +02:00
Julien G. 312bc715bf Merge pull request #430 from mauriceboe/fix/gpx-import-tracks-and-xml-parser
fix(gpx): replace regex parsing with fast-xml-parser and import tracks alongside waypoints
2026-04-05 15:56:22 +02:00
jubnl 6ba08352ed fix(gpx): replace regex parsing with fast-xml-parser and import tracks alongside waypoints
GPX files containing both <wpt> and <trk> elements would only import
waypoints, silently discarding track geometry. The fallback chain only
parsed <trkpt> when no waypoints were found.

Replaced all regex-based XML parsing helpers with fast-xml-parser for
correctness (namespaces, CDATA, attribute ordering). Tracks are now
always parsed independently of waypoints, with each <trk> element
becoming its own place with route geometry. Fixes #427.
2026-04-05 15:54:42 +02:00
Julien G. 58874a1ccb Merge pull request #429 from mauriceboe/fix/mcp-search-place-google-maps
fix(mcp): route search_place through mapsService to support Google Maps
2026-04-05 15:39:23 +02:00
jubnl 82f08360d7 fix(mcp): route search_place through mapsService to support Google Maps
The search_place MCP tool was hardcoding a direct Nominatim call, ignoring
any configured Google Maps API key and never returning google_place_id despite
the tool description advertising it. Replace the inline fetch with the existing
searchPlaces() service which already switches between Google and Nominatim.

Update unit tests to mock mapsService instead of global fetch, and add a
dedicated test case for the Google path returning google_place_id.

Closes #424
2026-04-05 15:38:19 +02:00
Julien G. 978d26f36c Merge pull request #428 from mauriceboe/fix/avatar-url-documents-tab
fix(files): prepend /uploads/avatars/ to avatar URL in documents tab
2026-04-05 15:25:26 +02:00
jubnl 18eee16d2d fix(files): prepend /uploads/avatars/ to avatar URL in documents tab
Raw avatar filename was passed through formatFile without being
transformed into a full URL path, causing the browser to resolve
it relative to the current /trips/... page. Closes #417.
2026-04-05 15:23:45 +02:00
Maurice c274846275 fix(memories): fix deprecated immich route regressions from PR #336
- Fix createAlbumLink using old column name (immich_album_id → album_id)
- Fix deleteAlbumLink not removing associated photos (with owner check)
- Update integration tests for new schema (asset_id, album_id, provider)
2026-04-05 15:19:13 +02:00
Maurice 7821993450 fix(memories): patch critical bugs from PR #336 Synology Photos merge
- Fix missing response on successful addTripPhotos in deprecated immich route
- Fix undefined tripId in asset proxy routes (use query param instead)
- Fix unquoted SQL string in migration 68 (id = memories → id = 'memories')
- Add missing return after error response in synology asset streaming
2026-04-05 15:11:07 +02:00
Maurice a9d6ce87c1 Merge pull request #336 from tiquis0290/test
Adding support for SynologyPhoto (immich like) and adding support to use more photo proiders not just immich
2026-04-05 15:08:50 +02:00
Maurice 67b21d5fe3 i18n(admin): rename tabs and merge notification panels
- Configuration → Personalization (all 13 languages)
- Merge Notification Channels + Admin Notifications into single Notifications tab
- Audit Log → Audit (all 13 languages)
2026-04-05 14:46:36 +02:00
Marek Maslowski 8b488efc8e fixing migrations to change to correct label name 2026-04-05 14:32:41 +02:00
Marek Maslowski 070b75b6be fixing loging in to synology 2026-04-05 14:26:28 +02:00
Marek Maslowski 51c4afd5f7 fixing error on test connection without params 2026-04-05 14:26:14 +02:00
Marek Maslowski 74b3b0f9ae removing race conteset on delting album link 2026-04-05 12:21:00 +02:00
Marek Maslowski 1236f3281d adding old routes 2026-04-05 12:17:43 +02:00
Marek Maslowski 4a0d586768 fix for not calling api route on fetch 2026-04-05 11:54:51 +02:00
Marek Maslowski 079964bec8 making helper functions for building urls 2026-04-05 11:50:34 +02:00
Marek Maslowski b0b85fff3a fix for settings page 2026-04-05 11:08:58 +02:00
Marek Maslowski 0d3a10120a post merge 2026-04-05 10:26:23 +02:00
Marek Maslowski b8c3d5b3d1 Merge branch 'dev' into test 2026-04-05 10:26:09 +02:00
jubnl 959015928f feat(security): mask saved webhook URLs instead of returning encrypted values
Encrypted webhook URLs are no longer returned to the frontend. Both user
and admin webhook fields now show '••••••••' as a placeholder when a URL
is already saved, and the sentinel value is skipped on save/test so the
stored secret is never exposed or accidentally overwritten.
2026-04-05 06:08:44 +02:00
jubnl d8ee545002 fix(ssrf): handle Node 20+ Happy Eyeballs dns lookup signature in pinned agent
Node 20+ enables autoSelectFamily by default, causing internal dns lookups
to be called with `all: true`. This expects the callback to receive an array
of address objects instead of a flat (address, family) pair, causing webhook
requests to fail with "Invalid IP address: undefined".
2026-04-05 05:59:25 +02:00
Julien G. 78b9536de9 Merge pull request #423 from mauriceboe/feat/settings-tabbed-layout
feat(settings): remake settings page with admin-style tabbed layout
2026-04-05 05:33:30 +02:00
jubnl 4e4afe2545 feat(settings): remake settings page with admin-style tabbed layout
Replaces the 2-column masonry layout with a horizontal pill tab bar
matching the admin page pattern. Extracts all sections into self-contained
components under components/Settings/ and reduces SettingsPage.tsx from
1554 lines to 93. Adds i18n tab label keys across all 13 language files.
2026-04-05 05:32:21 +02:00
jubnl 38afba0820 fix(csp): add https://router.project-osrm.org/route/v1 to CSP Connect-Src 2026-04-05 05:23:33 +02:00
Julien G. 81742dbb85 Merge pull request #419 from mauriceboe/feat/notification-system
feat(notifications): add unified multi-channel notification system
2026-04-05 04:37:06 +02:00
jubnl 3898e5f7e2 chore(CRLF): normalize index.html line endings to LF 2026-04-05 04:35:17 +02:00
jubnl 6a36efbf1a feat(i18n): translate missing keys across all 12 language files 2026-04-05 04:34:58 +02:00
Julien G. 991b4065e3 Merge branch 'dev' into feat/notification-system 2026-04-05 04:06:49 +02:00
jubnl c158df1bc5 chore(CRLF) Normalize all files to LF 2026-04-05 04:01:08 +02:00
jubnl f03705848d fix(translation): syntax error 2026-04-05 03:54:42 +02:00
jubnl 0c99eb1d07 chore: merge dev branch, resolve conflicts for migrations and translations
- migrations.ts: keep dev's migrations 69 (place_regions) + 70 (visited_regions), renumber our notification_channel_preferences migration to 71 and drop-old-table to 72
- translations: use dev values for existing keys, add notification system keys unique to this branch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 03:46:53 +02:00
jubnl 7b37d337c1 fix(security): address notification system security audit findings
- SSRF: guard sendWebhook() with checkSsrf() + createPinnedAgent() to block
  requests to loopback, link-local, private network, and cloud metadata endpoints
- XSS: escape subject, body, and ctaHref in buildEmailHtml() via escapeHtml()
  to prevent HTML injection through user-controlled params (actor, preview, etc.)
- Encrypt webhook URLs at rest: apply maybe_encrypt_api_key on save
  (settingsService for user URLs, authService for admin URL) and decrypt_api_key
  on read in getUserWebhookUrl() / getAdminWebhookUrl()
- Log failed channel dispatches: inspect Promise.allSettled() results and log
  rejections via logError instead of silently dropping them
- Log admin webhook failures: replace fire-and-forget .catch(() => {}) with
  .catch(err => logError(...)) and await the call
- Migration 69: guard against missing notification_preferences table on fresh installs
- Migration 70: drop the now-unused notification_preferences table
- Refactor: extract applyUserChannelPrefs() helper to deduplicate
  setPreferences / setAdminPreferences logic
- Tests: add SEC-016 (XSS, 5 cases) and SEC-017 (SSRF, 6 cases) test suites;
  mock ssrfGuard in notificationService tests
2026-04-05 03:36:50 +02:00
Julien G. 69ae6f93db Merge pull request #420 from mauriceboe/feat/atlas
feat(atlas): sub-national region view when zooming in
2026-04-05 03:19:48 +02:00
jubnl 71c1683bb3 feat(atlas): mark sub-national regions as visited with cascade behavior
- Add visited_regions table migration
- Mark/unmark region endpoints with auto-mark parent country
- Unmark country cascades to its regions; unmark last region cascades to country
- Region modal with mark/unmark flow and bucket list shortcut
- Viewport-based lazy loading of region GeoJSON at zoom >= 6
- i18n: add atlas.markRegionVisitedHint and atlas.confirmUnmarkRegion across all 13 locales
2026-04-05 03:17:59 +02:00
mauriceboe 6df8b2555d chore: resolve merge conflicts with dev branch
Merge dev into feat/notification-system, keeping all i18n keys from both
branches (notification system keys + reservation price/budget keys).
2026-04-05 01:43:43 +02:00
mauriceboe 16cadeb09e feat(atlas): sub-national region view when zooming in
- Zoom >= 5 shows visited regions (states/provinces/departments) colored on the map
- Server resolves places to regions via Nominatim reverse geocoding (zoom=8)
- Supports all ISO levels: lvl4 (states), lvl5 (provinces), lvl6 (departments)
- Handles city-states (Berlin, Vienna, Hamburg) via city/county fallback
- Fuzzy name matching between Nominatim and GeoJSON for cross-format compatibility
- 10m admin_1 GeoJSON loaded server-side (cached), filtered per country
- Region colors match their parent country color
- Custom DOM tooltip (ref-based, no re-renders on hover)
- Country layer dims to 35% opacity when regions visible
- place_regions DB table caches resolved regions permanently
- Rate-limited Nominatim calls (1 req/sec) with progressive resolution
2026-04-05 01:31:19 +02:00
jubnl fc29c5f7d0 feat(notifications): add unified multi-channel notification system
Introduces a fully featured notification system with three delivery
channels (in-app, email, webhook), normalized per-user/per-event/
per-channel preferences, admin-scoped notifications, scheduled trip
reminders and version update alerts.

- New notificationService.send() as the single orchestration entry point
- In-app notifications with simple/boolean/navigate types and WebSocket push
- Per-user preference matrix with normalized notification_channel_preferences table
- Admin notification preferences stored globally in app_settings
- Migration 69 normalizes legacy notification_preferences table
- Scheduler hooks for daily trip reminders and version checks
- DevNotificationsPanel for testing in dev mode
- All new tests passing, covering dispatch, preferences, migration, boolean
  responses, resilience, and full API integration (NSVC, NPREF, INOTIF,
  MIGR, VNOTIF, NROUTE series)
 - Previous tests passing
2026-04-05 01:22:18 +02:00
Marek Maslowski 399684cc19 Merge branch 'dev' into test 2026-04-05 00:36:40 +02:00
Marek Maslowski a038dbd8da fixing album sync on synology 2026-04-05 00:30:14 +02:00
Marek Maslowski f225f45f50 fix for deleting albums 2026-04-05 00:17:42 +02:00
Marek Maslowski 58b7c2e7ac some fixes when to display photo tab 2026-04-05 00:16:43 +02:00
mauriceboe b8058a2755 fix(reservations): budget category dropdown, localized auto-category, price input cleanup
- Budget category uses dropdown with existing categories instead of freetext
- Auto category uses translated booking type names (e.g. "Volo" in Italian)
- Remove number input spinner arrows, use decimal inputMode
- Add budget entry creation to PUT handler (update), not just POST (create)
- Error logging for failed budget entry creation
- i18n keys for all 13 languages
2026-04-05 00:13:07 +02:00
mauriceboe aa244dd548 feat(reservations): add price field with automatic budget entry creation
- Optional price and budget category fields on the reservation form
- When a price is set, a budget entry is automatically created on save
- Price and category stored in reservation metadata for reference
- Hint text shown when price is entered
- i18n keys for EN and DE
2026-04-04 23:59:30 +02:00
mauriceboe 33d8953554 fix(security): harden Google Maps URL resolver against SSRF
- Replace substring check with strict hostname validation (goo.gl, maps.app.goo.gl)
- Add checkSsrf() guard with bypass=true to block private/internal IPs unconditionally
- Prevents crafted URLs like https://evil.com/?foo=goo.gl from triggering server-side fetches
2026-04-04 23:47:46 +02:00
Marek Maslowski c39ae2b965 adding fetch in try to prevent crashes 2026-04-04 22:43:13 +02:00
Marek Maslowski 3413d3f77d fixing labels in english 2026-04-04 22:00:35 +02:00
Marek Maslowski c9e3185ad0 cleaning imports 2026-04-04 20:51:07 +02:00
Marek Maslowski f8cf37a9bd adding checks when loading added photos/albums that the provider is enabled 2026-04-04 20:50:45 +02:00
Marek Maslowski 20709d23ee fixes based on comment (missing api compatability and translation keys) 2026-04-04 20:31:15 +02:00
mauriceboe e4065c276b fix(map,lightbox): center map above day detail panel and fix lightbox close
- Map pans up when DayDetailPanel is open so route markers aren't hidden
- Files lightbox: clicking dark background closes lightbox again
- Memories lightbox: clicking dark background closes lightbox again
2026-04-04 20:26:24 +02:00
mauriceboe 11b6974387 feat(files,memories): add gallery navigation to image lightboxes
Files lightbox: prev/next buttons, keyboard arrows, swipe on mobile,
thumbnail strip, file counter. Navigates between all images in the
current filtered view.

Memories lightbox: prev/next buttons, keyboard arrows, swipe on mobile,
photo counter. Navigates between all visible trip photos.
2026-04-04 20:14:00 +02:00
Marek Maslowski 554a7d7530 changing back to download
tokens are no longer used here
2026-04-04 19:56:02 +02:00
Marek Maslowski 2baf407809 adding that deletion of album removes its items 2026-04-04 19:52:49 +02:00
mauriceboe 259ff53bfb fix(packing): add line numbers to import dialog and support quoted CSV values
- Import textarea now shows line numbers to distinguish wrapped lines from actual new lines
- CSV parser respects double-quoted values (e.g. "Shirt, blue" stays as one field)

Fixes #133
2026-04-04 19:52:42 +02:00
Marek Maslowski 21063e6230 Merge pull request #6 from tiquis0290/dev
Dev
2026-04-04 19:29:05 +02:00
Marek Maslowski 1285da063e Merge branch 'test' into dev 2026-04-04 19:27:16 +02:00
Marek Maslowski 3e9e3fcc9e Merge pull request #5 from tiquis0290/synology2
Synology2
2026-04-04 19:16:52 +02:00
Marek Maslowski ba4bfc693a fixing schemas and making migrations not crash 2026-04-04 19:14:45 +02:00
Julien G. 179938e904 Merge pull request #415 from mauriceboe/fix/collab-note-editor-thumbnail-auth
fix(collabNotes): use AuthedImg for thumbnails in edit modal (closes #404)
2026-04-04 19:09:42 +02:00
jubnl 4e13a59338 fix(collabNotes): use AuthedImg for thumbnails in edit modal (closes #404)
Raw <img src={a.url}> cannot send auth credentials; replace with AuthedImg
which fetches an ephemeral download token before rendering the image.
2026-04-04 19:08:04 +02:00
Julien G. 2c9e71c91d Merge pull request #414 from mauriceboe/fix/collab-notes-photo-flash-on-switch
fix(collabNotes): clear stale auth URL when switching photos (closes #403)
2026-04-04 19:00:10 +02:00
jubnl 733567d088 fix(collabNotes): clear stale auth URL when switching photos (closes #403)
Reset authUrl to empty string before fetching the new authenticated URL so
the previous photo is never rendered during the async gap. Show a spinner
while the new URL is loading.
2026-04-04 18:58:51 +02:00
Marek Maslowski 5b25c60b62 fixing migrations 2026-04-04 18:56:27 +02:00
Julien G. d7efa9d914 Merge pull request #413 from mauriceboe/fix/collab-notes-show-all-attachments-in-expanded-view
fix(collabNotes): show all attachments in expanded note view (closes #402)
2026-04-04 18:50:04 +02:00
jubnl c70f5284c7 fix(collabNotes): show all attachments in expanded note view (closes #402)
The expanded/fullscreen note modal was missing the attachments section entirely,
so users had no way to access files beyond the 1-2 shown in the compact card view.
Added a full, untruncated attachments grid below the markdown content in the modal.
2026-04-04 18:48:53 +02:00
Julien G. b40bea036f Merge pull request #412 from mauriceboe/fix/mobile-photo-viewer-lightbox
fix(memories): responsive photo lightbox for mobile (issue #401)
2026-04-04 18:40:28 +02:00
jubnl 6da7843bf0 fix(memories): responsive photo lightbox for mobile (issue #401)
On narrow screens the EXIF sidebar was squeezing the image to ~95px and
hiding the close button. On mobile (<768px) the sidebar is now hidden by
default; an info toggle button reveals it as a scrollable bottom sheet.
Desktop layout is unchanged.
2026-04-04 18:38:29 +02:00
Marek Maslowski 9f0ec8199f fixing db errors message 2026-04-04 18:28:44 +02:00
Julien G. 9bff25558e Merge pull request #409 from mauriceboe/refactor/mcp-use-service-layer
refactor(mcp): replace direct DB access with service layer calls
2026-04-04 18:23:35 +02:00
jubnl 00b96eb678 refactor(tripService): reuse service functions in getTripSummary
Replace inline DB queries in getTripSummary with calls to existing
service functions: listDays, listAccommodations, listBudgetItems,
listPackingItems, listReservations, listCollabNotes, getTripOwner,
and listMembers.

Budget and packing stats are now derived from the service results
instead of separate COUNT/SUM queries.
2026-04-04 18:22:07 +02:00
Marek Maslowski 3d0249e076 finishing refactor 2026-04-04 18:16:46 +02:00
jubnl 1bddb3c588 refactor(mcp): replace direct DB access with service layer calls
Replace all db.prepare() calls in mcp/index.ts, mcp/resources.ts, and
mcp/tools.ts with calls to the service layer. Add missing service functions:
- authService: isDemoUser, verifyMcpToken, verifyJwtToken
- adminService: isAddonEnabled
- atlasService: listVisitedCountries
- tripService: getTripSummary, listTrips with null archived param

Also fix getAssignmentWithPlace and formatAssignmentWithPlace to expose
place_id, assignment_time, and assignment_end_time at the top level, and
fix updateDay to correctly handle null title for clearing.

Add comprehensive unit and integration test suite for the MCP layer (821 tests all passing).
2026-04-04 18:12:53 +02:00
mauriceboe b26023e32a fix(pdf): clean up accommodation rendering in trip PDF
- Remove duplicate icon display on check-in/check-out time row
- Remove hardcoded 'N/A' fallback, show time only when available
- Fix inconsistent indentation and variable naming
- Add flex-wrap to accommodation layout for 3+ accommodations per day
- Use icon-per-call instead of pre-cached variables for clarity
2026-04-04 17:53:03 +02:00
Maurice c8421eb1fc Merge pull request #334 from micro92/feat/accomodationPDF
Add Accomodation to PDF
2026-04-04 17:51:31 +02:00
Marek Maslowski 8c125738e8 refactor of synology part 1 2026-04-04 17:13:17 +02:00
mauriceboe 6d92e14515 fix(trips): preserve day content when setting dates on dateless trips
Dateless days are now reassigned to the new date range instead of being
deleted and recreated. This keeps all assignments, notes, bookings and
other day content intact when a user adds start/end dates to a trip
that was created without them.
2026-04-04 17:09:03 +02:00
mauriceboe 0b36427c09 feat(todo): add To-Do list feature with 3-column layout
- New todo_items DB table with priority, due date, description, user assignment
- Full CRUD API with WebSocket real-time sync
- 3-column UI: sidebar filters (All, My Tasks, Overdue, Done, by Priority),
  task list with inline badges, and detail/create pane
- Apple-inspired design with custom dropdowns, date picker, priority system (P1-P3)
- Mobile responsive: icon-only sidebar, bottom-sheet modals for detail/create
- Lists tab with sub-tabs (Packing List + To-Do), persisted selection
- Addon renamed from "Packing List" to "Lists"
- i18n keys for all 13 languages
- UI polish: notification colors use system theme, mobile navbar cleanup,
  settings page responsive buttons
2026-04-04 16:58:24 +02:00
Julien G. 1ea0eb9965 Merge pull request #405 from mauriceboe/fix/issue-398-immich-unlink-photos
fix(immich): remove album photos on unlink
2026-04-04 16:41:11 +02:00
jubnl c4c3ea1e6d fix(immich): remove album photos on unlink
When unlinking an Immich album, photos synced from that album are now
deleted. A new `album_link_id` FK column on `trip_photos` tracks the
source album link at sync time; `deleteAlbumLink` deletes matching
photos before removing the link. Individually-added photos are
unaffected. The client now refreshes the photo grid after unlinking.

Adds integration tests IMMICH-020 through IMMICH-024.

Closes #398
2026-04-04 16:37:14 +02:00
Julien G. 43c801232e Merge pull request #399 from mauriceboe/main
Align dev with main
2026-04-04 15:29:51 +02:00
github-actions[bot] 6825a4a0c1 chore: bump version to 2.8.4 [skip ci] 2026-04-04 13:20:50 +00:00
jubnl 8a4a8b58be fix(version): revert version and ensure nomad img is pushed to dockerhub 2026-04-04 15:20:33 +02:00
github-actions[bot] be975f38a6 chore: bump version to 2.9.0 [skip ci] 2026-04-04 13:11:47 +00:00
jubnl fa37d5b3f7 Merge remote-tracking branch 'origin/main' 2026-04-04 15:11:30 +02:00
jubnl 0ddd0c14b2 chore: replace nomad references with trek in update instructions and CI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:11:07 +02:00
jubnl 297cfda32b chore: resolve merge conflict in TRIP-002 test — keep dev version (checks both dates)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:57:32 +02:00
github-actions[bot] d8367ec878 chore: bump version to 2.8.3 [skip ci] 2026-04-04 12:54:00 +00:00
jubnl 79057327fa Merge remote-tracking branch 'origin/main' 2026-04-04 14:53:40 +02:00
jubnl 0943184b1e test(trips): update TRIP-002 to reflect 7-day default window behavior 2026-04-04 14:53:12 +02:00
mauriceboe 3f612c4d26 fix(dayplan): improve drag-and-drop for items around transport bookings
- Allow dropping places above or below transport cards (top/bottom half detection)
- Fix visual re-render after transport position changes (useMemo invalidation)
- Fix drop indicator showing on all days for multi-day transports (scope key to day)
- Keep all places in order_index order so untimed places can be positioned between timed items
2026-04-04 14:49:16 +02:00
github-actions[bot] a4752ae692 chore: bump version to 2.8.2 [skip ci] 2026-04-04 12:48:36 +00:00
jubnl e6068d44b0 docs(oidc): fix OIDC_SCOPE default and clarify override behavior, skip CI for docs-only pushes, remove stale audit files 2026-04-04 14:48:11 +02:00
Marek Maslowski 877e1a09cc removing the need of suplementing provider links in config 2026-04-04 14:20:52 +02:00
Marek Maslowski bca82b3f8c changing routes and hierarchy of files for memories 2026-04-04 14:01:51 +02:00
Maurice 1aea2fcee8 Merge pull request #361 from lucaam/add_span_days_feature
Support multi-day spanning for reservations
2026-04-04 13:58:16 +02:00
Marek Maslowski 504713d920 change in hadnling return values from unified service 2026-04-04 13:36:12 +02:00
mauriceboe 50d2a211e5 fix(oidc): revert default scope to 'openid email profile'
Removes 'groups' from the default OIDC_SCOPE fallback, which caused
invalid_scope errors with providers that don't support it (e.g. Google).

Fixes #391
2026-04-04 13:33:54 +02:00
github-actions[bot] 5d3a740791 chore: bump version to 2.8.1 [skip ci] 2026-04-04 10:53:29 +00:00
mauriceboe 2c1c77f367 fix(oidc): revert default scope to 'openid email profile'
Removes 'groups' from the default OIDC_SCOPE fallback, which caused
invalid_scope errors with providers that don't support it (e.g. Google).

Fixes #391
2026-04-04 12:53:12 +02:00
Marek Maslowski 68f0d399ca adding helper functions for syncing albums 2026-04-04 12:22:22 +02:00
Marek Maslowski 1305a07502 after changing routes i forgot to chang them in picker 2026-04-04 11:34:48 +02:00
Marek Maslowski c9dd8e1192 Merge branch 'dev' into test 2026-04-04 00:53:24 +02:00
Marek Maslowski 860739b28b changing handling of rights for accesing assets 2026-04-04 00:52:01 +02:00
github-actions[bot] 80d013dd19 chore: bump version to 2.8.0 [skip ci] 2026-04-03 22:35:37 +00:00
jubnl 2469739bca fix(admin): update stale NOMAD references to TREK
- GitHubPanel: point release fetcher to mauriceboe/TREK
- AdminPage: fix Docker update instructions (image, container name, volume paths)
- es.ts: replace all remaining NOMAD occurrences with TREK
2026-04-04 00:21:40 +02:00
jubnl 2197e0e1fd ci(test): remove push trigger, keep only pull_request 2026-04-04 00:20:01 +02:00
jubnl 846db9d076 test(trips): assert exact start/end dates in TRIP-002
Replace not-null checks with exact date assertions mirroring the
route's defaulting logic (tomorrow + 7-day window).
2026-04-04 00:19:54 +02:00
jubnl a307d8d1c9 test(trips): update TRIP-002 to expect default 7-day window
Now that trips always default to a start+7 day window when no dates
are provided, the test expectation of null dates and zero dated days
is no longer valid.
2026-04-04 00:19:46 +02:00
jubnl ae0d48ac83 fix(immich): check all trips when verifying shared photo access
canAccessUserPhoto was using .get() which only returned the first matching
trip, causing access to be incorrectly denied when a photo was shared across
multiple trips and the requester was a member of a non-first trip.
2026-04-04 00:14:34 +02:00
jubnl 6400c2d27d fix(mcp): wire check_in/check_out times through hotel accommodation tools
Adds optional check_in and check_out fields to create_reservation and
link_hotel_accommodation so MCP clients can set accommodation times,
matching the existing REST API behaviour.

Closes #363
2026-04-04 00:09:56 +02:00
Marek Maslowski fc28996420 Merge pull request #4 from tiquis0290/dev
pulling changes from dev branch
2026-04-03 23:59:42 +02:00
jubnl 929105f0e4 Merge remote-tracking branch 'origin/dev' into dev 2026-04-03 23:59:06 +02:00
jubnl 93c0d6fe78 fix(trips): default to 7-day window when dates are omitted on creation
- No dates → tomorrow to tomorrow+7d
- Start only → end = start+7d
- End only → start = end-7d
- Both provided → unchanged

fix(ci): include client/package-lock.json in version bump commit
2026-04-03 23:58:39 +02:00
Maurice 88a40c3294 docs: update Discord channel to #github-pr 2026-04-03 23:53:12 +02:00
Maurice c056401000 ci: auto version bump on main — minor for dev merges, patch for hotfixes 2026-04-03 23:44:11 +02:00
jubnl eae799c7d6 fix(deployment): remove unessessary files from docker image 2026-04-03 23:07:00 +02:00
Maurice 20ce7460c1 docs: add contributing guidelines 2026-04-03 22:59:28 +02:00
jubnl d765a80ea3 fix(immich): proxy shared photos using owner's Immich credentials
Trip members viewing another member's shared photo were getting a 404
because the proxy endpoints always used the requesting user's Immich
credentials instead of the photo owner's. The ?userId= query param the
client already sent was silently ignored.

- Add canAccessUserPhoto() to verify the asset is shared and the
  requesting user is a trip member before allowing cross-user proxying
- Pass optional ownerUserId through proxyThumbnail, proxyOriginal, and
  getAssetInfo so credentials are fetched for the correct user
- Enforce shared=1 check so unshared photos remain inaccessible
2026-04-03 22:32:41 +02:00
Marek Maslowski b6686a462f removing use of single sue auth tokens for assets 2026-04-03 22:30:49 +02:00
Marek Maslowski 9ddb101135 Merge branch 'dev' into test 2026-04-03 22:28:29 +02:00
jubnl 1dc189b466 New issue template and workflow 2026-04-03 21:51:03 +02:00
jubnl e624ee337f update environment variables for unraid template 2026-04-03 21:48:27 +02:00
Maurice 6ba5df0215 fix(immich): replace ephemeral token auth with blob fetch for Safari compatibility (#381)
Safari blocks SameSite=Lax cookies on <img> subresource requests,
causing 401 errors when loading Immich thumbnails and originals.

Replaced the token-based <img src> approach with direct fetch()
using credentials: 'include', which reliably sends cookies across
all browsers. Images are now loaded as blobs with ObjectURLs.

Added a concurrency limiter (max 6 parallel fetches) to prevent
ERR_INSUFFICIENT_RESOURCES when many photos load simultaneously.
Queue is cleared when the photo picker closes so gallery images
load immediately.
2026-04-03 21:41:05 +02:00
Maurice 897e1bff26 fix(dates): use UTC parsing and display for date-only strings (#351)
Date-only strings parsed with new Date(dateStr + 'T00:00:00') were
interpreted relative to the local timezone, causing off-by-one day
display for users west of UTC. Fixed across 16 files by parsing as
UTC ('T00:00:00Z') and displaying with timeZone: 'UTC'.
2026-04-03 21:18:56 +02:00
Julien G. ba14636c1d Merge pull request #376 from darioackermann/dac/helm-checksums
chore(helm): add config/secret checksum to deployment
2026-04-03 19:56:26 +02:00
jubnl 6c72295424 fix(vacay): fix entitlement counter, year deletion, and year creation bugs
- toggleCompanyHoliday now calls loadStats() so the entitlement sidebar
  updates immediately when a vacation day is converted to a company holiday
- deleteYear now deletes vacay_user_years rows for the removed year,
  preventing stale entitlement data from persisting and re-appearing
  when the year is re-created
- deleteYear recalculates carry-over for year+1 when year N is deleted,
  using the new actual previous year as the source
- removeYear store action now calls loadStats() so the sidebar reflects
  the recalculated carry-over without requiring a page refresh
- Add prev-year button (+[<] 2026 [>]+) so users can add years going
  backwards after deleting a past year; add vacay.addPrevYear i18n key
  to all 13 supported languages

Closes #371
2026-04-03 19:51:22 +02:00
jubnl f6faaa23b0 fix(vacay): reset selectedYear when the active year is deleted
When deleting the currently selected year, selectedYear was never
cleared, leaving the deleted year shown as active in the UI. Now
resets to the latest remaining year, or the current calendar year
if all years have been removed.

Fixes #369
2026-04-03 19:24:49 +02:00
Marek Maslowski ba737a9920 Merge branch 'dev' into test 2026-04-03 19:18:28 +02:00
jubnl 98813a9b40 fix(helm): add ingressClassName support to Helm chart
Adds `ingress.className` value and renders `ingressClassName` in the
Ingress spec, allowing users to specify the ingress controller class.
Closes #377.
2026-04-03 19:15:51 +02:00
jubnl e0105115f4 fix(immich): detect http→https redirect on test connection and update URL
When a user enters an http:// Immich URL that redirects to https://,
the test succeeded (GET follows redirects fine) but subsequent POST
requests (e.g. photo search) broke due to method downgrade on 301/302.

Now testConnection() checks resp.url against the input URL after a
successful fetch. If the only difference is http→https on the same
host and port, it returns a canonicalUrl so the frontend can update
the input field before the user saves — ensuring the correct URL is
stored.
2026-04-03 19:12:55 +02:00
Marek Maslowski 7d51eadf90 removing old function import 2026-04-03 16:08:46 +00:00
Marek Maslowski 66740887e7 returning admin file to orginal look 2026-04-03 17:46:00 +02:00
Marek Maslowski 69deaf9969 removing uneccessary login in admin.ts 2026-04-03 17:41:40 +02:00
Dario Ackermann 217458da81 chore(helm): add config/secret checksum to deployment 2026-04-03 17:34:13 +02:00
Marek Maslowski 61a5e42403 Fix export statement formatting in synology.ts 2026-04-03 17:31:30 +02:00
Marek Maslowski 07546c4790 Refactor resource token creation logic
Simplified token creation by directly using req.body.purpose.
2026-04-03 17:29:50 +02:00
micro92 f4f768a1b3 fix accomodation -­­> accommodation typo 2026-04-03 11:27:17 -04:00
micro92 a9c392e26e Replace Emoji By Lucide Icon 2026-04-03 11:26:28 -04:00
Marek Maslowski 90af1332e8 moving linking album to common interface 2026-04-03 17:25:25 +02:00
Marek Maslowski de4bdb4a99 fixing routes for asset details 2026-04-03 17:10:18 +02:00
jubnl 8dd22ab8a3 fix: deselect day when closing DayDetailPanel
Closing the panel via the X button now calls handleSelectDay(null),
clearing selectedDayId from the Zustand store and resetting the route.
Fixes #356.
2026-04-03 17:04:45 +02:00
Marek Maslowski fa25ff29bb moving memories bl 2026-04-03 17:02:53 +02:00
Marek Maslowski 21f87d9b91 fixes after merge 2026-04-03 16:56:41 +02:00
Luca 0115987e52 feat: support multi-day spanning for reservations (flights, rental cars, events)
- ReservationModal: add separate departure/arrival date+time fields with
  type-specific labels (Departure/Arrival for flights, Pickup/Return for
  cars, Start/End for generic types), timezone fields for flights
- DayPlanSidebar: getTransportForDay now matches reservations across all
  days in their date range; shows phase badges (Departure/In Transit/
  Arrival etc.) with appropriate time display per day
- ReservationsPanel: show date range when end date differs from start
- All 13 translation files updated with new keys
2026-04-03 16:55:45 +02:00
Marek Maslowski 6c138ca924 Merge pull request #3 from tiquis0290/dev
Dev
2026-04-03 16:45:38 +02:00
Marek Maslowski 1adc2fec86 Merge branch 'test' into dev 2026-04-03 16:44:14 +02:00
Marek Maslowski 8c7f8d6ad1 fixing routes for immich 2026-04-03 16:37:21 +02:00
Marek Maslowski 2ae9da3153 fix for auth tokens 2026-04-03 16:25:58 +02:00
Marek Maslowski b4741c31a9 moving business logic for synology to separet file 2026-04-03 16:25:45 +02:00
jubnl cfdbf9235f feat(helm): add all missing env vars from README to Helm chart
Add TZ, LOG_LEVEL, FORCE_HTTPS, TRUST_PROXY, OIDC_ISSUER, OIDC_CLIENT_ID,
OIDC_DISPLAY_NAME, OIDC_ONLY, OIDC_ADMIN_CLAIM, OIDC_ADMIN_VALUE, OIDC_SCOPE,
DEMO_MODE to values.yaml and configmap.yaml. Add OIDC_CLIENT_SECRET as a
secretEnv entry rendered in secret.yaml and mounted in deployment.yaml.
2026-04-03 16:15:18 +02:00
jubnl 059158d087 add feature request bad names as exclusion 2026-04-03 16:12:01 +02:00
jubnl 77393ff40b auto close issue on empty/bad title 2026-04-03 16:01:12 +02:00
jubnl 64d4a20403 feat: add MCP_RATE_LIMIT env variable to control MCP request rate
Document MCP_RATE_LIMIT in README, docker-compose, .env.example, Helm values and configmap.
2026-04-03 15:44:33 +02:00
jubnl 6b94c0632c feat: add about section in user setting with trek version + discord link 2026-04-03 15:30:10 +02:00
Maurice cb124ba3ec fix: show required indicator on day note title, disable save when empty 2026-04-03 15:24:13 +02:00
Maurice ba01b4acac fix: mobile day detail opens on single tap instead of double-click (#311) 2026-04-03 14:55:44 +02:00
jubnl ce72f45d9a Merge remote-tracking branch 'origin/dev' into dev 2026-04-03 14:45:34 +02:00
jubnl bf2eea18c3 Fix: add bypass for ssrf check to force dissallow internal ip 2026-04-03 14:45:12 +02:00
Maurice 501bab0f69 test: update cookie test to match sameSite lax change 2026-04-03 14:42:48 +02:00
Maurice 5dd80d5cb8 feat: Discord links, translation sync, iOS login fix, trip copy fix
- Add Discord button to admin GitHub panel and user menu
- Sync all 13 translation files to 1434 keys with native translations
- Fix duplicate keys in Polish translation (pl.ts)
- Fix iOS login race condition: sameSite strict→lax, loadUser sequence counter
- Fix trip copy route: add missing db, Trip, TRIP_SELECT imports
2026-04-03 14:39:44 +02:00
Julien G. 8f6de3cd23 Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-04-03 14:25:36 +02:00
Julien G. 816696d0fe Merge pull request #349 from mauriceboe/343-bug-attachments-in-collab-notes-seem-to-be-broken
fix: collab note attachments broken (#343)
2026-04-03 14:14:42 +02:00
jubnl bb54fda6dc fix: collab note attachments broken (#343)
- Fix attachment URLs to use /api/trips/:id/files/:id/download instead
  of /uploads/files/... which was unconditionally blocked with 401
- Use getAuthUrl() with ephemeral tokens for displaying attachments and
  opening them in a new tab (images, PDFs, documents)
- Replace htmlFor/id label pattern with ref.current.click() for the
  file picker button in NoteFormModal — fixes file not being added to
  pending list on first note creation
- Add integration tests COLLAB-028 to COLLAB-031 covering URL format,
  listing URLs, ephemeral token download, and unauthenticated 401
2026-04-03 14:11:18 +02:00
marco783 36f2292f2d added map preview to settings, change latitude and longitude with left click on the map (#348) 2026-04-03 13:21:47 +02:00
Julien G. 905c7d460b Add comprehensive backend test suite (#339)
* add test suite, mostly covers integration testing, tests are only backend side

* workflow runs the correct script

* workflow runs the correct script

* workflow runs the correct script

* unit tests incoming

* Fix multer silent rejections and error handler info leak

- Revert cb(null, false) to cb(new Error(...)) in auth.ts, collab.ts,
  and files.ts so invalid uploads return an error instead of silently
  dropping the file
- Error handler in app.ts now always returns 500 / "Internal server
  error" instead of forwarding err.message to the client

* Use statusCode consistently for multer errors and error handler

- Error handler in app.ts reads err.statusCode to forward the correct
  HTTP status while keeping the response body generic
2026-04-03 13:17:53 +02:00
Gérnyi Márk d48714d17a feat: add copy/duplicate trip from dashboard (#270)
New POST /api/trips/:id/copy endpoint that deep copies all trip
planning data (days, places, assignments, reservations, budget,
packing, accommodations, day notes) with proper FK remapping
inside a transaction. Skips files, collab data, and members.

Copy button on all dashboard card types (spotlight, grid, list,
archived) gated by trip_create permission. Translations for all
12 languages.

Also adds reminder_days to Trip interface (removes as-any casts).
2026-04-03 12:38:45 +02:00
Wojciech Chrzan a0db42fbfe feat(i18n): add Polish language support (#252) 2026-04-03 12:28:48 +02:00
Marek Maslowski 82a3940a2c Merge pull request #2 from tiquis0290/test-backup
Resolving conflicts with dev
2026-04-03 12:20:20 +02:00
Marek Maslowski b224f8b713 fixing errors in migration 2026-04-03 12:19:00 +02:00
Marek Maslowski be03fffcae fixing metada 2026-04-03 12:06:07 +02:00
Marek Maslowski 1e27a62b53 fixing path for asset in full res 2026-04-03 12:06:07 +02:00
Marek Maslowski d418d85d02 fixing selection of photos from multiple sources at once 2026-04-03 12:06:07 +02:00
Marek Maslowski a7d3f9fc06 returning test connectioon button to original intend 2026-04-03 12:06:07 +02:00
Marek Maslowski 7a169d0596 feat(integrations): add synology photos support 2026-04-03 12:04:30 +02:00
Marek Maslowski cf968969d0 refactor(memories): generalize photo providers and decouple from immich 2026-04-03 12:03:04 +02:00
Marek Maslowski c20d0256c8 fixing metada 2026-04-03 11:50:28 +02:00
Marek Maslowski c4236d6737 fixing path for asset in full res 2026-04-03 11:50:28 +02:00
Marek Maslowski 4b8cfc78b8 fixing selection of photos from multiple sources at once 2026-04-03 11:50:28 +02:00
Marek Maslowski f7c965bc6b returning test connectioon button to original intend 2026-04-03 11:50:28 +02:00
Marek Maslowski 78a91ccb95 feat(integrations): add synology photos support 2026-04-03 11:50:28 +02:00
Marek Maslowski 8e9f8784dc refactor(memories): generalize photo providers and decouple from immich 2026-04-03 11:50:00 +02:00
Maurice 5be2e9b268 add Discord community badge to README 2026-04-03 11:41:43 +02:00
Julien G. f4d0ccb454 Merge pull request #344 from marco783/addPeopleCount
added trip member count to dashboard
2026-04-03 11:23:10 +02:00
Marco Pasquali a40983e65e added trip member count to dashboard
added translations for  (generated with AI, so they could be wrong)
2026-04-03 11:10:21 +02:00
jubnl f32c103fe1 fix: deleted chats are not shown in share view anymore 2026-04-03 10:50:34 +02:00
Julien G. 0b77fe5292 Merge pull request #277 from Cod3d1PA/feat/holiday-hover-tooltip
feat: show holiday name on hover in calendar
2026-04-03 04:27:39 +02:00
jubnl 9afb51fcc0 fix: ensure invite link shows the register page. Closes #335 2026-04-03 03:58:44 +02:00
jubnl 4e10028669 document APP_URL usage 2026-04-03 03:51:29 +02:00
jubnl d4e16ebe49 fix: use APP_URL is defined as base url in mails 2026-04-03 03:44:45 +02:00
micro92 1e44b25a0c Add Accomodation to PDF 2026-04-02 20:59:02 -04:00
Julien G. 4ff03a1f2c Merge pull request #330 from jubnl/dev
rename import
2026-04-02 19:48:39 +02:00
jubnl 40f7c00adb rename import 2026-04-02 19:47:50 +02:00
Julien G. b43d8d119f Merge pull request #329 from jubnl/dev
feat: in-app notification system
2026-04-02 19:37:27 +02:00
jubnl 74e3f85866 fix: finish rename refactor 2026-04-02 19:09:43 +02:00
jubnl bbf3f0cae8 fix: update import paths after client-side file renames
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:59:22 +02:00
jubnl c0e9a771d6 feat: add in-app notification system with real-time delivery
Introduces a full in-app notification system with three types (simple,
boolean with server-side callbacks, navigate), three scopes (user, trip,
admin), fan-out persistence per recipient, and real-time push via
WebSocket. Includes a notification bell in the navbar, dropdown, dedicated
/notifications page, and a dev-only admin tab for testing all notification
variants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:57:52 +02:00
Maurice c49272efc1 add Discord community badge to README 2026-04-02 17:19:24 +02:00
Maurice 979322025d refactor: extract business logic from routes into reusable service modules 2026-04-02 17:14:53 +02:00
Maurice f0131632a7 fix: show icon-only trip tabs on mobile to prevent overflow 2026-04-02 15:05:36 +02:00
Maurice ffe91604b5 Merge pull request #273 from lucaam/undo_button_v2
feat: undo button for trip planner (+ fix to route preview)
2026-04-02 14:59:16 +02:00
Maurice e7fa8f5da9 fix: widen budget sidebar from 180px to 240px to prevent clipping 2026-04-02 14:55:10 +02:00
Maurice 3256f5156d fix: photo marker badge now renders above circle instead of clipped inside 2026-04-02 14:50:08 +02:00
Maurice d45073a0bd Merge pull request #298 from jubnl/dev
feat: Adds 2 environment variables to control initial admin user credentials, adds 1 environment variable to control OIDC scope
2026-04-02 14:34:28 +02:00
jubnl a4d6348a79 fix: add raw.githubusercontent.com to CSP connect-src for Atlas map
The Atlas feature fetches country GeoJSON from GitHub raw content, which
was blocked by the Content Security Policy connect-src directive.

Closes #285
2026-04-02 14:10:14 +02:00
jubnl c944a7d101 fix: allow unauthenticated access to public share links
Skip loadUser() and exclude /shared/ from the 401 redirect interceptor
so unauthenticated users can open shared trip links without being
redirected to /login. Fixes #308.
2026-04-02 14:05:38 +02:00
jubnl 45e0c7e546 fix: replace toast.warn with toast.warning in Immich save handler
toast.warn does not exist in the toast library; calling it threw an error
that was caught and displayed as "Could not connect to Immich" even when
the save succeeded. Fixes #309.
2026-04-02 13:59:08 +02:00
jubnl 32b63adc68 fix: add OIDC_SCOPE env var and document it across all config files
Fixes #306 — OIDC scopes were hardcoded to 'openid email profile',
causing OIDC_ADMIN_CLAIM-based role mapping to fail when the required
scope (e.g. 'groups') wasn't requested. The new OIDC_SCOPE variable
defaults to 'openid email profile groups' so group-based admin mapping
works out of the box. Variable is now documented in README, docker-compose,
.env.example, and the Helm chart values.
2026-04-02 07:46:58 +02:00
jubnl b1cca15f6f docs: add ADMIN_EMAIL and ADMIN_PASSWORD to README env vars table and compose snippet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:22:18 +02:00
jubnl dfeb7b3db7 Merge remote-tracking branch 'fork/dev'
merge
2026-04-01 23:14:15 +02:00
jubnl 50424fc574 feat: support ADMIN_EMAIL and ADMIN_PASSWORD env vars for initial admin setup
Allow the first-boot admin account to be configured via ADMIN_EMAIL and
ADMIN_PASSWORD environment variables. If both are set the account is created
with those credentials; otherwise the existing random-password fallback is
used. Documented across .env.example, docker-compose.yml, Helm chart
(values.yaml, secret.yaml, deployment.yaml), and CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:09:57 +02:00
Julien G. 12a910876e Merge pull request #1 from jubnl/main
apply hot fixes to dev
2026-04-01 23:07:38 +02:00
Maurice d73a5e223c Merge pull request #292 from jubnl/main 2026-04-01 21:52:26 +02:00
jubnl fd9567e3fe Merge remote-tracking branch 'fork/main' 2026-04-01 21:44:56 +02:00
jubnl ae04071466 docs: document COOKIE_SECURE and OIDC_DISCOVERY_URL across all config files
Adds COOKIE_SECURE (fixes login loop on plain-HTTP setups) and the previously
undocumented OIDC_DISCOVERY_URL to .env.example, docker-compose.yml, README.md,
chart/values.yaml, chart/templates/configmap.yaml, and chart/README.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:44:02 +02:00
Maurice 2ab3f59722 Merge pull request #290 from jubnl/main 2026-04-01 21:42:50 +02:00
Julien G. 7257fac859 Merge branch 'mauriceboe:main' into main 2026-04-01 21:20:50 +02:00
jubnl 1a4c04e239 fix: resolve Immich 401 passthrough causing spurious login redirects
- Auth middleware now tags its 401s with code: AUTH_REQUIRED so the
  client interceptor only redirects to /login on genuine session failures,
  not on upstream API errors
- Fix /albums and album sync routes using raw encrypted API key instead
  of getImmichCredentials() (which decrypts it), causing Immich to reject
  requests with 401
- Add toast error notifications for all Immich operations in MemoriesPanel
  that previously swallowed errors silently

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:19:53 +02:00
Maurice 39a495714f Merge pull request #284 from jubnl/main 2026-04-01 20:43:37 +02:00
jubnl fabf5a7e26 fix: remove redundant db import alias in index.ts
db was already imported as addonDb; the extra db named import was
unnecessary. Updated the one stray db.prepare call at line 155 to use
addonDb consistently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:38:25 +02:00
jubnl e71bd6768e fix: show actual backend error messages on login page and add missing db import
- LoginPage now uses getApiErrorMessage() instead of err.message so
  backend validation errors (e.g. "Password must be at least 8 characters")
  are displayed instead of the generic "Request failed with status code 400"
- Add missing db import in server/src/index.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:37:01 +02:00
Maurice 71403e6303 fix: always fetch fresh photo URLs for map markers instead of using stored HTTP URLs 2026-04-01 19:48:58 +02:00
Maurice 43fc4db00e fix: convert stored HTTP photo URLs to base64 for map markers, add exchangerate-api to CSP 2026-04-01 19:40:19 +02:00
Maurice e9ee2d4b0d fix: rebuild client assets with photoService and latest changes 2026-04-01 19:32:45 +02:00
Maurice 228cb05932 chore: bump version to 2.7.2 2026-04-01 19:13:32 +02:00
Cod3d1PA 505bf04a1f feat: show holiday name on hover in calendar
Add a native title tooltip to calendar day cells so hovering over a
public holiday reveals its name (and custom label if configured).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:01:15 +00:00
Luca 41bfcf2f76 fix: stale closure in updateRouteForDay causes route to disappear on place click
useCallback captured tripStore at creation time (dep: [routeCalcEnabled]).
If assignments were empty on first render (trip still loading), the callback
would permanently see empty assignments and call setRoute(null) whenever
invoked — e.g. when clicking a place triggers onSelectDay → updateRouteForDay.

Fix: store tripStore in a ref updated on every render so the callback always
reads the latest assignments without needing to be recreated.
2026-04-01 18:29:40 +02:00
Luca e308204808 feat: undo button for trip planner
Implements a full undo history system for the Plan screen.

New hook: usePlannerHistory (client/src/hooks/usePlannerHistory.ts)
- Maintains a LIFO stack (up to 30 entries) of reversible actions
- Exposes pushUndo(label, fn), undo(), canUndo, lastActionLabel

Tracked actions:
- Assign place to day (undo: remove the assignment)
- Remove place from day (undo: re-assign at original position)
- Reorder places within a day (undo: restore previous order)
- Move place to a different day (undo: move back)
- Optimize route (undo: restore original order)
- Lock / unlock place (undo: toggle back)
- Delete place (undo: recreate place + restore all day assignments)
- Add place (undo: delete it)
- Import from GPX (undo: delete all imported places)
- Import from Google Maps list (undo: delete all imported places)

UI: Undo button (Undo2 icon) in DayPlanSidebar header. PDF, ICS and
Undo buttons all use custom instant hover tooltips instead of native
title attributes.

A toast notification confirms each undo action.

Translations: undo.* keys added to all 12 language files.
2026-04-01 18:20:14 +02:00
Maurice 411d5408c1 fix: place inspector too narrow at intermediate window widths (#272)
Inspector now ignores sidebar widths when window is under 900px,
preventing it from being squeezed when sidebars are visually hidden
but their width values are still set.
2026-04-01 17:58:57 +02:00
Maurice 45684d9e44 Merge pull request #257 from jubnl/dev
Security hardening, encryption at rest
2026-04-01 17:42:43 +02:00
jubnl 0ebcff9504 Conflict resolution 2026-04-01 17:40:45 +02:00
Julien G. edafe01387 Merge branch 'dev' into dev 2026-04-01 17:30:31 +02:00
Maurice 16277a3811 security: fix missing trip access checks on Immich routes (GHSA-pcr3-6647-jh72)
security: require auth for uploaded photos (GHSA-wxx3-84fc-mrx2)

GHSA-pcr3-6647-jh72 (HIGH):
- Add canAccessTrip check to all /trips/:tripId/photos and
  /trips/:tripId/album-links endpoints
- Prevents authenticated users from accessing other trips' photos

GHSA-wxx3-84fc-mrx2 (LOW):
- /uploads/photos now requires JWT auth token or valid share token
- Covers and avatars remain public (needed for login/share pages)
- Files were already blocked behind auth
2026-04-01 15:46:08 +02:00
Maurice ef5b381f8e feat: collapse days hides map markers, Immich test-before-save (#216)
Map markers:
- Collapsing a day in the sidebar hides its places from the map
- Places assigned to multiple days only hide when all days collapsed
- Unplanned places always stay visible

Immich settings:
- New POST /integrations/immich/test endpoint validates credentials
  without saving them
- Save button disabled until test connection passes
- Changing URL or API key resets test status
- i18n: testFirst key for all 12 languages
2026-04-01 15:30:59 +02:00
Maurice ef9880a2a5 feat: Immich album linking with auto-sync (#206)
- Link Immich albums to trips — photos sync automatically
- Album picker shows all user's Immich albums
- Linked albums displayed as chips with sync/unlink buttons
- Auto-sync on link: fetches all album photos and adds to trip
- Manual re-sync button for each linked album
- DB migration: trip_album_links table

fix: shared Immich photos visible to other trip members

- Thumbnail/original proxy now uses photo owner's Immich credentials
  when userId query param is provided, fixing 404 for shared photos
- i18n: album keys for all 12 languages
2026-04-01 15:21:20 +02:00
Maurice 95cb81b0e5 perf: major trip planner performance overhaul (#218)
Store & re-render optimization:
- TripPlannerPage uses selective Zustand selectors instead of full store
- placesSlice only updates affected days on place update/delete
- Route calculation only reacts to selected day's assignments
- DayPlanSidebar uses stable action refs instead of full store

Map marker performance:
- Shared photoService for PlaceAvatar and MapView (single cache, no duplicate requests)
- Client-side base64 thumbnail generation via canvas (CORS-safe for Wikimedia)
- Map markers use base64 data URL <img> tags for smooth zoom (no external image decode)
- Sidebar uses same base64 thumbnails with IntersectionObserver for visible-first loading
- Icon cache prevents duplicate L.divIcon creation
- MarkerClusterGroup with animate:false and optimized chunk settings
- Photo fetch deduplication and batched state updates

Server optimizations:
- Wikimedia image size reduced to 400px (from 600px)
- Photo cache: 5min TTL for errors (was 12h), prevents stale 404 caching
- Removed unused image-proxy endpoint

UX improvements:
- Splash screen with plane animation during initial photo preload
- Markdown rendering in DayPlanSidebar place descriptions
- Missing i18n keys added, all 12 languages synced to 1376 keys
2026-04-01 14:56:01 +02:00
Maurice 7d0ae631b8 fix: mobile place editing and detail view (#269)
- PlacesSidebar mobile: tap opens action sheet with view details,
  edit, assign to day, and delete options
- PlaceInspector renders as fullscreen portal overlay on mobile
- DayPlanSidebar mobile: tapping a place closes overlay and opens
  inspector
- Inspector closes when edit or delete is triggered on mobile
- i18n: added places.viewDetails for all 12 languages
2026-04-01 12:38:44 +02:00
Maurice 5c04074d54 fix: allow unauthenticated SMTP by saving empty user/pass fields (#265)
The test-smtp button filtered out empty SMTP user/password values
before saving, preventing unauthenticated SMTP setups from working.
Changed filter from truthy check to !== undefined so empty strings
are properly persisted.
2026-04-01 12:20:03 +02:00
Maurice e89ba2ecfc fix: add referrerPolicy to TileLayer to fix OSM tile blocking (#264)
OpenStreetMap requires a Referer header per their tile usage policy.
Without it, tiles are blocked with "Access blocked" error.
2026-04-01 12:17:53 +02:00
Maurice 4ebf9c5f11 feat: add expense date and CSV export to budget
- New expense_date column on budget items (DB migration #42)
- Date column in budget table with custom date picker
- CSV export button with BOM, semicolon separator, localized dates,
  currency in header, per-person/day calculations
- CustomDatePicker compact/borderless modes for inline table use
- i18n keys for all 12 languages
2026-04-01 12:16:11 +02:00
jubnl add0b17e04 feat(auth): migrate JWT storage from localStorage to httpOnly cookies
Eliminates XSS token theft risk by storing session JWTs in an httpOnly
cookie (trek_session) instead of localStorage, making them inaccessible
to JavaScript entirely.

- Add cookie-parser middleware and setAuthCookie/clearAuthCookie helpers
- Set trek_session cookie on login, register, demo-login, MFA verify, OIDC exchange
- Auth middleware reads cookie first, falls back to Authorization: Bearer (MCP unchanged)
- Add POST /api/auth/logout to clear the cookie server-side
- Remove all localStorage auth_token reads/writes from client
- Axios uses withCredentials; raw fetch calls use credentials: include
- WebSocket ws-token exchange uses credentials: include (no JWT param)
- authStore initialises isLoading: true so ProtectedRoute waits for /api/auth/me

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 11:02:45 +02:00
Maurice 60906cf1d1 fix: hide MCP tokens tab when addon inactive, move permissions to users tab
- MCP tokens tab only shown when MCP addon is enabled
- Permissions panel moved from own tab to users tab below invite links
- Fixed inconsistent dropdown widths in permissions panel
2026-04-01 10:39:43 +02:00
Julien G. 9292acb979 Merge branch 'dev' into dev 2026-04-01 10:27:51 +02:00
Maurice be57b7130f feat: render markdown in place descriptions, notes and reservations
Use react-markdown with remark-gfm for place description/notes
in PlaceInspector and day note subtitles and reservation notes
in DayPlanSidebar. Reuses existing collab-note-md CSS styles.
2026-04-01 10:19:59 +02:00
jubnl b88a8fcbb5 fix: unify password validation error to show all requirements at once
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:14:35 +02:00
Maurice 040840917c feat: add Google Maps list import
Import places from shared Google Maps lists via URL.
Button in places sidebar next to GPX import opens a modal
where users can paste a shared list link. Server fetches
list data from Google Maps and creates places with name,
coordinates and notes. i18n keys added for all 12 languages.

Closes #205
2026-04-01 10:13:35 +02:00
jubnl 44e5f07f59 fix: persist encryption key to disk regardless of resolution source
Previously, when the JWT secret was used as a fallback encryption key,
nothing was written to data/.encryption_key. This meant that rotating
the JWT secret via the admin panel would silently break decryption of
all stored secrets on the next restart.

Now, whatever key is resolved — env var, JWT secret fallback, or
auto-generated — is immediately persisted to data/.encryption_key.
On all subsequent starts, the file is read directly and the fallback
chain is skipped entirely, making JWT rotation permanently safe.

The env var path also writes to the file so the key survives container
restarts if the env var is later removed.
2026-04-01 10:03:46 +02:00
jubnl c9e61859ce chore(helm): update ENCRYPTION_KEY docs to reflect automatic fallback
Existing installs no longer need to manually set ENCRYPTION_KEY to their
old JWT secret on upgrade — the server falls back to data/.jwt_secret
automatically. Update values.yaml, NOTES.txt, and chart README accordingly.
2026-04-01 09:50:38 +02:00
jubnl 862f59b77a chore: update docker-compose ENCRYPTION_KEY comment to match new behaviour 2026-04-01 09:50:28 +02:00
jubnl 871bfd7dfd fix: make ENCRYPTION_KEY optional with backwards-compatible fallback
process.exit(1) when ENCRYPTION_KEY is unset was a breaking change for
existing installs — a plain git pull would prevent the server from
starting.

Replace with a three-step fallback:
  1. ENCRYPTION_KEY env var (explicit, takes priority)
  2. data/.jwt_secret (existing installs: encrypted data stays readable
     after upgrade with zero manual intervention)
  3. data/.encryption_key auto-generated on first start (fresh installs)

A warning is logged when falling back to the JWT secret so operators
are nudged toward setting ENCRYPTION_KEY explicitly.

Update README env table and Docker Compose comment to reflect that
ENCRYPTION_KEY is recommended but no longer required.
2026-04-01 09:50:17 +02:00
jubnl 4d596f2ff9 feat: add encryption key migration script and document it in README
Add server/scripts/migrate-encryption.ts — a standalone script that
re-encrypts all at-rest secrets (OIDC client secret, SMTP password,
Maps/OpenWeather/Immich API keys, MFA secrets) when rotating
ENCRYPTION_KEY, without requiring the app to be running.

- Prompts for old and new keys interactively; input is never echoed,
  handles copy-pasted keys correctly via a shared readline interface
  with a line queue to prevent race conditions on piped/pasted input
- Creates a timestamped DB backup before any changes
- Idempotent: detects already-migrated values by trying the new key
- Exits non-zero and retains the backup if any field fails

README updates:
- Add .env setup step (openssl rand -hex 32) before the Docker Compose
  snippet so ENCRYPTION_KEY is set before first start
- Add ENCRYPTION_KEY to the docker run one-liner
- Add "Rotating the Encryption Key" section documenting the script,
  the docker exec command, and the upgrade path via ./data/.jwt_secret

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:35:32 +02:00
Maurice 8c85ea3644 fix: restrict trip listing and access to own/shared trips only (#250)
Remove admin override that returned all trips regardless of ownership.
Admins now see only their own trips and trips where they are a member.
2026-04-01 09:29:28 +02:00
jubnl 19350fbc3e fix: point upgraders to ./data/.jwt_secret in ENCRYPTION_KEY error and docs
The startup error now tells operators exactly where to find the old key
value (./data/.jwt_secret) rather than just saying "your old JWT_SECRET".
docker-compose.yml and README updated to mark ENCRYPTION_KEY as required
and remove the stale "auto-generated" comments.
2026-04-01 08:43:45 +02:00
jubnl 358afd2428 fix: require ENCRYPTION_KEY at startup instead of auto-generating
Auto-generating and persisting the key to data/.encryption_key co-locates
the key with the database, defeating encryption at rest if an attacker can
read the data directory. It also silently loses all encrypted secrets if the
data volume is recreated.

Replace the auto-generation fallback with a hard startup error that tells
operators exactly what to do:
- Upgraders from the JWT_SECRET-derived encryption era: set ENCRYPTION_KEY
  to their old JWT_SECRET so existing ciphertext remains readable.
- Fresh installs: generate a key with `openssl rand -hex 32`.
2026-04-01 08:43:36 +02:00
jubnl 7a314a92b1 fix: add SSRF protection for link preview and Immich URL
- Create server/src/utils/ssrfGuard.ts with checkSsrf() and createPinnedAgent()
  - Resolves DNS before allowing outbound requests to catch hostnames that
    map to private IPs (closes the TOCTOU gap in the old inline checks)
  - Always blocks loopback (127.x, ::1) and link-local/metadata (169.254.x)
  - RFC-1918, CGNAT (100.64/10), and IPv6 ULA ranges blocked by default;
    opt-in via ALLOW_INTERNAL_NETWORK=true for self-hosters running Immich
    on a local network
  - createPinnedAgent() pins node-fetch to the validated IP, preventing
    DNS rebinding between the check and the actual connection

- Replace isValidImmichUrl() (hostname-string check, no DNS resolution)
  with checkSsrf(); make PUT /integrations/immich/settings async
  - Audit log entry (immich.private_ip_configured) written when a user
    saves an Immich URL that resolves to a private IP
  - Response includes a warning field surfaced as a toast in the UI

- Replace ~20 lines of duplicated inline SSRF logic in the link-preview
  handler with a single checkSsrf() call + pinned agent

- Document ALLOW_INTERNAL_NETWORK in README, docker-compose.yml,
  server/.env.example, chart/values.yaml, chart/templates/configmap.yaml,
  and chart/README.md
2026-04-01 07:59:03 +02:00
jubnl e03505dca2 fix: enforce consistent password policy across all auth flows
Replace duplicated inline validation with a shared validatePassword()
utility that checks minimum length (8), rejects repetitive and common
passwords, and requires uppercase, lowercase, a digit, and a special
character.

- Add server/src/services/passwordPolicy.ts as single source of truth
- Apply to registration, password change, and admin create/edit user
  (admin routes previously had zero validation)
- Fix client min-length mismatch (6 vs 8) in RegisterPage and LoginPage
- Add client-side password length guard to AdminPage forms
- Update register.passwordTooShort and settings.passwordWeak i18n keys
  in all 12 locales to reflect the corrected requirements
2026-04-01 07:58:46 +02:00
jubnl ce8d498f2d fix: add independent rate limiter for MFA verification endpoints
TOTP brute-force is a realistic attack once a password is compromised:
with no independent throttle, an attacker shared the login budget (10
attempts) across /login, /register, and /mfa/verify-login, and
/mfa/enable had no rate limiting at all.

- Add a dedicated `mfaAttempts` store so MFA limits are tracked
  separately from login attempts
- Introduce `mfaLimiter` (5 attempts / 15 min) applied to both
  /mfa/verify-login and /mfa/enable
- Refactor `rateLimiter()` to accept an optional store parameter,
  keeping all existing call-sites unchanged
- Include mfaAttempts in the periodic cleanup interval
2026-04-01 07:58:29 +02:00
jubnl b109c1340a fix: prevent ICS header injection in calendar export
Three vulnerabilities patched in the /export.ics route:

- esc() now handles bare \r and CRLF sequences — the previous regex only
  matched \n, leaving \r intact and allowing CRLF injection via \r\n
- reservation DESCRIPTION field was built from unescaped user data
  (type, confirmation_number, notes, airline, flight/train numbers,
  airports) and written raw into ICS output; now passed through esc()
- Content-Disposition filename used ICS escaping instead of HTTP header
  sanitization; replaced with a character allowlist to prevent " and
  \r\n injection into the response header
2026-04-01 07:58:18 +02:00
jubnl e10f6bf9af fix: remove JWT_SECRET env var — server manages it exclusively
Setting JWT_SECRET via environment variable was broken by design:
the admin panel rotation updates the in-memory binding and persists
the new value to data/.jwt_secret, but an env var would silently
override it on the next restart, reverting the rotation.

The server now always loads JWT_SECRET from data/.jwt_secret
(auto-generating it on first start), making the file the single
source of truth. Rotation is handled exclusively through the admin
panel.

- config.ts: drop process.env.JWT_SECRET fallback and
  JWT_SECRET_IS_GENERATED export; always read from / write to
  data/.jwt_secret
- index.ts: remove the now-obsolete JWT_SECRET startup warning
- .env.example, docker-compose.yml, README: remove JWT_SECRET entries
- Helm chart: remove JWT_SECRET from secretEnv, secret.yaml, and
  deployment.yaml; rename generateJwtSecret → generateEncryptionKey
  and update NOTES.txt and README accordingly
2026-04-01 07:58:05 +02:00
jubnl 6f5550dc50 fix: decouple at-rest encryption from JWT_SECRET, add JWT rotation
Introduces a dedicated ENCRYPTION_KEY for encrypting stored secrets
(API keys, MFA TOTP, SMTP password, OIDC client secret) so that
rotating the JWT signing secret no longer invalidates encrypted data,
and a compromised JWT_SECRET no longer exposes stored credentials.

- server/src/config.ts: add ENCRYPTION_KEY (auto-generated to
  data/.encryption_key if not set, same pattern as JWT_SECRET);
  switch JWT_SECRET to `export let` so updateJwtSecret() keeps the
  CJS module binding live for all importers without restart
- apiKeyCrypto.ts, mfaCrypto.ts: derive encryption keys from
  ENCRYPTION_KEY instead of JWT_SECRET
- admin POST /rotate-jwt-secret: generates a new 32-byte hex secret,
  persists it to data/.jwt_secret, updates the live in-process binding
  via updateJwtSecret(), and writes an audit log entry
- Admin panel (Settings → Danger Zone): "Rotate JWT Secret" button
  with a confirmation modal warning that all sessions will be
  invalidated; on success the acting admin is logged out immediately
- docker-compose.yml, .env.example, README, Helm chart (values.yaml,
  secret.yaml, deployment.yaml, NOTES.txt, README): document
  ENCRYPTION_KEY and its upgrade migration path
2026-04-01 07:57:55 +02:00
jubnl dfdd473eca fix: validate uploaded backup DB before restore
Before swapping in a restored database, run PRAGMA integrity_check and
verify the five core TREK tables (users, trips, trip_members, places,
days) are present. This blocks restoring corrupt, empty, or unrelated
SQLite files that would otherwise crash the app immediately after swap,
and prevents a malicious admin from hot-swapping a crafted database with
forged users or permissions.
2026-04-01 07:57:42 +02:00
jubnl b515880adb fix: encrypt Immich API key at rest using AES-256-GCM
Per-user Immich API keys were stored as plaintext in the users table,
giving any attacker with DB read access full control over each user's
Immich photo server. Keys are now encrypted on write with
maybe_encrypt_api_key() and decrypted at the point of use via a shared
getImmichCredentials() helper. A new migration (index 66) back-fills
encryption for any existing plaintext values on startup.
2026-04-01 07:57:29 +02:00
jubnl 78695b4e03 fix: replace JWT tokens in URL query params with short-lived ephemeral tokens
Addresses CWE-598: long-lived JWTs were exposed in WebSocket URLs, file
download links, and Immich asset proxy URLs, leaking into server logs,
browser history, and Referer headers.

- Add ephemeralTokens service: in-memory single-use tokens with per-purpose
  TTLs (ws=30s, download/immich=60s), max 10k entries, periodic cleanup
- Add POST /api/auth/ws-token and POST /api/auth/resource-token endpoints
- WebSocket auth now consumes an ephemeral token instead of verifying the JWT
  directly from the URL; client fetches a fresh token before each connect
- File download ?token= query param now accepts ephemeral tokens; Bearer
  header path continues to accept JWTs for programmatic access
- Immich asset proxy replaces authFromQuery JWT injection with ephemeral token
  consumption
- Client: new getAuthUrl() utility, AuthedImg/ImmichImg components, and async
  onClick handlers replace the synchronous authUrl() pattern throughout
  FileManager, PlaceInspector, and MemoriesPanel
- Add OIDC_DISCOVERY_URL env var and oidc_discovery_url DB setting to allow
  overriding the auto-constructed discovery endpoint (required for Authentik
  and similar providers); exposed in the admin UI and .env.example
2026-04-01 07:57:14 +02:00
jubnl 0ee53e7b38 fix: prevent OIDC redirect URI construction from untrusted X-Forwarded-Host
The OIDC login route silently fell back to building the redirect URI from
X-Forwarded-Host/X-Forwarded-Proto when APP_URL was not configured. An
attacker could set X-Forwarded-Host: attacker.example.com to redirect the
authorization code to their own server after the user authenticates.

Remove the header-derived fallback entirely. If APP_URL is not set (via env
or the app_url DB setting), the OIDC login endpoint now returns a 500 error
rather than trusting attacker-controlled request headers. Document APP_URL
in .env.example as required for OIDC use.
2026-04-01 07:56:55 +02:00
jubnl 1b28bd96d4 fix: encrypt SMTP password at rest using AES-256-GCM
The smtp_pass setting was stored as plaintext in app_settings, exposing
SMTP credentials to anyone with database read access. Apply the same
encrypt_api_key/decrypt_api_key pattern already used for OIDC client
secrets and API keys. A new migration transparently re-encrypts any
existing plaintext value on startup; decrypt_api_key handles legacy
plaintext gracefully so in-flight reads remain safe during upgrade.
2026-04-01 07:56:43 +02:00
jubnl bba50f038b fix: encrypt OIDC client secret at rest using AES-256-GCM
The oidc_client_secret was written to app_settings as plaintext,
unlike Maps and OpenWeather API keys which are protected with
apiKeyCrypto. An attacker with read access to the SQLite file
(e.g. via a backup download) could obtain the secret and
impersonate the application with the identity provider.

- Encrypt on write in PUT /api/admin/oidc via maybe_encrypt_api_key
- Decrypt on read in GET /api/admin/oidc and in getOidcConfig()
  (oidc.ts) before passing the secret to the OIDC client library
- Add a startup migration that encrypts any existing plaintext value
  already present in the database
2026-04-01 07:56:29 +02:00
jubnl 701a8ab03a fix: route db helper functions through the null-safe proxy
getPlaceWithTags, canAccessTrip, and isOwner were calling _db! directly,
bypassing the Proxy that guards against null-dereference during a backup
restore. When the restore handler briefly sets _db = null, any concurrent
request hitting these helpers would crash with an unhandled TypeError
instead of receiving a clean 503-style error.

Replace all four _db! accesses with the exported db proxy so the guard
("Database connection is not available") fires consistently.
2026-04-01 07:56:01 +02:00
jubnl ccb5f9df1f fix: wrap each migration in a transaction and surface swallowed errors
Previously, the migration runner called each migration function directly with no transaction wrapping and updated schema_version only after all pending migrations had run. A mid-migration failure (e.g. disk full after ALTER TABLE but before CREATE INDEX) would leave the schema in a partially-applied state with no rollback path. On the next restart the broken migration would be skipped — because schema_version had not advanced — but only if the failure was noticed at all.

~43 catch {} blocks silently discarded every error, including non-idempotency errors such as disk-full or corruption, making it impossible to know a migration had failed.

Changes:
- Each migration now runs inside db.transaction(); better-sqlite3 rolls back automatically on throw.
- schema_version is updated after every individual migration succeeds, so a failure does not cause already-applied migrations to re-run.
- A migration that throws after rollback logs FATAL and calls process.exit(1), refusing to start with a broken schema.
- All catch {} blocks on ALTER TABLE ADD COLUMN re-throw any error that is not "duplicate column name", so only the expected idempotency case is swallowed.
- Genuinely optional steps (INSERT OR IGNORE, UPDATE data-copy, DROP TABLE IF EXISTS) now log a warning instead of discarding the error entirely.
2026-04-01 07:55:35 +02:00
jubnl c9341eda3f fix: remove RCE vector from admin update endpoint.
The POST /api/admin/update endpoint ran git pull, npm install, and npm run build via execSync, potentially giving any compromised admin account full code execution on the host in case repository is compromised. TREK ships as a Docker image so runtime self-updating is unnecessary.
- Remove the /update route and child_process import from admin.ts
- Remove the installUpdate API client method
- Replace the live-update modal with an info-only panel showing docker pull instructions and a link to the GitHub release
- Drop the updating/updateResult state and handleInstallUpdate handler
2026-04-01 07:55:34 +02:00
Maurice fb2e8d8209 fix: keep marker tooltip visible on touch devices when selected
On mobile/touch devices, Leaflet tooltips disappear immediately on tap
since there is no hover state. This makes the info bubble permanent for
the selected marker on touch devices so it stays readable.

Fixes #249
2026-04-01 00:11:12 +02:00
Maurice 27fb9246e6 Merge pull request #238 from slashwarm/feat/permissions-admin-panel
feat: configurable permissions system in admin
2026-04-01 00:05:14 +02:00
Gérnyi Márk 9a2c7c5db6 fix: address PR review feedback
- Suppress note context menu when canEditDays is false instead of
  showing empty menu
- Untie poll voting from collab_edit — voting is participation, not
  editing; any trip member can vote
- Restore NoteFormModal props (note, tripId) to required; remove
  leftover canUploadFiles prop in favor of direct zustand hook
2026-03-31 23:56:19 +02:00
Gérnyi Márk d1ad5da919 fix: tighten trip_edit and member_manage defaults to trip_owner
Previously defaulted to trip_member which is more permissive than
upstream behavior. Admins can still open it up via the panel.
2026-03-31 23:52:29 +02:00
Gérnyi Márk 1fbc19ad4f fix: add missing permission checks to file routes and map context menu
- Add checkPermission to 6 unprotected file endpoints (star, restore,
  permanent delete, empty trash, link, unlink)
- Gate map right-click place creation with place_edit permission
- Use file_upload permission for collab note file uploads
2026-03-31 23:45:11 +02:00
Gérnyi Márk 23edfe3dfc fix: harden permissions system after code review
- Gate permissions in /app-config behind optionalAuth so unauthenticated
  requests don't receive admin configuration
- Fix trip_delete isMember parameter (was hardcoded false)
- Return skipped keys from savePermissions for admin visibility
- Add disabled prop to CustomSelect, use in BudgetPanel currency picker
- Fix CollabChat reaction handler returning false instead of void
- Pass canUploadFiles as prop to NoteFormModal instead of internal store read
- Make edit-only NoteFormModal props optional (onDeleteFile, note, tripId)
- Add missing trailing newlines to .gitignore and it.ts
2026-03-31 23:36:17 +02:00
Gérnyi Márk 1ff8546484 fix: i18n chat reply/delete titles, gate collab category settings 2026-03-31 23:36:17 +02:00
Gérnyi Márk 6d18d5ed2d fix: gate collab notes category settings button with collab_edit 2026-03-31 23:36:16 +02:00
Gérnyi Márk 6d5067247c refactor: remove dead isAdmin prop from dashboard cards
Permission gating via useCanDo() makes the isAdmin prop redundant —
admin bypass is handled inside the permission system itself.
2026-03-31 23:36:16 +02:00
Gérnyi Márk 5e05bcd0db Revert "fix: change trip_edit to trip_owner"
This reverts commit 24f95be247ee0bdf49ab72fa69d4261c61194d63.
2026-03-31 23:36:16 +02:00
Gérnyi Márk 5f71b85c06 feat: add client-side permission gating to all write-action UIs
Gate all mutating UI elements with useCanDo() permission checks:
- BudgetPanel (budget_edit), PackingListPanel (packing_edit)
- DayPlanSidebar, DayDetailPanel (day_edit)
- ReservationsPanel, ReservationModal (reservation_edit)
- CollabNotes, CollabPolls, CollabChat (collab_edit)
- FileManager (file_edit, file_delete, file_upload)
- PlaceFormModal, PlaceInspector, PlacesSidebar (place_edit, file_upload)
- TripFormModal (trip_edit, trip_cover_upload)
- DashboardPage (trip_edit, trip_cover_upload, trip_delete, trip_archive)
- TripMembersModal (member_manage, share_manage)

Also: fix redundant getTripOwnerId queries in trips.ts, remove dead
getTripOwnerId function, fix TripMembersModal grid when share hidden,
fix canRemove logic, guard TripListItem empty actions div.
2026-03-31 23:36:16 +02:00
Gérnyi Márk d74133745a chore: update package-lock.json and .gitignore 2026-03-31 23:36:16 +02:00
Gérnyi Márk eee2bbe47a fix: change trip_edit to trip_owner 2026-03-31 23:36:16 +02:00
Gérnyi Márk c1bce755ca refactor: dedupe database requests 2026-03-31 23:36:15 +02:00
Gérnyi Márk 015be3d53a fix: incorrect hook order 2026-03-31 23:36:15 +02:00
Gérnyi Márk 7d3b37a2a3 feat: add configurable permissions system with admin panel
Adds a full permissions management feature allowing admins to control
who can perform actions across the app (trip CRUD, files, places,
budget, packing, reservations, collab, members, share links).

- New server/src/services/permissions.ts: 16 configurable actions,
  in-memory cache, checkPermission() helper, backwards-compatible
  defaults matching upstream behaviour
- GET/PUT /admin/permissions endpoints; permissions loaded into
  app-config response so clients have them on startup
- checkPermission() applied to all mutating route handlers across
  10 server route files; getTripOwnerId() helper eliminates repeated
  inline DB queries; trips.ts and files.ts now reuse canAccessTrip()
  result to avoid redundant DB round-trips
- New client/src/store/permissionsStore.ts: Zustand store +
  useCanDo() hook; TripOwnerContext type accepts both Trip and
  DashboardTrip shapes without casting at call sites
- New client/src/components/Admin/PermissionsPanel.tsx: categorised
  UI with per-action dropdowns, customised badge, save/reset
- AdminPage, DashboardPage, FileManager, PlacesSidebar,
  TripMembersModal gated via useCanDo(); no prop drilling
- 46 perm.* translation keys added to all 12 language files
2026-03-31 23:36:15 +02:00
Maurice ff1c1ed56a Merge branch 'dev' of https://github.com/mauriceboe/TREK into dev 2026-03-31 23:23:17 +02:00
Maurice d5674e9a11 fix: archive restore/delete buttons not visible in dark mode 2026-03-31 23:18:04 +02:00
Maurice 7eabe65bcf Merge pull request #240 from Summerfeeling/feat/more-currencies
feat: added all supported currencies from exchangerate-api (#229)
2026-03-31 23:12:32 +02:00
Maurice 3444e3f446 Merge branch 'perf-test' of https://github.com/jubnl/TREK into dev
# Conflicts:
#	client/src/components/Map/MapView.tsx
2026-03-31 23:10:02 +02:00
Maurice 9e3ac1e490 fix: increase max trip duration from 90 to 365 days 2026-03-31 22:58:27 +02:00
Maurice c38e70e244 fix: toggle switches not reflecting state in admin settings 2026-03-31 22:49:31 +02:00
Maurice ce7215341f fix: 12h time format input and display in bookings
- Allow typing AM/PM in time input when 12h format is active
- Format end time correctly in reservations panel (handle time-only strings)
2026-03-31 22:40:59 +02:00
Maurice 4733955531 fix: render Lucide category icons on map markers instead of text/emoji 2026-03-31 22:35:43 +02:00
Maurice 36267de117 fix: bag modal cut off on small screens 2026-03-31 22:27:26 +02:00
Maurice cd13399da5 fix: show selected map template in settings dropdown 2026-03-31 22:18:42 +02:00
Maurice 36cd2feca5 fix: use Nominatim reverse geocoding for accurate country detection in atlas
Bounding boxes overlap for neighboring countries (e.g. Munich matched
Austria instead of Germany). Now uses Nominatim reverse geocoding with
in-memory cache as primary fallback, bounding boxes only as last resort.
2026-03-31 21:58:20 +02:00
Maurice fbe3b5b17e Merge pull request #225 from andreibrebene/improvements/various-improvements
Improvements/various improvements
2026-03-31 21:40:26 +02:00
Maurice 10107ecf31 fix: require auth for file downloads, localize atlas search, use flag images
- Block direct access to /uploads/files (401), serve via authenticated
  /api/trips/:tripId/files/:id/download with JWT verification
- Client passes auth token as query parameter for direct links
- Atlas country search now uses Intl.DisplayNames (user language) instead
  of English GeoJSON names
- Atlas search results use flagcdn.com flag images instead of emoji
2026-03-31 21:38:16 +02:00
Andrei Brebene 94d698e39f docs: simplify README docker-compose example to essential env vars only
Made-with: Cursor
2026-03-31 22:24:08 +03:00
Andrei Brebene 6c88a01123 docs: document all env vars and remove SMTP/webhook from docker config
SMTP and webhook settings are configured via Admin UI only.

Made-with: Cursor
2026-03-31 22:24:07 +03:00
Andrei Brebene 75af89de30 docs: remove SMTP and webhook env vars (configured via Admin UI only)
Made-with: Cursor
2026-03-31 22:23:53 +03:00
Andrei Brebene ed8518aca4 docs: document all environment variables in docker-compose, .env.example, and README
Made-with: Cursor
2026-03-31 22:23:53 +03:00
Andrei Brebene 7522f396e7 feat: configurable trip reminders, admin full access, and enhanced audit logging
- Add configurable trip reminder days (1, 3, 9 or custom up to 30) settable by trip owner
- Grant administrators full access to edit, archive, delete, view and list all trips
- Show trip owner email in audit logs and docker logs when admin edits/deletes another user's trip
- Show target user email in audit logs when admin edits or deletes a user account
- Use email instead of username in all notifications (Discord/Slack/email) to avoid ambiguity
- Grey out notification event toggles when no SMTP/webhook is configured
- Grey out trip reminder selector when notifications are disabled
- Skip local admin account creation when OIDC_ONLY=true with OIDC configured
- Conditional scheduler logging: show disabled reason or active reminder count
- Log per-owner reminder creation/update in docker logs
- Demote 401/403 HTTP errors to DEBUG log level to reduce noise
- Hide edit/archive/delete buttons for non-owner invited users on trip cards
- Fix literal "0" rendering on trip cards from SQLite numeric is_owner field
- Add missing translation keys across all 14 language files

Made-with: Cursor
2026-03-31 22:23:38 +03:00
Andrei Brebene 9b2f083e4b feat: notifications, audit logging, and admin improvements
- Add centralized notification service with webhook (Discord/Slack) and
  email (SMTP) support, triggered for trip invites, booking changes,
  collab messages, and trip reminders
- Webhook sends one message per event (group channel); email sends
  individually per trip member, excluding the actor
- Discord invite notifications now include the invited user's name
- Add LOG_LEVEL env var (info/debug) controlling console and file output
- INFO logs show user email, action, and IP for audit events; errors
  for HTTP requests
- DEBUG logs show every request with full body/query (passwords redacted),
  audit details, notification params, and webhook payloads
- Add persistent trek.log file logging with 10MB rotation (5 files)
  in /app/data/logs/
- Color-coded log levels in Docker console output
- Timestamps without timezone name (user sets TZ via Docker)
- Add Test Webhook and Save buttons to admin notification settings
- Move notification event toggles to admin panel
- Add daily trip reminder scheduler (9 AM, timezone-aware)
- Wire up booking create/update/delete and collab message notifications
- Add i18n keys for notification UI across all 13 languages

Made-with: Cursor
2026-03-31 22:23:23 +03:00
jubnl 9a949d7391 Performance on trip planner (Maybe ?) 2026-03-31 21:13:29 +02:00
Summerfeeling | Timo 13904fb702 feat: added all supported currencies from exchangerate-api (#229) 2026-03-31 21:04:59 +02:00
Maurice f7160e6dec Merge pull request #179 from shanelord01/audit/remediation-clean
Automated Security & Quality Audit via Claude Code
2026-03-31 20:53:48 +02:00
Maurice 1983691950 Merge branch 'feat/add-searchbar-in-atlas' of https://github.com/Akashic101/NOMAD into dev
# Conflicts:
#	client/src/i18n/translations/cs.ts
#	client/src/i18n/translations/it.ts
2026-03-31 20:29:23 +02:00
Maurice 6866644d0c Merge pull request #189 from M-Enderle/feat/gpx-full-route-import
feat(add-gpx-tracks): adds better gpx track views
2026-03-31 20:17:22 +02:00
Maurice b120aabaa3 Merge pull request #191 from M-Enderle/feat/mcp-improvements
feat(mcp-improvements): add search_place, list_categories tools + fix opening hours in MCP
2026-03-31 20:16:04 +02:00
Maurice 1d442c1d7a Merge pull request #182 from BKSalman/mobile-fixes
mobile UI fixes
2026-03-31 20:14:30 +02:00
Maurice 9a0294360c Merge pull request #181 from BKSalman/accom-fix
fix: update dayAccommodations state after create/edit/delete
2026-03-31 20:10:42 +02:00
Maurice 9de0c5b051 Merge remote-tracking branch 'origin/dev' into asteriskyg/main
# Conflicts:
#	server/src/routes/files.ts
2026-03-31 20:08:42 +02:00
Maurice 9e9b86f1b4 Merge branch 'fix/encrypt-api-keys' of https://github.com/Akashic101/NOMAD into dev 2026-03-31 20:03:55 +02:00
David Moll 8ff5ec486f Merge branch 'main' into feat/add-searchbar-in-atlas 2026-03-31 12:31:14 +02:00
David Moll 5576339bcc feat(atlas): add searchbar 2026-03-31 12:27:13 +02:00
Moritz Enderle e668e80f1c feat: add search_place, list_categories tools + fix opening hours in MCP
- Add google_place_id and osm_id params to create_place tool so the app
  can fetch opening hours and ratings for MCP-created places
- Add list_categories tool for discovering category IDs
- Add search_place tool (Nominatim) to look up osm_id before creating
2026-03-31 10:38:29 +02:00
Moritz Enderle 3aaa6e916b feat: adds better gpx track views 2026-03-31 10:29:49 +02:00
Maurice ad329eddb9 Merge pull request #176 from jubnl/main
Prevent duplicate place assignment when dragging to an empty day
2026-03-31 10:00:37 +02:00
David Moll 990e804bd3 fix(server): encrypt api keys 2026-03-31 09:00:35 +02:00
Salman Abuhaimed 299e26bebe make day plan side bar icons more readable 2026-03-31 06:29:31 +03:00
Salman Abuhaimed 96b6d7d81f fix: note modal hidden behind mobile sidebar due to z-index 2026-03-31 06:29:31 +03:00
Salman Abuhaimed 27d5c3400c fix: update dayAccommodations state after create/edit/delete 2026-03-31 06:27:45 +03:00
Salman Abuhaimed bb9c0c9b68 fix: day details on mobile not showing 2026-03-31 06:27:11 +03:00
Claude 483190e7c1 fix: XSS in GitHubPanel markdown renderer and RouteCalculator profile bug
Escape HTML entities before dangerouslySetInnerHTML in release notes
renderer to prevent stored XSS via malicious GitHub release bodies.
Fix RouteCalculator ignoring the profile parameter (was hardcoded to
'driving').

https://claude.ai/code/session_01SoQKcF5Rz9Y8Nzo4PzkxY8
2026-03-31 00:34:09 +00:00
Claude c89ff8b551 fix: critical Immich SSRF and API key exposure vulnerabilities
- Add URL validation on Immich URL save to prevent SSRF attacks
  (blocks private IPs, metadata endpoints, non-HTTP protocols)
- Remove userId query parameter from asset proxy endpoints to prevent
  any authenticated user from accessing another user's Immich API key
  and photo library
- Add asset ID validation (alphanumeric only) to prevent path traversal
  in proxied Immich API URLs
- Update AUDIT_FINDINGS.md with Immich and admin route findings

https://claude.ai/code/session_01SoQKcF5Rz9Y8Nzo4PzkxY8
2026-03-31 00:34:06 +00:00
Claude 63232e56a3 fix: prevent OIDC token data leaking to logs, update audit findings
- Redact OIDC token exchange error logs to only include HTTP status
- Add additional findings from exhaustive server security scan to
  AUDIT_FINDINGS.md

https://claude.ai/code/session_01SoQKcF5Rz9Y8Nzo4PzkxY8
2026-03-31 00:34:04 +00:00
Claude 643504d89b fix: infrastructure hardening and documentation improvements
- Add *.sqlite* patterns to .gitignore
- Expand .dockerignore to exclude chart/, docs/, .github/, etc.
- Add HEALTHCHECK instruction to Dockerfile
- Fix Helm chart: preserve JWT secret across upgrades (lookup),
  add securityContext, conditional PVC creation, resource defaults
- Remove hardcoded demo credentials from MCP.md
- Complete .env.example with all configurable environment variables

https://claude.ai/code/session_01SoQKcF5Rz9Y8Nzo4PzkxY8
2026-03-31 00:34:01 +00:00
Claude 2288f9d2fc fix: harden PWA caching and client-side auth security
- Exclude sensitive API paths (auth, admin, backup, settings) from SW cache
- Restrict upload caching to public assets only (covers, avatars)
- Remove opaque response caching (status 0) for API and uploads
- Clear service worker caches on logout
- Only logout on 401 errors, not transient network failures
- Fix register() TypeScript interface to include invite_token parameter
- Remove unused RegisterPage and DemoBanner imports
- Disable source maps in production build
- Add SRI hash for Leaflet CSS CDN

https://claude.ai/code/session_01SoQKcF5Rz9Y8Nzo4PzkxY8
2026-03-31 00:33:58 +00:00
Claude 804c2586a9 fix: tighten CSP, fix API key exposure, improve error handling
- Remove 'unsafe-inline' from script-src CSP directive
- Restrict connectSrc and imgSrc to known external domains
- Move Google API key from URL query parameter to X-Goog-Api-Key header
- Sanitize error logging in production (no stack traces)
- Log file link errors instead of silently swallowing them

https://claude.ai/code/session_01SoQKcF5Rz9Y8Nzo4PzkxY8
2026-03-31 00:33:56 +00:00
Claude fedd559fd6 fix: pin JWT algorithm to HS256 and harden token security
- Add { algorithms: ['HS256'] } to all jwt.verify() calls to prevent
  algorithm confusion attacks (including the 'none' algorithm)
- Add { algorithm: 'HS256' } to all jwt.sign() calls for consistency
- Reduce OIDC token payload to only { id } (was leaking username, email, role)
- Validate OIDC redirect URI against APP_URL env var when configured
- Add startup warning when JWT_SECRET is auto-generated

https://claude.ai/code/session_01SoQKcF5Rz9Y8Nzo4PzkxY8
2026-03-31 00:33:53 +00:00
Claude 5f07bdaaf1 docs: add comprehensive security and code quality audit findings
AUDIT_FINDINGS.md documents all findings across security, code quality,
best practices, dependency hygiene, documentation, and testing categories.

https://claude.ai/code/session_01SoQKcF5Rz9Y8Nzo4PzkxY8
2026-03-31 00:33:50 +00:00
jubnl fb643a1ade fix: stop drop event bubbling causing duplicate place assignment 2026-03-31 01:32:20 +02:00
Maurice 069fd99341 Merge branch 'pr-169'
# Conflicts:
#	client/src/i18n/translations/ar.ts
#	client/src/i18n/translations/br.ts
#	client/src/i18n/translations/cs.ts
#	client/src/i18n/translations/de.ts
#	client/src/i18n/translations/en.ts
#	client/src/i18n/translations/es.ts
#	client/src/i18n/translations/fr.ts
#	client/src/i18n/translations/hu.ts
#	client/src/i18n/translations/it.ts
#	client/src/i18n/translations/nl.ts
#	client/src/i18n/translations/ru.ts
#	client/src/i18n/translations/zh.ts
#	client/src/pages/SettingsPage.tsx
2026-03-30 23:46:32 +02:00
Maurice 3dc760484a Merge pull request #166 from fgbona/feat/#155
feat(require-mfa): #155 enforce MFA via admin policy toggle across app access
2026-03-30 23:42:45 +02:00
Fernando Bona 13580ea5fb Merge branch 'main' into feat/#155 2026-03-30 18:36:18 -03:00
Fernando Bona aa5dd1abc6 Merge branch 'main' into fix/mfa-backup-codes 2026-03-30 18:27:46 -03:00
fgbona de444bf770 fix(mfa-backup-codes): persist backup codes panel after enable and refresh
Keep MFA backup codes visible after enabling MFA by avoiding protected-route unmount during user reload (`loadUser({ silent: true })`) and restoring pending backup codes from sessionStorage until the user explicitly dismisses them.
2026-03-30 18:22:45 -03:00
Maurice 821f71ac28 fix: add MCP translation keys for cs, hu, it languages 2026-03-30 23:14:05 +02:00
Maurice faebc62917 Merge branch 'pr-125'
# Conflicts:
#	client/src/api/client.ts
#	client/src/i18n/translations/ar.ts
#	client/src/i18n/translations/es.ts
#	client/src/i18n/translations/fr.ts
#	client/src/i18n/translations/nl.ts
#	client/src/i18n/translations/ru.ts
#	client/src/i18n/translations/zh.ts
#	client/src/pages/AdminPage.tsx
#	client/src/pages/SettingsPage.tsx
#	server/package.json
#	server/src/db/migrations.ts
#	server/src/index.ts
#	server/src/routes/admin.ts
2026-03-30 23:10:34 +02:00
Fernando Bona 41e572445c Merge branch 'main' into feat/#155 2026-03-30 17:52:07 -03:00
fgbona 66f5ea50c5 feat(require-mfa): #155 enforce MFA via admin policy toggle across app access
Add an admin-controlled `require_mfa` policy in App Settings and expose it via `/auth/app-config` so the client can enforce it globally. Users without MFA are redirected to Settings after login and blocked from protected API/WebSocket access until setup is completed, while preserving MFA setup endpoints and admin recovery paths. Also prevent enabling the policy unless the acting admin already has MFA enabled, and block MFA disable while the policy is active. Includes UI toggle in Admin > Settings, required-policy notice in Settings, client-side 403 `MFA_REQUIRED` handling, and i18n updates for all supported locales.
2026-03-30 17:42:40 -03:00
Maurice ce4b8088ec fix: force light mode on shared trip page 2026-03-30 22:32:58 +02:00
Maurice b1138eb9db fix: shared page language redirect + skip TLS for self-signed certs — closes #163 #164
- Language change on public shared page no longer triggers API call / login redirect
- New "Skip TLS certificate check" toggle in Admin > SMTP settings
- Also configurable via SMTP_SKIP_TLS_VERIFY=true env var
2026-03-30 22:26:09 +02:00
Maurice 8412f303dd fix: Dockerfile volume permissions — fix SQLITE_READONLY on upgrade 2026-03-30 21:38:28 +02:00
ASTERISK Kwon ba87a7f876 fix: correct linksMap type annotation 2026-03-30 20:32:49 +09:00
ASTERISK Kwon 9f1b0554d6 fix: decode multer filename encoding for non-ASCII filenames 2026-03-30 20:31:04 +09:00
jubnl 3dd15499e6 Add documentation 2026-03-30 05:37:30 +02:00
jubnl 393e99201a Add documentation 2026-03-30 05:35:14 +02:00
jubnl 153b7f64b7 some fixes 2026-03-30 06:59:24 +02:00
jubnl 7b2d45665c Merge remote-tracking branch 'origin/main'
# Conflicts:
#	server/src/db/migrations.ts
2026-03-30 03:56:05 +02:00
jubnl 37873dd938 feat: mcp server 2026-03-30 03:53:45 +02:00
321 changed files with 60016 additions and 11820 deletions
+23 -1
View File
@@ -5,6 +5,28 @@ client/dist
data
uploads
.git
.env
.github
**/.env
**/.env.*
*.log
*.md
!client/**/*.md
chart/
docs/
docker-compose.yml
unraid-template.xml
*.sqlite
*.sqlite-shm
*.sqlite-wal
*.db
*.db-shm
*.db-wal
**/coverage
.DS_Store
Thumbs.db
.vscode
.idea
sonar-project.properties
server/tests/
server/vitest.config.ts
server/reset-admin.js
+29
View File
@@ -0,0 +1,29 @@
# Normalize line endings to LF on commit
* text=auto eol=lf
# Explicitly enforce LF for source files
*.ts text eol=lf
*.tsx text eol=lf
*.js text eol=lf
*.jsx text eol=lf
*.json text eol=lf
*.css text eol=lf
*.html text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.py text eol=lf
*.sh text eol=lf
# Binary files — no line ending conversion
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
*.pdf binary
*.zip binary
-38
View File
@@ -1,38 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
+108
View File
@@ -0,0 +1,108 @@
name: Bug Report
description: Create a report to help us improve TREK
title: "[BUG] "
labels: []
body:
- type: checkboxes
id: preflight
attributes:
label: Pre-flight checklist
options:
- label: I have searched [existing issues](https://github.com/mauriceboe/TREK/issues) and this bug has not been reported yet
required: true
- label: I am running the latest available version of TREK
required: true
- type: input
id: version
attributes:
label: TREK version
description: Found in the Settings → About, or in the Docker image tag
placeholder: "e.g. 2.8.0"
validations:
required: true
- type: textarea
id: description
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is.
placeholder: When I do X, Y happens instead of Z…
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Step-by-step instructions to reliably trigger the bug.
placeholder: |
1. Go to '...'
2. Click on '...'
3. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What did you expect to happen?
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: Deployment method
options:
- Docker Compose
- Docker (standalone)
- Kubernetes / Helm
- Unraid template
- Sources
- Other
validations:
required: true
- type: input
id: os
attributes:
label: Host OS
placeholder: "e.g. Ubuntu 24.04, Unraid 6.12, Synology DSM 7"
- type: dropdown
id: user_os
attributes:
label: Accessing TREK from
options:
- Desktop browser
- Mobile browser
- Mobile app (PWA)
validations:
required: true
- type: input
id: browser
attributes:
label: Browser (if applicable)
placeholder: "e.g. Chrome 124, Firefox 125, Safari 17"
- type: textarea
id: logs
attributes:
label: Relevant logs or error output
description: Paste any relevant server or browser console output here.
render: shell
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: Drag and drop screenshots here if applicable.
- type: textarea
id: context
attributes:
label: Additional context
description: Anything else that might help us understand the issue.
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://github.com/mauriceboe/TREK/wiki
about: Check the docs before opening an issue
- name: Feature Request
url: https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests
about: Suggest a new feature or improvement in Discussions
- name: Questions & Help
url: https://github.com/mauriceboe/TREK/discussions
about: For questions and general help, use Discussions instead
-20
View File
@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE]"
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+21
View File
@@ -0,0 +1,21 @@
## Description
<!-- What does this PR do? Why? -->
## Related Issue or Discussion
<!-- This project requires an issue or an approved feature request before submitting a PR. -->
<!-- For bug fixes: Closes #ISSUE_NUMBER -->
<!-- For features: Addresses discussion #DISCUSSION_NUMBER -->
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Checklist
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
- [ ] This PR targets the `dev` branch, not `main`
- [ ] I have tested my changes locally
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
- [ ] I have updated documentation if needed
@@ -0,0 +1,71 @@
name: Close issues with unchanged bad titles
on:
schedule:
- cron: '0 */6 * * *' # Every 6 hours
permissions:
issues: write
jobs:
close-stale:
runs-on: ubuntu-latest
steps:
- name: Close stale invalid-title issues
uses: actions/github-script@v7
with:
script: |
const badTitles = [
"[bug]", "bug report", "bug", "issue",
"help", "question", "test", "...", "untitled"
];
const { data: issues } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
labels: 'invalid-title',
state: 'open',
per_page: 100,
});
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const issue of issues) {
const createdAt = new Date(issue.created_at);
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
const titleLower = issue.title.trim().toLowerCase();
if (!badTitles.includes(titleLower)) {
// Title was fixed — remove the label and move on
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: 'invalid-title',
});
continue;
}
// Still a bad title after 24h — close it
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: [
'## Issue closed',
'',
'This issue has been automatically closed because the title was not updated within 24 hours.',
'',
'Feel free to open a new issue with a descriptive title that summarizes the problem.',
].join('\n'),
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned',
});
}
@@ -0,0 +1,66 @@
name: Close PRs with unchanged wrong base branch
on:
schedule:
- cron: '0 */6 * * *' # Every 6 hours
permissions:
pull-requests: write
issues: write
jobs:
close-stale:
runs-on: ubuntu-latest
steps:
- name: Close stale wrong-base-branch PRs
uses: actions/github-script@v7
with:
script: |
const { data: pulls } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const pull of pulls) {
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
if (!hasLabel) continue;
const createdAt = new Date(pull.created_at);
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
// Base was fixed — remove label and move on
if (pull.base.ref !== 'main') {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pull.number,
name: 'wrong-base-branch',
});
continue;
}
// Still targeting main after 24h — close it
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pull.number,
body: [
'## PR closed',
'',
'This PR has been automatically closed because the base branch was not updated to `dev` within 24 hours.',
'',
'Feel free to open a new PR targeting `dev`.',
].join('\n'),
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pull.number,
state: 'closed',
});
}
@@ -0,0 +1,92 @@
name: Flag issues with bad titles
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
check-title:
runs-on: ubuntu-latest
steps:
- name: Flag or redirect issue
uses: actions/github-script@v7
with:
script: |
const title = context.payload.issue.title.trim();
const titleLower = title.toLowerCase();
const badTitles = [
"[bug]", "bug report", "bug", "issue",
"help", "question", "test", "...", "untitled"
];
const featureRequestTitles = [
"feature request", "[feature]", "[feature request]", "[enhancement]"
];
if (badTitles.includes(titleLower)) {
// Ensure the label exists
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'invalid-title',
});
} catch {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'invalid-title',
color: 'e4e669',
description: 'Issue title does not meet quality standards',
});
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
labels: ['invalid-title'],
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: [
'## Invalid title',
'',
`Your issue title \`${title}\` is too generic to be actionable.`,
'',
'Please edit the title to something descriptive that summarizes the problem — for example:',
'> _Map view crashes when zooming in on Safari 17_',
'',
'**This issue will be automatically closed in 24 hours if the title has not been updated.**',
].join('\n'),
});
} else if (featureRequestTitles.some(t => titleLower.startsWith(t))) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: [
'## Wrong place for feature requests',
'',
'Feature requests should be submitted in [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests), not as issues.',
'',
'This issue has been closed. Feel free to re-submit your idea in the right place!',
].join('\n'),
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
state: 'closed',
state_reason: 'not_planned',
});
}
+86 -7
View File
@@ -3,11 +3,74 @@ name: Build & Push Docker Image
on:
push:
branches: [main]
paths-ignore:
- 'docs/**'
- '**/*.md'
workflow_dispatch:
permissions:
contents: write
jobs:
version-bump:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.VERSION }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Determine bump type and update version
id: bump
run: |
# Check if this push is a merge commit from dev branch
COMMIT_MSG=$(git log -1 --pretty=%s)
PARENT_COUNT=$(git log -1 --pretty=%p | wc -w)
if echo "$COMMIT_MSG" | grep -qiE "^Merge (pull request|branch).*dev"; then
BUMP="minor"
elif [ "$PARENT_COUNT" -gt 1 ] && git log -1 --pretty=%P | xargs -n1 git branch -r --contains 2>/dev/null | grep -q "origin/dev"; then
BUMP="minor"
else
BUMP="patch"
fi
echo "Bump type: $BUMP"
# Read current version
CURRENT=$(node -p "require('./server/package.json').version")
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
if [ "$BUMP" = "minor" ]; then
MINOR=$((MINOR + 1))
PATCH=0
else
PATCH=$((PATCH + 1))
fi
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$CURRENT → $NEW_VERSION ($BUMP)"
# Update package.json files and Helm chart
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
# Commit and tag
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add server/package.json server/package-lock.json client/package.json client/package-lock.json charts/trek/Chart.yaml
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag "v$NEW_VERSION"
git push origin main --follow-tags
build:
runs-on: ${{ matrix.runner }}
needs: version-bump
strategy:
fail-fast: false
matrix:
@@ -21,6 +84,8 @@ jobs:
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
- uses: actions/checkout@v4
with:
ref: main
- uses: docker/setup-buildx-action@v3
@@ -54,13 +119,11 @@ jobs:
merge:
runs-on: ubuntu-latest
needs: build
needs: [version-bump, build]
steps:
- uses: actions/checkout@v4
- name: Get version from package.json
id: version
run: echo "VERSION=$(node -p "require('./server/package.json').version")" >> $GITHUB_OUTPUT
with:
ref: main
- name: Download build digests
uses: actions/download-artifact@v4
@@ -79,13 +142,29 @@ jobs:
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
VERSION=${{ needs.version-bump.outputs.version }}
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
docker buildx imagetools create \
-t mauriceboe/trek:latest \
-t mauriceboe/trek:${{ steps.version.outputs.VERSION }} \
-t mauriceboe/trek:$VERSION \
-t mauriceboe/nomad:latest \
-t mauriceboe/nomad:${{ steps.version.outputs.VERSION }} \
-t mauriceboe/nomad:$VERSION \
"${digests[@]}"
- name: Inspect manifest
run: docker buildx imagetools inspect mauriceboe/trek:latest
release-helm:
runs-on: ubuntu-latest
needs: version-bump
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Publish Helm chart
uses: stefanprodan/helm-gh-pages@v1.7.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
charts_dir: charts
+100
View File
@@ -0,0 +1,100 @@
name: Enforce PR Target Branch
on:
pull_request:
types: [opened, reopened, edited, synchronize]
jobs:
check-target:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Flag or clear wrong base branch
uses: actions/github-script@v7
with:
script: |
const base = context.payload.pull_request.base.ref;
const labels = context.payload.pull_request.labels.map(l => l.name);
const prNumber = context.payload.pull_request.number;
// If the base was fixed, remove the label and let it through
if (base !== 'main') {
if (labels.includes('wrong-base-branch')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: 'wrong-base-branch',
});
}
return;
}
// Base is main — check if this user is a maintainer
let permission = 'none';
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.pull_request.user.login,
});
permission = data.permission;
} catch (_) {
// User is not a collaborator — treat as 'none'
}
if (['admin', 'write'].includes(permission)) {
console.log(`User has '${permission}' permission, skipping.`);
return;
}
// Already labeled — avoid spamming on every push
if (labels.includes('wrong-base-branch')) {
core.setFailed("PR must target `dev`, not `main`.");
return;
}
// Ensure the label exists
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'wrong-base-branch',
});
} catch {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'wrong-base-branch',
color: 'd73a4a',
description: 'PR is targeting the wrong base branch',
});
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['wrong-base-branch'],
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: [
'## Wrong target branch',
'',
'This PR targets `main`, but contributions must go through `dev` first.',
'',
'To fix this, click **Edit** next to the PR title and change the base branch to `dev`.',
'',
'**This PR will be automatically closed in 24 hours if the base branch has not been updated.**',
'',
'> _If you need to merge directly to `main`, contact a maintainer._',
].join('\n'),
});
core.setFailed("PR must target `dev`, not `main`.");
+39
View File
@@ -0,0 +1,39 @@
name: Tests
permissions:
contents: read
on:
pull_request:
branches: [main, dev]
paths:
- 'server/**'
- '.github/workflows/test.yml'
jobs:
server-tests:
name: Server Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: server/package-lock.json
- name: Install dependencies
run: cd server && npm ci
- name: Run tests
run: cd server && npm run test:coverage
- name: Upload coverage
if: success()
uses: actions/upload-artifact@v6
with:
name: coverage
path: server/coverage/
retention-days: 7
+7
View File
@@ -11,6 +11,9 @@ client/public/icons/*.png
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite-shm
*.sqlite-wal
# User data
server/data/
@@ -28,6 +31,7 @@ Thumbs.db
# IDE
.vscode/
.idea/
.claude/
# Logs
logs
@@ -52,3 +56,6 @@ coverage
.cache
*.tsbuildinfo
*.tgz
.scannerwork
test-data
+46
View File
@@ -0,0 +1,46 @@
# Contributing to TREK
Thanks for your interest in contributing! Please read these guidelines before opening a pull request.
## Ground Rules
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/P7TUxHJs). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
3. **No breaking changes** — Backwards compatibility is non-negotiable
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
## Pull Requests
### Your PR should include:
- **Summary** — What does this change and why? (1-3 bullet points)
- **Test plan** — How did you verify it works?
- **Linked issue** — Reference the issue (e.g. `Fixes #123`)
### Your PR will be closed if it:
- Wasn't discussed and approved in `#github-pr` on Discord first
- Introduces breaking changes
- Adds unnecessary complexity or features beyond scope
- Reformats or refactors unrelated code
- Adds dependencies without clear justification
### Commit messages
Use [conventional commits](https://www.conventionalcommits.org/):
```
fix(maps): correct zoom level on Safari
feat(budget): add CSV export for expenses
```
## Development Environment
See the [Developer Environment page](https://github.com/mauriceboe/TREK/wiki/Development-environment) for more information on setting up your development environment.
## More Details
See the [Contributing wiki page](https://github.com/mauriceboe/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
+12 -17
View File
@@ -1,4 +1,4 @@
# Stage 1: React Client bauen
# Stage 1: Build React client
FROM node:22-alpine AS client-builder
WORKDIR /app/client
COPY client/package*.json ./
@@ -6,37 +6,32 @@ RUN npm ci
COPY client/ ./
RUN npm run build
# Stage 2: Produktions-Server
# Stage 2: Production server
FROM node:22-alpine
WORKDIR /app
# Timezone support + Server-Dependencies (better-sqlite3 braucht Build-Tools)
# Timezone support + native deps (better-sqlite3 needs build tools)
COPY server/package*.json ./
RUN apk add --no-cache tzdata python3 make g++ && \
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
npm ci --production && \
apk del python3 make g++
# Server-Code kopieren
COPY server/ ./
# Gebauten Client kopieren
COPY --from=client-builder /app/client/dist ./public
# Fonts für PDF-Export kopieren
COPY --from=client-builder /app/client/public/fonts ./public/fonts
# Verzeichnisse erstellen + Symlink für Abwärtskompatibilität (alte docker-compose mounten nach /app/server/uploads)
RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data
RUN mkdir -p /app/data/logs /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 && \
chown -R node:node /app
RUN chown -R node:node /app
USER node
# Umgebung setzen
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", "--import", "tsx", "src/index.ts"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
ENTRYPOINT ["dumb-init", "--"]
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"]
+234
View File
@@ -0,0 +1,234 @@
# MCP Integration
TREK includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets AI
assistants — such as Claude Desktop, Cursor, or any MCP-compatible client — read and modify your trip data through a
structured API.
> **Note:** MCP is an addon that must be enabled by your TREK administrator before it becomes available.
## Table of Contents
- [Setup](#setup)
- [Limitations & Important Notes](#limitations--important-notes)
- [Resources (read-only)](#resources-read-only)
- [Tools (read-write)](#tools-read-write)
- [Example](#example)
---
## Setup
### 1. Enable the MCP addon (admin)
An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp`
endpoint returns `403 Forbidden` and the MCP section does not appear in user settings.
### 2. Create an API token
Once MCP is enabled, go to **Settings > MCP Configuration** and create an API token:
1. Click **Create New Token**
2. Give it a descriptive name (e.g. "Claude Desktop", "Work laptop")
3. **Copy the token immediately** — it is shown only once and cannot be recovered
Each user can create up to **10 tokens**.
### 3. Configure your MCP client
The Settings page shows a ready-to-copy client configuration snippet. For **Claude Desktop**, add the following to your
`claude_desktop_config.json`:
```json
{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"https://your-trek-instance.com/mcp",
"--header",
"Authorization: Bearer trek_your_token_here"
]
}
}
}
```
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
---
## Limitations & Important Notes
| Limitation | Details |
|-----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|
| **Admin activation required** | The MCP addon must be enabled by an admin before any user can access it. |
| **Per-user scoping** | Each MCP session is scoped to the authenticated user. You can only access trips you own or are a member of. |
| **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. |
| **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. |
| **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. |
| **Rate limiting** | 60 requests per minute per user. Exceeding this returns a `429` error. |
| **Session limits** | Maximum 5 concurrent MCP sessions per user. Sessions expire after 1 hour of inactivity. |
| **Token limits** | Maximum 10 API tokens per user. |
| **Token revocation** | Deleting a token immediately terminates all active MCP sessions for that user. |
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
---
## Resources (read-only)
Resources provide read-only access to your TREK data. MCP clients can read these to understand the current state before
making changes.
| Resource | URI | Description |
|-------------------|--------------------------------------------|-----------------------------------------------------------|
| Trips | `trek://trips` | All trips you own or are a member of |
| Trip Detail | `trek://trips/{tripId}` | Single trip with metadata and member count |
| Days | `trek://trips/{tripId}/days` | Days of a trip with their assigned places |
| Places | `trek://trips/{tripId}/places` | All places/POIs saved in a trip |
| Budget | `trek://trips/{tripId}/budget` | Budget and expense items |
| Packing | `trek://trips/{tripId}/packing` | Packing checklist |
| Reservations | `trek://trips/{tripId}/reservations` | Flights, hotels, restaurants, etc. |
| Day Notes | `trek://trips/{tripId}/days/{dayId}/notes` | Notes for a specific day |
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
| Categories | `trek://categories` | Available place categories (for use when creating places) |
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
| Visited Countries | `trek://visited-countries` | Countries marked as visited in Atlas |
---
## Tools (read-write)
TREK exposes **34 tools** organized by feature area. Use `get_trip_summary` as a starting point — it returns everything
about a trip in a single call.
### Trip Summary
| Tool | Description |
|--------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget totals, packing stats, reservations, and collab notes. Use this as your context loader. |
### Trips
| Tool | Description |
|---------------|---------------------------------------------------------------------------------------------|
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
| `create_trip` | Create a new trip with title, dates, currency. Days are auto-generated from the date range. |
| `update_trip` | Update a trip's title, description, dates, or currency. |
| `delete_trip` | Delete a trip. **Owner only.** |
### Places
| Tool | Description |
|----------------|-----------------------------------------------------------------------------------|
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone. |
| `update_place` | Update any field of an existing place. |
| `delete_place` | Remove a place from a trip. |
### Day Planning
| Tool | Description |
|---------------------------|-------------------------------------------------------------------------------|
| `assign_place_to_day` | Pin a place to a specific day in the itinerary. |
| `unassign_place` | Remove a place assignment from a day. |
| `reorder_day_assignments` | Reorder places within a day by providing assignment IDs in the desired order. |
| `update_assignment_time` | Set start/end times for a place assignment (e.g. "09:00" "11:30"). |
| `update_day` | Set or clear a day's title (e.g. "Arrival in Paris", "Free day"). |
### Reservations
| Tool | Description |
|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `create_reservation` | Create a pending reservation. Supports flights, hotels, restaurants, trains, cars, cruises, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
### Budget
| Tool | Description |
|----------------------|--------------------------------------------------------------|
| `create_budget_item` | Add an expense with name, category, and price. |
| `update_budget_item` | Update an expense's details, split (persons/days), or notes. |
| `delete_budget_item` | Remove a budget item. |
### Packing
| Tool | Description |
|-----------------------|--------------------------------------------------------------|
| `create_packing_item` | Add an item to the packing checklist with optional category. |
| `update_packing_item` | Rename an item or change its category. |
| `toggle_packing_item` | Check or uncheck a packing item. |
| `delete_packing_item` | Remove a packing item. |
### Day Notes
| Tool | Description |
|-------------------|-----------------------------------------------------------------------|
| `create_day_note` | Add a note to a specific day with optional time label and emoji icon. |
| `update_day_note` | Edit a day note's text, time, or icon. |
| `delete_day_note` | Remove a note from a day. |
### Collab Notes
| Tool | Description |
|----------------------|-------------------------------------------------------------------------------------------------|
| `create_collab_note` | Create a shared note visible to all trip members. Supports title, content, category, and color. |
| `update_collab_note` | Edit a collab note's content, category, color, or pin status. |
| `delete_collab_note` | Delete a collab note and its associated files. |
### Bucket List
| Tool | Description |
|---------------------------|--------------------------------------------------------------------------------------------|
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. |
| `delete_bucket_list_item` | Remove an item from your bucket list. |
### Atlas
| Tool | Description |
|--------------------------|--------------------------------------------------------------------------------|
| `mark_country_visited` | Mark a country as visited using its ISO 3166-1 alpha-2 code (e.g. "FR", "JP"). |
| `unmark_country_visited` | Remove a country from your visited list. |
---
## Example
Conversation with Claude: https://claude.ai/share/51572203-6a4d-40f8-a6bd-eba09d4b009d
Initial prompt (1st message):
```
I'd like to plan a week-long trip to Kyoto, Japan, arriving April 5 2027
and leaving April 11 2027. It's cherry blossom season so please keep that
in mind when picking spots.
Before writing anything to TREK, do some research: look up what's worth
visiting, figure out a logical day-by-day flow (group nearby spots together
to avoid unnecessary travel), find a well-reviewed hotel in a central
neighbourhood, and think about what kind of food and restaurant experiences
are worth including.
Once you have a solid plan, write the whole thing to TREK:
- Create the trip
- Add all the places you've researched with their real coordinates
- Build out the daily itinerary with sensible visiting times
- Book the hotel as a reservation and link it properly to the accommodation days
- Add any notable restaurant reservations
- Put together a realistic budget in EUR
- Build a packing list suited to April in Kyoto
- Leave a pinned collab note with practical tips (transport, etiquette, money, etc.)
- Add a day note for each day with any important heads-up (early start, crowd
tips, booking requirements, etc.)
- Mark Japan as visited in my Atlas
Currency: CHF. Use get_trip_summary at the end and give me a quick recap
of everything that was added.
```
PDF of the generated trip: [./docs/TREK-Generated-by-MCP.pdf](./docs/TREK-Generated-by-MCP.pdf)
![trip](./docs/screenshot-trip-mcp.png)
+85 -14
View File
@@ -9,6 +9,7 @@
</p>
<p align="center">
<a href="https://discord.gg/NhZBDSd4qW"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
@@ -98,7 +99,9 @@
## Quick Start
```bash
docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
```
The app runs on port `3000`. The first user to register becomes the admin.
@@ -120,22 +123,59 @@ services:
app:
image: mauriceboe/trek:latest
container_name: trek
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
tmpfs:
- /tmp:noexec,nosuid,size=64m
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
# - OIDC_ISSUER=https://auth.example.com
# - OIDC_CLIENT_ID=trek
# - OIDC_CLIENT_SECRET=supersecret
# - OIDC_DISPLAY_NAME="SSO"
# - OIDC_ONLY=true # disable password auth entirely
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
- FORCE_HTTPS=true # Redirect HTTP to HTTPS when behind a TLS-terminating proxy
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production.
- TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
- APP_URL=${APP_URL:-} # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP; Also used as the base URL for email notifications and other external links
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
# - OIDC_ONLY=false # Set to true to disable local password auth entirely (SSO only)
# - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
# - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role
# - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM)
# - OIDC_DISCOVERY_URL= # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik)
# - DEMO_MODE=false # Enable demo mode (resets data hourly)
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
# - MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
```
This example is aimed at reverse-proxy deployments. If you access TREK directly on `http://<host>:3000` without nginx, Caddy, Traefik, or another TLS-terminating proxy in front of it, set `FORCE_HTTPS=false` and remove `TRUST_PROXY` to avoid redirects to a non-existent HTTPS endpoint.
```bash
docker compose up -d
```
@@ -162,6 +202,18 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
### Rotating the Encryption Key
If you need to rotate `ENCRYPTION_KEY` (e.g. you are upgrading from a version that derived encryption from `JWT_SECRET`), use the migration script to re-encrypt all stored secrets under the new key without starting the app:
```bash
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
```
The script will prompt for your old and new keys interactively (input is not echoed). It creates a timestamped database backup before making any changes and exits with a non-zero code if anything fails.
**Upgrading from a previous version?** Your old JWT secret is in `./data/.jwt_secret`. Use its contents as the "old key" and your new `ENCRYPTION_KEY` value as the "new key".
### Reverse Proxy (recommended)
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
@@ -226,17 +278,35 @@ trek.yourdomain.com {
| Variable | Description | Default |
|----------|-------------|---------|
| **Core** | | |
| `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 | — |
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Redirect HTTP to HTTPS behind a TLS-terminating proxy. If you access TREK directly on `http://host:3000`, keep this `false`. | `false` |
| `COOKIE_SECURE` | Set to `false` to allow session cookies over plain HTTP (e.g. accessing via IP without HTTPS). Defaults to `true` in production. **Not recommended to disable in production.** | `true` |
| `TRUST_PROXY` | Number of trusted reverse proxies for `X-Forwarded-For`. Use this only when TREK is actually behind a reverse proxy. | `1` |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — |
| **OIDC / SSO** | | |
| `OIDC_ISSUER` | OpenID Connect 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` |
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
| `OIDC_ONLY` | Disable local password auth entirely (first SSO login becomes admin) | `false` |
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | |
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
| `OIDC_SCOPE` | Space-separated OIDC scopes to request. **Fully replaces** the default — always include `openid email profile` plus any extra scopes you need (e.g. add `groups` when using `OIDC_ADMIN_CLAIM`) | `openid email profile` |
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint. Useful for providers that expose it at a non-standard path (e.g. Authentik: `https://auth.example.com/application/o/trek/.well-known/openid-configuration`) | — |
| **Initial Setup** | | |
| `ADMIN_EMAIL` | Email for the first admin account created on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is generated and printed to the server log. Has no effect once any user exists. | `admin@trek.local` |
| `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random |
| **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `5` |
## Optional API Keys
@@ -261,6 +331,7 @@ docker build -t trek .
- **Database**: SQLite, stored in `./data/travel.db`
- **Uploads**: Stored in `./uploads/`
- **Logs**: `./data/logs/trek.log` (auto-rotated)
- **Backups**: Create and restore via Admin Panel
- **Auto-Backups**: Configurable schedule and retention in Admin Panel
-33
View File
@@ -1,33 +0,0 @@
# 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
@@ -1,13 +0,0 @@
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.
-12
View File
@@ -1,12 +0,0 @@
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 }}
-23
View File
@@ -1,23 +0,0 @@
{{- 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 }}
-53
View File
@@ -1,53 +0,0 @@
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
+36
View File
@@ -0,0 +1,36 @@
# 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 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` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- 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.
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
- Set `env.COOKIE_SECURE: "false"` only if your deployment has no TLS (e.g. during local testing without ingress). Session cookies require HTTPS in all other cases.
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 0.1.0
version: 2.9.12
description: Minimal Helm chart for TREK app
appVersion: "latest"
appVersion: "2.9.12"
+23
View File
@@ -0,0 +1,23 @@
1. ENCRYPTION_KEY handling:
- ENCRYPTION_KEY encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest.
- By default, the chart creates a Kubernetes Secret from `secretEnv.ENCRYPTION_KEY` in values.yaml.
- To generate a random key at install (preserved across upgrades), set `generateEncryptionKey: true`.
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must
contain a key matching `existingSecretKey` (defaults to `ENCRYPTION_KEY`).
- If left empty, the server resolves the key automatically: existing installs fall back to
data/.jwt_secret (encrypted data stays readable with no manual action); fresh installs
auto-generate a key persisted to the data PVC.
2. JWT_SECRET is managed entirely by the server:
- Auto-generated on first start and persisted to the data PVC (data/.jwt_secret).
- Rotate it via the admin panel (Settings → Danger Zone → Rotate JWT Secret).
- No Helm configuration needed or supported.
3. Example usage:
- Set an explicit encryption key: `--set secretEnv.ENCRYPTION_KEY=your_enc_key`
- Generate a random key at install: `--set generateEncryptionKey=true`
- Use an existing secret: `--set existingSecret=my-k8s-secret`
- Use a custom key name in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_ENC_KEY`
4. Only one method should be used at a time. If both `generateEncryptionKey` and `existingSecret` are
set, `existingSecret` takes precedence. Ensure the referenced secret and key exist in the namespace.
+63
View File
@@ -0,0 +1,63 @@
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.TZ }}
TZ: {{ .Values.env.TZ | quote }}
{{- end }}
{{- if .Values.env.LOG_LEVEL }}
LOG_LEVEL: {{ .Values.env.LOG_LEVEL | quote }}
{{- end }}
{{- if .Values.env.ALLOWED_ORIGINS }}
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
{{- end }}
{{- if .Values.env.APP_URL }}
APP_URL: {{ .Values.env.APP_URL | quote }}
{{- end }}
{{- if .Values.env.FORCE_HTTPS }}
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
{{- end }}
{{- if .Values.env.COOKIE_SECURE }}
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
{{- end }}
{{- if .Values.env.TRUST_PROXY }}
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
{{- end }}
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
{{- end }}
{{- if .Values.env.OIDC_ISSUER }}
OIDC_ISSUER: {{ .Values.env.OIDC_ISSUER | quote }}
{{- end }}
{{- if .Values.env.OIDC_CLIENT_ID }}
OIDC_CLIENT_ID: {{ .Values.env.OIDC_CLIENT_ID | quote }}
{{- end }}
{{- if .Values.env.OIDC_DISPLAY_NAME }}
OIDC_DISPLAY_NAME: {{ .Values.env.OIDC_DISPLAY_NAME | quote }}
{{- end }}
{{- if .Values.env.OIDC_ONLY }}
OIDC_ONLY: {{ .Values.env.OIDC_ONLY | quote }}
{{- end }}
{{- if .Values.env.OIDC_ADMIN_CLAIM }}
OIDC_ADMIN_CLAIM: {{ .Values.env.OIDC_ADMIN_CLAIM | quote }}
{{- end }}
{{- if .Values.env.OIDC_ADMIN_VALUE }}
OIDC_ADMIN_VALUE: {{ .Values.env.OIDC_ADMIN_VALUE | quote }}
{{- end }}
{{- if .Values.env.OIDC_SCOPE }}
OIDC_SCOPE: {{ .Values.env.OIDC_SCOPE | quote }}
{{- end }}
{{- if .Values.env.OIDC_DISCOVERY_URL }}
OIDC_DISCOVERY_URL: {{ .Values.env.OIDC_DISCOVERY_URL | quote }}
{{- end }}
{{- if .Values.env.DEMO_MODE }}
DEMO_MODE: {{ .Values.env.DEMO_MODE | quote }}
{{- end }}
{{- if .Values.env.MCP_RATE_LIMIT }}
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
{{- end }}
@@ -11,6 +11,9 @@ spec:
app: {{ include "trek.name" . }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
labels:
app: {{ include "trek.name" . }}
spec:
@@ -20,21 +23,46 @@ spec:
- name: {{ .name }}
{{- end }}
{{- end }}
securityContext:
fsGroup: 1000
containers:
- name: trek
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- containerPort: 3000
envFrom:
- configMapRef:
name: {{ include "trek.fullname" . }}-config
env:
- name: JWT_SECRET
- name: ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: {{ .Values.existingSecretKey | default "JWT_SECRET" }}
key: {{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}
optional: true
- name: ADMIN_EMAIL
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: ADMIN_EMAIL
optional: true
- name: ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: ADMIN_PASSWORD
optional: true
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: OIDC_CLIENT_SECRET
optional: true
volumeMounts:
- name: data
mountPath: /app/data
@@ -10,6 +10,9 @@ metadata:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- toYaml .Values.ingress.tls | nindent 4 }}
@@ -1,3 +1,4 @@
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
@@ -23,3 +24,4 @@ spec:
resources:
requests:
storage: {{ .Values.persistence.uploads.size }}
{{- end }}
+47
View File
@@ -0,0 +1,47 @@
{{- if and (not .Values.existingSecret) (not .Values.generateEncryptionKey) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "trek.fullname" . }}-secret
labels:
app: {{ include "trek.name" . }}
type: Opaque
data:
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ .Values.secretEnv.ENCRYPTION_KEY | b64enc | quote }}
{{- if .Values.secretEnv.ADMIN_EMAIL }}
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.ADMIN_PASSWORD }}
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }}
{{- end }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
{{- $secretName := printf "%s-secret" (include "trek.fullname" .) }}
{{- $existingSecret := (lookup "v1" "Secret" .Release.Namespace $secretName) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ $secretName }}
labels:
app: {{ include "trek.name" . }}
type: Opaque
stringData:
{{- if and $existingSecret $existingSecret.data }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ index $existingSecret.data (.Values.existingSecretKey | default "ENCRYPTION_KEY") | b64dec }}
{{- else }}
{{ .Values.existingSecretKey | default "ENCRYPTION_KEY" }}: {{ randAlphaNum 32 }}
{{- end }}
{{- if .Values.secretEnv.ADMIN_EMAIL }}
ADMIN_EMAIL: {{ .Values.secretEnv.ADMIN_EMAIL }}
{{- end }}
{{- if .Values.secretEnv.ADMIN_PASSWORD }}
ADMIN_PASSWORD: {{ .Values.secretEnv.ADMIN_PASSWORD }}
{{- end }}
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
{{- end }}
{{- end }}
+111
View File
@@ -0,0 +1,111 @@
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
# TZ: "UTC"
# Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin).
# LOG_LEVEL: "info"
# "info" = concise user actions, "debug" = verbose details.
# ALLOWED_ORIGINS: ""
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
# APP_URL: "https://trek.example.com"
# Public base URL of this instance. Required when OIDC is enabled — must match the redirect URI registered with your IdP.
# Also used as the base URL for links in email notifications and other external links.
# FORCE_HTTPS: "false"
# Set to "true" to redirect HTTP to HTTPS behind a TLS-terminating proxy.
# COOKIE_SECURE: "true"
# Set to "false" to allow session cookies over plain HTTP (e.g. no ingress TLS). Not recommended for production.
# TRUST_PROXY: "1"
# Number of trusted reverse proxies for X-Forwarded-For header parsing.
# ALLOW_INTERNAL_NETWORK: "false"
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
# OIDC_ISSUER: ""
# OpenID Connect provider URL.
# OIDC_CLIENT_ID: ""
# OIDC client ID.
# OIDC_DISPLAY_NAME: "SSO"
# Label shown on the SSO login button.
# OIDC_ONLY: "false"
# Set to "true" to disable local password auth entirely (first SSO login becomes admin).
# OIDC_ADMIN_CLAIM: ""
# OIDC claim used to identify admin users.
# OIDC_ADMIN_VALUE: ""
# Value of the OIDC claim that grants admin role.
# OIDC_SCOPE: "openid email profile groups"
# Space-separated OIDC scopes to request. Must include scopes for any claim used by OIDC_ADMIN_CLAIM.
# OIDC_DISCOVERY_URL: ""
# Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik).
# DEMO_MODE: "false"
# Enable demo mode (hourly data resets).
# MCP_RATE_LIMIT: "60"
# Max MCP API requests per user per minute. Defaults to 60.
# MCP_MAX_SESSION_PER_USER: "5"
# Max concurrent MCP sessions per user. Defaults to 5.
# Secret environment variables stored in a Kubernetes Secret.
# JWT_SECRET is managed entirely by the server (auto-generated into the data PVC,
# rotatable via the admin panel) — it is not configured here.
secretEnv:
# At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC, etc.).
# Recommended: set to a random 32-byte hex value (openssl rand -hex 32).
# If left empty the server resolves the key automatically:
# 1. data/.jwt_secret (existing installs — encrypted data stays readable after upgrade)
# 2. data/.encryption_key auto-generated on first start (fresh installs)
ENCRYPTION_KEY: ""
# Initial admin account — only used on first boot when no users exist yet.
# If both values are non-empty the admin account is created with these credentials.
# If either is empty a random password is generated and printed to the server log.
ADMIN_EMAIL: ""
ADMIN_PASSWORD: ""
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
OIDC_CLIENT_SECRET: ""
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
generateEncryptionKey: false
# If set, use an existing Kubernetes secret that contains ENCRYPTION_KEY
existingSecret: ""
existingSecretKey: ENCRYPTION_KEY
persistence:
enabled: true
data:
size: 1Gi
uploads:
size: 1Gi
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
ingress:
enabled: false
className: ""
annotations: {}
hosts:
- host: chart-example.local
paths:
- /
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
+3 -1
View File
@@ -21,7 +21,9 @@
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
</head>
<body>
<div id="root"></div>
+20 -20
View File
@@ -1,12 +1,12 @@
{
"name": "trek-client",
"version": "2.7.0",
"version": "2.9.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-client",
"version": "2.7.0",
"version": "2.9.12",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
@@ -2789,9 +2789,9 @@
}
},
"node_modules/@rollup/pluginutils/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3693,9 +3693,9 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4679,9 +4679,9 @@
"license": "MIT"
},
"node_modules/filelist/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5941,9 +5941,9 @@
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
@@ -7181,9 +7181,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8705,9 +8705,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "2.7.1",
"version": "2.9.12",
"private": true,
"type": "module",
"scripts": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

+48 -8
View File
@@ -3,7 +3,6 @@ import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import LoginPage from './pages/LoginPage'
import RegisterPage from './pages/RegisterPage'
import DashboardPage from './pages/DashboardPage'
import TripPlannerPage from './pages/TripPlannerPage'
import FilesPage from './pages/FilesPage'
@@ -12,10 +11,12 @@ import SettingsPage from './pages/SettingsPage'
import VacayPage from './pages/VacayPage'
import AtlasPage from './pages/AtlasPage'
import SharedTripPage from './pages/SharedTripPage'
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
import { ToastContainer } from './components/shared/Toast'
import { TranslationProvider, useTranslation } from './i18n'
import DemoBanner from './components/Layout/DemoBanner'
import { authApi } from './api/client'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
interface ProtectedRouteProps {
children: ReactNode
@@ -23,8 +24,12 @@ interface ProtectedRouteProps {
}
function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) {
const { isAuthenticated, user, isLoading } = useAuthStore()
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const user = useAuthStore((s) => s.user)
const isLoading = useAuthStore((s) => s.isLoading)
const appRequireMfa = useAuthStore((s) => s.appRequireMfa)
const { t } = useTranslation()
const location = useLocation()
if (isLoading) {
return (
@@ -38,7 +43,17 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
const redirectParam = encodeURIComponent(location.pathname + location.search)
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
}
if (
appRequireMfa &&
user &&
!user.mfa_enabled &&
location.pathname !== '/settings'
) {
return <Navigate to="/settings?mfa=required" replace />
}
if (adminRequired && user && user.role !== 'admin') {
@@ -63,17 +78,21 @@ function RootRedirect() {
}
export default function App() {
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
useEffect(() => {
if (token) {
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/login')) {
loadUser()
}
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string }) => {
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
if (config?.demo_mode) setDemoMode(true)
if (config?.dev_mode) setDevMode(true)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
if (config?.version) {
const storedVersion = localStorage.getItem('trek_app_version')
@@ -99,13 +118,26 @@ export default function App() {
const { settings } = useSettingsStore()
useInAppNotificationListener()
useEffect(() => {
if (isAuthenticated) {
loadSettings()
}
}, [isAuthenticated])
const location = useLocation()
const isSharedPage = location.pathname.startsWith('/shared/')
useEffect(() => {
// Shared page always forces light mode
if (isSharedPage) {
document.documentElement.classList.remove('dark')
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', '#ffffff')
return
}
const mode = settings.dark_mode
const applyDark = (isDark: boolean) => {
document.documentElement.classList.toggle('dark', isDark)
@@ -121,7 +153,7 @@ export default function App() {
return () => mq.removeEventListener('change', handler)
}
applyDark(mode === true || mode === 'dark')
}, [settings.dark_mode])
}, [settings.dark_mode, isSharedPage])
return (
<TranslationProvider>
@@ -187,6 +219,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/notifications"
element={
<ProtectedRoute>
<InAppNotificationsPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</TranslationProvider>
+58
View File
@@ -0,0 +1,58 @@
export async function getAuthUrl(url: string, purpose: 'download'): Promise<string> {
if (!url) return url
try {
const resp = await fetch('/api/auth/resource-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ purpose }),
})
if (!resp.ok) return url
const { token } = await resp.json()
return `${url}${url.includes('?') ? '&' : '?'}token=${token}`
} catch {
return url
}
}
// ── Blob-based image fetching (Safari-safe, no ephemeral tokens needed) ────
const MAX_CONCURRENT = 6
let active = 0
const queue: Array<() => void> = []
function dequeue() {
while (active < MAX_CONCURRENT && queue.length > 0) {
active++
queue.shift()!()
}
}
export function clearImageQueue() {
queue.length = 0
}
export async function fetchImageAsBlob(url: string): Promise<string> {
if (!url) return ''
return new Promise<string>((resolve) => {
const run = async () => {
try {
const resp = await fetch(url, { credentials: 'include' })
if (!resp.ok) { resolve(''); return }
const blob = await resp.blob()
resolve(URL.createObjectURL(blob))
} catch {
resolve('')
} finally {
active--
dequeue()
}
}
if (active < MAX_CONCURRENT) {
active++
run()
} else {
queue.push(run)
}
})
}
+66 -15
View File
@@ -3,18 +3,15 @@ import { getSocketId } from './websocket'
const apiClient: AxiosInstance = axios.create({
baseURL: '/api',
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor - add auth token and socket ID
// Request interceptor - add socket ID
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
const sid = getSocketId()
if (sid) {
config.headers['X-Socket-Id'] = sid
@@ -28,12 +25,19 @@ apiClient.interceptors.request.use(
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('auth_token')
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) {
window.location.href = '/login'
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/')) {
const currentPath = window.location.pathname + window.location.search
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
}
}
if (
error.response?.status === 403 &&
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
!window.location.pathname.startsWith('/settings')
) {
window.location.href = '/settings?mfa=required'
}
return Promise.reject(error)
}
)
@@ -44,7 +48,7 @@ export const authApi = {
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data),
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data),
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }),
mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data),
me: () => apiClient.get('/auth/me').then(r => r.data),
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
@@ -61,6 +65,11 @@ export const authApi = {
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
mcpTokens: {
list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data),
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name }).then(r => r.data),
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
},
}
export const tripsApi = {
@@ -75,6 +84,7 @@ export const tripsApi = {
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
}
export const daysApi = {
@@ -95,6 +105,8 @@ export const placesApi = {
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)
},
importGoogleList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
}
export const assignmentsApi = {
@@ -119,12 +131,24 @@ export const packingApi = {
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds }).then(r => r.data),
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
updateBag: (tripId: number | string, bagId: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data),
}
export const todoApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/todo/${id}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds }).then(r => r.data),
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo/category-assignees`).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
}
export const tagsApi = {
list: () => apiClient.get('/tags').then(r => r.data),
create: (data: Record<string, unknown>) => apiClient.post('/tags', data).then(r => r.data),
@@ -151,7 +175,6 @@ export const adminApi = {
addons: () => apiClient.get('/admin/addons').then(r => r.data),
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data),
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
@@ -170,6 +193,15 @@ export const adminApi = {
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),
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data),
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
sendTestNotification: (data: Record<string, unknown>) =>
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
}
export const addonsApi = {
@@ -216,7 +248,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),
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
}
export const weatherApi = {
@@ -267,9 +299,8 @@ export const backupApi = {
list: () => apiClient.get('/backup/list').then(r => r.data),
create: () => apiClient.post('/backup/create').then(r => r.data),
download: async (filename: string): Promise<void> => {
const token = localStorage.getItem('auth_token')
const res = await fetch(`/api/backup/download/${filename}`, {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!res.ok) throw new Error('Download failed')
const blob = await res.blob()
@@ -300,8 +331,28 @@ export const shareApi = {
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),
updatePreferences: (prefs: Record<string, 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),
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data),
}
export const inAppNotificationsApi = {
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
unreadCount: () =>
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
markRead: (id: number) =>
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
markUnread: (id: number) =>
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
markAllRead: () =>
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
delete: (id: number) =>
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
deleteAll: () =>
apiClient.delete('/notifications/in-app/all').then(r => r.data),
respond: (id: number, response: 'positive' | 'negative') =>
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
}
export default apiClient
+42 -12
View File
@@ -9,9 +9,10 @@ let reconnectDelay = 1000
const MAX_RECONNECT_DELAY = 30000
const listeners = new Set<WebSocketListener>()
const activeTrips = new Set<string>()
let currentToken: string | null = null
let shouldReconnect = false
let refetchCallback: RefetchCallback | null = null
let mySocketId: string | null = null
let connecting = false
export function getSocketId(): string | null {
return mySocketId
@@ -21,9 +22,28 @@ export function setRefetchCallback(fn: RefetchCallback | null): void {
refetchCallback = fn
}
function getWsUrl(token: string): string {
function getWsUrl(wsToken: string): string {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
return `${protocol}://${location.host}/ws?token=${token}`
return `${protocol}://${location.host}/ws?token=${wsToken}`
}
async function fetchWsToken(): Promise<string | null> {
try {
const resp = await fetch('/api/auth/ws-token', {
method: 'POST',
credentials: 'include',
})
if (resp.status === 401) {
// Session expired — stop reconnecting
shouldReconnect = false
return null
}
if (!resp.ok) return null
const { token } = await resp.json()
return token as string
} catch {
return null
}
}
function handleMessage(event: MessageEvent): void {
@@ -45,19 +65,29 @@ function scheduleReconnect(): void {
if (reconnectTimer) return
reconnectTimer = setTimeout(() => {
reconnectTimer = null
if (currentToken) {
connectInternal(currentToken, true)
if (shouldReconnect) {
connectInternal(true)
}
}, reconnectDelay)
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
}
function connectInternal(token: string, _isReconnect = false): void {
async function connectInternal(_isReconnect = false): Promise<void> {
if (connecting) return
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return
}
const url = getWsUrl(token)
connecting = true
const wsToken = await fetchWsToken()
connecting = false
if (!wsToken) {
if (shouldReconnect) scheduleReconnect()
return
}
const url = getWsUrl(wsToken)
socket = new WebSocket(url)
socket.onopen = () => {
@@ -82,7 +112,7 @@ function connectInternal(token: string, _isReconnect = false): void {
socket.onclose = () => {
socket = null
if (currentToken) {
if (shouldReconnect) {
scheduleReconnect()
}
}
@@ -92,18 +122,18 @@ function connectInternal(token: string, _isReconnect = false): void {
}
}
export function connect(token: string): void {
currentToken = token
export function connect(): void {
shouldReconnect = true
reconnectDelay = 1000
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
connectInternal(token, false)
connectInternal(false)
}
export function disconnect(): void {
currentToken = null
shouldReconnect = false
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
+133 -31
View File
@@ -2,11 +2,12 @@ import { useEffect, useState } from 'react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2 } from 'lucide-react'
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image,
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2,
}
interface Addon {
@@ -14,7 +15,17 @@ interface Addon {
name: string
description: string
icon: string
type: string
enabled: boolean
config?: Record<string, unknown>
}
interface ProviderOption {
key: string
label: string
description: string
enabled: boolean
toggle: () => Promise<void>
}
interface AddonIconProps {
@@ -32,7 +43,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
const dm = useSettingsStore(s => s.settings.dark_mode)
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const toast = useToast()
const [addons, setAddons] = useState([])
const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
const [addons, setAddons] = useState<Addon[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
@@ -51,13 +63,13 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
}
}
const handleToggle = async (addon) => {
const handleToggle = async (addon: Addon) => {
const newEnabled = !addon.enabled
// Optimistic update
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
try {
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
window.dispatchEvent(new Event('addons-changed'))
refreshGlobalAddons()
toast.success(t('admin.addons.toast.updated'))
} catch (err: unknown) {
// Rollback
@@ -66,8 +78,44 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
}
}
const isPhotoProviderAddon = (addon: Addon) => {
return addon.type === 'photo_provider'
}
const isPhotosAddon = (addon: Addon) => {
const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase()
return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
}
const handleTogglePhotoProvider = async (providerAddon: Addon) => {
const enableProvider = !providerAddon.enabled
const prev = addons
setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a))
try {
await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider })
refreshGlobalAddons()
toast.success(t('admin.addons.toast.updated'))
} catch {
setAddons(prev)
toast.error(t('admin.addons.toast.error'))
}
}
const tripAddons = addons.filter(a => a.type === 'trip')
const globalAddons = addons.filter(a => a.type === 'global')
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
const integrationAddons = addons.filter(a => a.type === 'integration')
const photosAddon = tripAddons.find(isPhotosAddon)
const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
key: provider.id,
label: provider.name,
description: provider.description,
enabled: provider.enabled,
toggle: () => handleTogglePhotoProvider(provider),
}))
const photosDerivedEnabled = providerOptions.some(p => p.enabled)
if (loading) {
return (
@@ -105,7 +153,42 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
</div>
{tripAddons.map(addon => (
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
<AddonRow
addon={addon}
onToggle={handleToggle}
t={t}
nameOverride={photosAddon && addon.id === photosAddon.id ? 'Memories providers' : undefined}
descriptionOverride={photosAddon && addon.id === photosAddon.id ? 'Enable or disable each photo provider.' : undefined}
statusOverride={photosAddon && addon.id === photosAddon.id ? photosDerivedEnabled : undefined}
hideToggle={photosAddon && addon.id === photosAddon.id}
/>
{photosAddon && addon.id === photosAddon.id && providerOptions.length > 0 && (
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div className="space-y-2">
{providerOptions.map(provider => (
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
onClick={provider.toggle}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
))}
</div>
</div>
)}
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div style={{ flex: 1, minWidth: 0 }}>
@@ -144,6 +227,21 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
))}
</div>
)}
{/* Integration Addons */}
{integrationAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
<Link2 size={13} style={{ color: 'var(--text-muted)' }} />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
{t('admin.addons.type.integration')} {t('admin.addons.integrationHint')}
</span>
</div>
{integrationAddons.map(addon => (
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
))}
</div>
)}
</div>
)}
</div>
@@ -153,8 +251,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
interface AddonRowProps {
addon: Addon
onToggle: (addonId: string) => void
onToggle: (addon: Addon) => void
t: (key: string) => string
statusOverride?: boolean
hideToggle?: boolean
}
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
@@ -169,9 +269,12 @@ function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string
}
}
function AddonRow({ addon, onToggle, t }: AddonRowProps) {
function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) {
const isComingSoon = false
const label = getAddonLabel(t, addon)
const displayName = nameOverride || label.name
const displayDescription = descriptionOverride || label.description
const enabledState = statusOverride ?? addon.enabled
return (
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
{/* Icon */}
@@ -182,41 +285,40 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{label.name}</span>
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{displayName}</span>
{isComingSoon && (
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
Coming Soon
</span>
)}
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{
background: addon.type === 'global' ? 'var(--bg-secondary)' : 'var(--bg-secondary)',
color: 'var(--text-muted)',
}}>
{addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')}
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}>
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
</span>
</div>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{label.description}</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{displayDescription}</p>
</div>
{/* Toggle */}
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
<span className="hidden sm:inline text-xs font-medium" style={{ color: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
onClick={() => !isComingSoon && onToggle(addon)}
disabled={isComingSoon}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
>
<span
className="inline-block h-4 w-4 transform rounded-full transition-transform"
style={{
background: 'var(--bg-card)',
transform: (addon.enabled && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
}}
/>
</button>
{!hideToggle && (
<button
onClick={() => !isComingSoon && onToggle(addon)}
disabled={isComingSoon}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
>
<span
className="inline-block h-4 w-4 transform rounded-full transition-transform"
style={{
background: 'var(--bg-card)',
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
}}
/>
</button>
)}
</div>
</div>
)
@@ -0,0 +1,120 @@
import { useState, useEffect } from 'react'
import { adminApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { Key, Trash2, User, Loader2 } from 'lucide-react'
import { useTranslation } from '../../i18n'
interface AdminMcpToken {
id: number
name: string
token_prefix: string
created_at: string
last_used_at: string | null
user_id: number
username: string
}
export default function AdminMcpTokensPanel() {
const [tokens, setTokens] = useState<AdminMcpToken[]>([])
const [isLoading, setIsLoading] = useState(true)
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null)
const toast = useToast()
const { t, locale } = useTranslation()
useEffect(() => {
setIsLoading(true)
adminApi.mcpTokens()
.then(d => setTokens(d.tokens || []))
.catch(() => toast.error(t('admin.mcpTokens.loadError')))
.finally(() => setIsLoading(false))
}, [])
const handleDelete = async (id: number) => {
try {
await adminApi.deleteMcpToken(id)
setTokens(prev => prev.filter(tk => tk.id !== id))
setDeleteConfirmId(null)
toast.success(t('admin.mcpTokens.deleteSuccess'))
} catch {
toast.error(t('admin.mcpTokens.deleteError'))
}
}
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.title')}</h2>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
</div>
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
</div>
) : tokens.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
</div>
) : (
<>
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
<span>{t('admin.mcpTokens.tokenName')}</span>
<span>{t('admin.mcpTokens.owner')}</span>
<span className="text-right">{t('admin.mcpTokens.created')}</span>
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
<span></span>
</div>
{tokens.map((token, i) => (
<div key={token.id}
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
</div>
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
<User className="w-3.5 h-3.5 flex-shrink-0" />
<span className="whitespace-nowrap">{token.username}</span>
</div>
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
{new Date(token.created_at).toLocaleDateString(locale)}
</span>
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
</span>
<button onClick={() => setDeleteConfirmId(token.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</>
)}
</div>
{deleteConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.deleteTitle')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.deleteMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setDeleteConfirmId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={() => handleDelete(deleteConfirmId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
{t('common.delete')}
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -15,7 +15,11 @@ interface AuditEntry {
ip: string | null
}
export default function AuditLogPanel(): React.ReactElement {
interface AuditLogPanelProps {
serverTimezone?: string
}
export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement {
const { t, locale } = useTranslation()
const [entries, setEntries] = useState<AuditEntry[]>([])
const [total, setTotal] = useState(0)
@@ -66,9 +70,10 @@ export default function AuditLogPanel(): React.ReactElement {
const fmtTime = (iso: string) => {
try {
return new Date(iso).toLocaleString(locale, {
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: serverTimezone || undefined,
})
} catch {
return iso
+4 -2
View File
@@ -324,9 +324,11 @@ export default function BackupPanel() {
</div>
<button
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
className={`relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-900 dark:bg-slate-100' : 'bg-gray-200 dark:bg-gray-600'}`}
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: autoSettings.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className={`absolute left-1 h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${autoSettings.enabled ? 'translate-x-5' : 'translate-x-0'}`} />
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: autoSettings.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</label>
@@ -0,0 +1,285 @@
import React, { useState, useEffect } from 'react'
import { adminApi, tripsApi } from '../../api/client'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast'
import {
Bell, Zap, ArrowRight, CheckCircle, Navigation, User,
Calendar, Clock, Image, MessageSquare, Tag, UserPlus,
Download, MapPin,
} from 'lucide-react'
interface Trip {
id: number
title: string
}
interface AppUser {
id: number
username: string
email: string
}
export default function DevNotificationsPanel(): React.ReactElement {
const toast = useToast()
const user = useAuthStore(s => s.user)
const [sending, setSending] = useState<string | null>(null)
const [trips, setTrips] = useState<Trip[]>([])
const [selectedTripId, setSelectedTripId] = useState<number | null>(null)
const [users, setUsers] = useState<AppUser[]>([])
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
useEffect(() => {
tripsApi.list().then(data => {
const list = (data.trips || data || []) as Trip[]
setTrips(list)
if (list.length > 0) setSelectedTripId(list[0].id)
}).catch(() => {})
adminApi.users().then(data => {
const list = (data.users || data || []) as AppUser[]
setUsers(list)
if (list.length > 0) setSelectedUserId(list[0].id)
}).catch(() => {})
}, [])
const fire = async (label: string, payload: Record<string, unknown>) => {
setSending(label)
try {
await adminApi.sendTestNotification(payload)
toast.success(`Sent: ${label}`)
} catch (err: any) {
toast.error(err.message || 'Failed')
} finally {
setSending(null)
}
}
const selectedTrip = trips.find(t => t.id === selectedTripId)
const selectedUser = users.find(u => u.id === selectedUserId)
const username = user?.username || 'Admin'
const tripTitle = selectedTrip?.title || 'Test Trip'
// ── Helpers ──────────────────────────────────────────────────────────────
const Btn = ({
id, label, sub, icon: Icon, color, onClick,
}: {
id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void
}) => (
<button
onClick={onClick}
disabled={sending !== null}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: `${color}20`, color }}>
<Icon className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>{sub}</p>
</div>
{sending === id && (
<div className="w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin flex-shrink-0" />
)}
</button>
)
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>{children}</h3>
)
const TripSelector = () => (
<select
value={selectedTripId ?? ''}
onChange={e => setSelectedTripId(Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
</select>
)
const UserSelector = () => (
<select
value={selectedUserId ?? ''}
onChange={e => setSelectedUserId(Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
</select>
)
return (
<div className="space-y-8">
<div className="flex items-center gap-2">
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold" style={{ background: '#fbbf24', color: '#000' }}>
DEV ONLY
</div>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
Notification Testing
</span>
</div>
{/* ── Type Testing ─────────────────────────────────────────────────── */}
<div>
<SectionTitle>Type Testing</SectionTitle>
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
Test how each in-app notification type renders, sent to yourself.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Btn id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell} color="#6366f1"
onClick={() => fire('simple-me', {
event: 'test_simple',
scope: 'user',
targetId: user?.id,
params: {},
})}
/>
<Btn id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle} color="#10b981"
onClick={() => fire('boolean-me', {
event: 'test_boolean',
scope: 'user',
targetId: user?.id,
params: {},
inApp: {
type: 'boolean',
positiveCallback: { action: 'test_approve', payload: {} },
negativeCallback: { action: 'test_deny', payload: {} },
},
})}
/>
<Btn id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation} color="#f59e0b"
onClick={() => fire('navigate-me', {
event: 'test_navigate',
scope: 'user',
targetId: user?.id,
params: {},
})}
/>
<Btn id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap} color="#ef4444"
onClick={() => fire('simple-admins', {
event: 'test_simple',
scope: 'admin',
targetId: 0,
params: {},
})}
/>
</div>
</div>
{/* ── Trip-Scoped Events ───────────────────────────────────────────── */}
{trips.length > 0 && (
<div>
<SectionTitle>Trip-Scoped Events</SectionTitle>
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
Fires each trip event to all members of the selected trip (excluding yourself).
</p>
<TripSelector />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Btn id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar} color="#6366f1"
onClick={() => selectedTripId && fire('booking_change', {
event: 'booking_change',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, booking: 'Test Hotel', type: 'hotel', tripId: String(selectedTripId) },
})}
/>
<Btn id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock} color="#10b981"
onClick={() => selectedTripId && fire('trip_reminder', {
event: 'trip_reminder',
scope: 'trip',
targetId: selectedTripId,
params: { trip: tripTitle, tripId: String(selectedTripId) },
})}
/>
<Btn id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image} color="#f59e0b"
onClick={() => selectedTripId && fire('photos_shared', {
event: 'photos_shared',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
})}
/>
<Btn id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare} color="#8b5cf6"
onClick={() => selectedTripId && fire('collab_message', {
event: 'collab_message',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) },
})}
/>
<Btn id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag} color="#ec4899"
onClick={() => selectedTripId && fire('packing_tagged', {
event: 'packing_tagged',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
})}
/>
</div>
</div>
)}
{/* ── User-Scoped Events ───────────────────────────────────────────── */}
{users.length > 0 && (
<div>
<SectionTitle>User-Scoped Events</SectionTitle>
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
Fires each user event to the selected recipient.
</p>
<UserSelector />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Btn
id={`trip_invite-${selectedUserId}`}
label="trip_invite"
sub="navigate · user"
icon={UserPlus}
color="#06b6d4"
onClick={() => selectedUserId && fire(`trip_invite-${selectedUserId}`, {
event: 'trip_invite',
scope: 'user',
targetId: selectedUserId,
params: { actor: username, trip: tripTitle, invitee: selectedUser?.email || '', tripId: String(selectedTripId ?? 0) },
})}
/>
<Btn
id={`vacay_invite-${selectedUserId}`}
label="vacay_invite"
sub="navigate · user"
icon={MapPin}
color="#f97316"
onClick={() => selectedUserId && fire(`vacay_invite-${selectedUserId}`, {
event: 'vacay_invite',
scope: 'user',
targetId: selectedUserId,
params: { actor: username, planId: '1' },
})}
/>
</div>
</div>
)}
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
<div>
<SectionTitle>Admin-Scoped Events</SectionTitle>
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
Fires to all admin users.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Btn id="version_available" label="version_available" sub="navigate · admin" icon={Download} color="#64748b"
onClick={() => fire('version_available', {
event: 'version_available',
scope: 'admin',
targetId: 0,
params: { version: '9.9.9-test' },
})}
/>
</div>
</div>
</div>
)
}
+84 -5
View File
@@ -1,9 +1,9 @@
import { useState, useEffect } from 'react'
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee } from 'lucide-react'
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react'
import { getLocaleForLanguage, useTranslation } from '../../i18n'
import apiClient from '../../api/client'
const REPO = 'mauriceboe/NOMAD'
const REPO = 'mauriceboe/TREK'
const PER_PAGE = 10
export default function GitHubPanel() {
@@ -72,11 +72,15 @@ export default function GitHubPanel() {
}
}
const escapeHtml = (str) => str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
const inlineFormat = (text) => {
return text
return escapeHtml(text)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">$1</a>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`
})
}
for (const line of lines) {
@@ -115,7 +119,7 @@ export default function GitHubPanel() {
return (
<div className="space-y-3">
{/* Support cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<a
href="https://ko-fi.com/mauriceboe"
target="_blank"
@@ -152,6 +156,81 @@ export default function GitHubPanel() {
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://discord.gg/nSdKaXgN"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#5865F215', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<a
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ef444415', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Bug size={20} style={{ color: '#ef4444' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.reportBug')}</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.reportBugHint')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#f59e0b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Lightbulb size={20} style={{ color: '#f59e0b' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.featureRequest')}</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.featureRequestHint')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://github.com/mauriceboe/TREK/wiki"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#6366f115', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<BookOpen size={20} style={{ color: '#6366f1' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Wiki</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.wikiHint')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
</div>
{/* Loading / Error / Releases */}
@@ -0,0 +1,170 @@
import React, { useEffect, useState, useMemo } from 'react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { usePermissionsStore, PermissionLevel } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { Save, Loader2, RotateCcw } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
interface PermissionEntry {
key: string
level: PermissionLevel
defaultLevel: PermissionLevel
allowedLevels: PermissionLevel[]
}
const LEVEL_LABELS: Record<string, string> = {
admin: 'perm.level.admin',
trip_owner: 'perm.level.tripOwner',
trip_member: 'perm.level.tripMember',
everybody: 'perm.level.everybody',
}
const CATEGORIES = [
{ id: 'trip', keys: ['trip_create', 'trip_edit', 'trip_delete', 'trip_archive', 'trip_cover_upload'] },
{ id: 'members', keys: ['member_manage'] },
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
]
export default function PermissionsPanel(): React.ReactElement {
const { t } = useTranslation()
const toast = useToast()
const [entries, setEntries] = useState<PermissionEntry[]>([])
const [values, setValues] = useState<Record<string, PermissionLevel>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
useEffect(() => {
loadPermissions()
}, [])
const loadPermissions = async () => {
setLoading(true)
try {
const data = await adminApi.getPermissions()
setEntries(data.permissions)
const vals: Record<string, PermissionLevel> = {}
for (const p of data.permissions) vals[p.key] = p.level
setValues(vals)
setDirty(false)
} catch {
toast.error(t('common.error'))
} finally {
setLoading(false)
}
}
const handleChange = (key: string, level: PermissionLevel) => {
setValues(prev => ({ ...prev, [key]: level }))
setDirty(true)
}
const handleSave = async () => {
setSaving(true)
try {
const data = await adminApi.updatePermissions(values)
if (data.permissions) {
usePermissionsStore.getState().setPermissions(data.permissions)
}
setDirty(false)
toast.success(t('perm.saved'))
} catch {
toast.error(t('common.error'))
} finally {
setSaving(false)
}
}
const handleReset = () => {
const defaults: Record<string, PermissionLevel> = {}
for (const p of entries) defaults[p.key] = p.defaultLevel
setValues(defaults)
setDirty(true)
}
const entryMap = useMemo(() => new Map(entries.map(e => [e.key, e])), [entries])
if (loading) {
return (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" />
</div>
)
}
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
<div>
<h2 className="font-semibold text-slate-900">{t('perm.title')}</h2>
<p className="text-xs text-slate-400 mt-0.5">{t('perm.subtitle')}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleReset}
disabled={saving}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" />
{t('perm.resetDefaults')}
</button>
<button
onClick={handleSave}
disabled={saving || !dirty}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:bg-slate-400 transition-colors"
>
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
{t('common.save')}
</button>
</div>
</div>
<div className="divide-y divide-slate-100">
{CATEGORIES.map(cat => (
<div key={cat.id} className="px-6 py-4">
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">
{t(`perm.cat.${cat.id}`)}
</h3>
<div className="space-y-3">
{cat.keys.map(key => {
const entry = entryMap.get(key)
if (!entry) return null
const currentLevel = values[key] || entry.defaultLevel
const isDefault = currentLevel === entry.defaultLevel
return (
<div key={key} className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-700">{t(`perm.action.${key}`)}</p>
<p className="text-xs text-slate-400 mt-0.5">{t(`perm.actionHint.${key}`)}</p>
</div>
<div className="flex items-center gap-2">
{!isDefault && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700">
{t('perm.customized')}
</span>
)}
<CustomSelect
value={currentLevel}
onChange={(val) => handleChange(key, val as PermissionLevel)}
options={entry.allowedLevels.map(l => ({
value: l,
label: t(LEVEL_LABELS[l] || l),
}))}
style={{ minWidth: 160 }}
/>
</div>
</div>
)
})}
</div>
</div>
))}
</div>
</div>
</div>
)
}
+145 -70
View File
@@ -2,10 +2,12 @@ import ReactDOM from 'react-dom'
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import DOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTranslation } from '../../i18n'
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight } from 'lucide-react'
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { budgetApi } from '../../api/client'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import type { BudgetItem, BudgetMember } from '../../types'
import { currencyDecimals } from '../../utils/formatters'
@@ -59,7 +61,7 @@ const calcPD = (p, d) => (d > 0 ? p / d : null)
const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null)
// ── Inline Edit Cell ─────────────────────────────────────────────────────────
function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip }) {
function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }) {
const [editing, setEditing] = useState(false)
const [editValue, setEditValue] = useState(value ?? '')
const inputRef = useRef(null)
@@ -86,12 +88,12 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
: (value || '')
return (
<div onClick={() => { setEditValue(value ?? ''); setEditing(true) }} title={editTooltip}
style={{ cursor: 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center',
<div onClick={() => { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
{display || placeholder || '-'}
</div>
)
@@ -99,7 +101,7 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
// ── Add Item Row ─────────────────────────────────────────────────────────────
interface AddItemRowProps {
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null }) => void
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void
t: (key: string) => string
}
@@ -109,12 +111,13 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
const [persons, setPersons] = useState('')
const [days, setDays] = useState('')
const [note, setNote] = useState('')
const [expenseDate, setExpenseDate] = useState('')
const nameRef = useRef(null)
const handleAdd = () => {
if (!name.trim()) return
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null })
setName(''); setPrice(''); setPersons(''); setDays(''); setNote('')
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
setTimeout(() => nameRef.current?.focus(), 50)
}
@@ -132,15 +135,20 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
</td>
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
<input value={persons} onChange={e => setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} />
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
</td>
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
<input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} />
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
</td>
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
<td className="hidden lg:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
<div style={{ maxWidth: 90, margin: '0 auto' }}>
<CustomDatePicker value={expenseDate} onChange={setExpenseDate} placeholder="-" compact />
</div>
</td>
<td className="hidden sm:table-cell" style={{ padding: '4px 6px' }}>
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
</td>
@@ -227,9 +235,10 @@ interface BudgetMemberChipsProps {
onSetMembers: (memberIds: number[]) => void
onTogglePaid?: (userId: number, paid: boolean) => void
compact?: boolean
readOnly?: boolean
}
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true }: BudgetMemberChipsProps) {
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) {
const chipSize = compact ? 20 : 30
const btnSize = compact ? 18 : 28
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
@@ -271,17 +280,19 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTog
{members.map(m => (
<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}
onClick={!readOnly && onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
/>
))}
<button ref={btnRef} onClick={openDropdown}
style={{
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
}}>
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
</button>
{!readOnly && (
<button ref={btnRef} onClick={openDropdown}
style={{
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
}}>
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
</button>
)}
{showDropdown && ReactDOM.createPortal(
<div ref={dropRef} style={{
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
@@ -412,12 +423,14 @@ interface BudgetPanelProps {
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore()
const can = useCanDo()
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 canEdit = can('budget_edit', trip)
const fmt = (v, cur) => fmtNum(v, locale, cur)
const hasMultipleMembers = tripMembers.length > 1
@@ -470,6 +483,41 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
setNewCategoryName('')
}
const handleExportCsv = () => {
const sep = ';'
const esc = (v: any) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s }
const d = currencyDecimals(currency)
const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : ''
const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) }
const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note']
const rows = [header.join(sep)]
for (const cat of categoryNames) {
for (const item of (grouped[cat] || [])) {
const pp = calcPP(item.total_price, item.persons)
const pd = calcPD(item.total_price, item.days)
const ppd = calcPPD(item.total_price, item.persons, item.days)
rows.push([
esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')),
fmtPrice(item.total_price), item.persons ?? '', item.days ?? '',
fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd),
esc(item.note || ''),
].join(sep))
}
}
const bom = '\uFEFF'
const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9\u00C0-\u024F _-]/g, '').trim()
a.download = `budget-${safeName}.csv`
a.click()
URL.revokeObjectURL(url)
}
const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
const td = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
@@ -482,16 +530,18 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
</div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
<p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
placeholder={t('budget.emptyPlaceholder')}
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
<Plus size={16} />
</button>
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
placeholder={t('budget.emptyPlaceholder')}
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
<Plus size={16} />
</button>
</div>
)}
</div>
)
}
@@ -504,6 +554,10 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<Calculator size={20} color="var(--text-primary)" />
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
</div>
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
<Download size={13} /> CSV
</button>
</div>
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
@@ -518,7 +572,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', borderRadius: '10px 10px 0 0', padding: '9px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
{editingCat?.name === cat ? (
{canEdit && editingCat?.name === cat ? (
<input
autoFocus
value={editingCat.value}
@@ -530,21 +584,25 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
) : (
<>
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
<button onClick={() => setEditingCat({ name: cat, value: cat })}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
<Pencil size={10} />
</button>
{canEdit && (
<button onClick={() => setEditingCat({ name: cat, value: cat })}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
<Pencil size={10} />
</button>
)}
</>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
<Trash2 size={13} />
</button>
{canEdit && (
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
<Trash2 size={13} />
</button>
)}
</div>
</div>
@@ -552,14 +610,15 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ ...th, textAlign: 'left', minWidth: 100 }}>{t('budget.table.name')}</th>
<th style={{ ...th, minWidth: 60 }}>{t('budget.table.total')}</th>
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 130 }}>{t('budget.table.persons')}</th>
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 45 }}>{t('budget.table.days')}</th>
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perPerson')}</th>
<th className="hidden md:table-cell" style={{ ...th, minWidth: 80 }}>{t('budget.table.perDay')}</th>
<th style={{ ...th, textAlign: 'left', minWidth: 120 }}>{t('budget.table.name')}</th>
<th style={{ ...th, minWidth: 75 }}>{t('budget.table.total')}</th>
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 160 }}>{t('budget.table.persons')}</th>
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 55 }}>{t('budget.table.days')}</th>
<th className="hidden md:table-cell" style={{ ...th, minWidth: 100 }}>{t('budget.table.perPerson')}</th>
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perDay')}</th>
<th className="hidden lg:table-cell" style={{ ...th, minWidth: 95 }}>{t('budget.table.perPersonDay')}</th>
<th className="hidden sm:table-cell" style={{ ...th, textAlign: 'left', minWidth: 80 }}>{t('budget.table.note')}</th>
<th className="hidden sm:table-cell" style={{ ...th, width: 90, maxWidth: 90 }}>{t('budget.table.date')}</th>
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 150 }}>{t('budget.table.note')}</th>
<th style={{ ...th, width: 36 }}></th>
</tr>
</thead>
@@ -574,7 +633,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<td style={td}>
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} />
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} />
{/* Mobile: larger chips under name since Persons column is hidden */}
{hasMultipleMembers && (
<div className="sm:hidden" style={{ marginTop: 4 }}>
@@ -584,12 +643,13 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
compact={false}
readOnly={!canEdit}
/>
</div>
)}
</td>
<td style={{ ...td, textAlign: 'center' }}>
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} />
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
</td>
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
{hasMultipleMembers ? (
@@ -598,29 +658,41 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
tripMembers={tripMembers}
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
readOnly={!canEdit}
/>
) : (
<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')} />
<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')} readOnly={!canEdit} />
)}
</td>
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
</td>
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td>
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td>
<td className="hidden lg:table-cell" style={{ ...td, textAlign: 'center', color: ppd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{ppd != null ? fmt(ppd, currency) : '-'}</td>
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} /></td>
<td className="hidden sm:table-cell" style={{ ...td, padding: '2px 6px', width: 90, maxWidth: 90, textAlign: 'center' }}>
{canEdit ? (
<div style={{ maxWidth: 90, margin: '0 auto' }}>
<CustomDatePicker value={item.expense_date || ''} onChange={v => handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless />
</div>
) : (
<span style={{ fontSize: 11, color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span>
)}
</td>
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td>
<td style={{ ...td, textAlign: 'center' }}>
{canEdit && (
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
<Trash2 size={14} />
</button>
)}
</td>
</tr>
)
})}
<AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />
{canEdit && <AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />}
</tbody>
</table>
</div>
@@ -629,29 +701,32 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
})}
</div>
<div className="w-full md:w-[280px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
<div className="w-full md:w-[240px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
<div style={{ marginBottom: 12 }}>
<CustomSelect
value={currency}
onChange={setCurrency}
disabled={!canEdit}
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
searchable
/>
</div>
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
<input
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
placeholder={t('budget.categoryName')}
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
/>
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
<Plus size={16} />
</button>
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
<input
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
placeholder={t('budget.categoryName')}
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
/>
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
<Plus size={16} />
</button>
</div>
)}
<div style={{
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
@@ -666,7 +741,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</div>
</div>
</div>
<div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
<div style={{ fontSize: 22, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
</div>
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
+34 -23
View File
@@ -3,6 +3,8 @@ import ReactDOM from 'react-dom'
import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react'
import { collabApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import type { User } from '../../types'
@@ -353,6 +355,9 @@ interface CollabChatProps {
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('collab_edit', trip)
const [messages, setMessages] = useState([])
const [loading, setLoading] = useState(true)
@@ -636,11 +641,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
style={{ position: 'relative' }}
onMouseEnter={() => setHoveredId(msg.id)}
onMouseLeave={() => setHoveredId(null)}
onContextMenu={e => { e.preventDefault(); setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
onTouchEnd={e => {
const now = Date.now()
const lastTap = e.currentTarget.dataset.lastTap || 0
if (now - lastTap < 300) {
if (now - lastTap < 300 && canEdit) {
e.preventDefault()
const touch = e.changedTouches?.[0]
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
@@ -692,7 +697,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
transition: 'opacity .1s',
...(own ? { left: -6 } : { right: -6 }),
}}>
<button onClick={() => setReplyTo(msg)} title="Reply" style={{
<button onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
width: 24, height: 24, borderRadius: '50%', border: 'none',
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
@@ -703,8 +708,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
>
<Reply size={11} />
</button>
{own && (
<button onClick={() => handleDelete(msg.id)} title="Delete" style={{
{own && canEdit && (
<button onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
width: 24, height: 24, borderRadius: '50%', border: 'none',
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
@@ -735,7 +740,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
{msg.reactions.map(r => {
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
return (
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => handleReact(msg.id, r.emoji)} />
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
)
})}
</div>
@@ -780,23 +785,27 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
{/* Emoji button */}
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
}}>
<Smile size={20} />
</button>
{canEdit && (
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
}}>
<Smile size={20} />
</button>
)}
<textarea
ref={textareaRef}
rows={1}
disabled={!canEdit}
style={{
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
maxHeight: 100, overflowY: 'hidden',
opacity: canEdit ? 1 : 0.5,
}}
placeholder={t('collab.chat.placeholder')}
value={text}
@@ -805,15 +814,17 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
/>
{/* Send */}
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
transition: 'background 0.15s',
}}>
<ArrowUp size={18} strokeWidth={2.5} />
</button>
{canEdit && (
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
transition: 'background 0.15s',
}}>
<ArrowUp size={18} strokeWidth={2.5} />
</button>
)}
</div>
</div>
+102 -30
View File
@@ -3,8 +3,11 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import DOM from 'react-dom'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react'
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react'
import { collabApi } from '../../api/client'
import { getAuthUrl } from '../../api/authUrl'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import type { User } from '../../types'
@@ -94,22 +97,37 @@ interface FilePreviewPortalProps {
}
function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
const [authUrl, setAuthUrl] = useState('')
const rawUrl = file?.url || ''
useEffect(() => {
setAuthUrl('')
if (!rawUrl) return
getAuthUrl(rawUrl, 'download').then(setAuthUrl)
}, [rawUrl])
if (!file) return null
const url = file.url || `/uploads/${file.filename}`
const isImage = file.mime_type?.startsWith('image/')
const isPdf = file.mime_type === 'application/pdf'
const isTxt = file.mime_type?.startsWith('text/')
const openInNewTab = async () => {
const u = await getAuthUrl(rawUrl, 'download')
window.open(u, '_blank', 'noreferrer')
}
return ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
{isImage ? (
/* Image lightbox — floating controls */
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
<img src={url} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
{authUrl
? <img src={authUrl} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
: <Loader2 size={32} className="animate-spin" style={{ color: 'rgba(255,255,255,0.5)' }} />
}
<div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8 }}>
<a href={url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }}><ExternalLink size={15} /></a>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
</div>
</div>
@@ -120,19 +138,19 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<a href={url} target="_blank" rel="noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', textDecoration: 'none' }}><ExternalLink size={13} /></a>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
</div>
</div>
{(isPdf || isTxt) ? (
<object data={`${url}#view=FitH`} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
<object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<a href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>Download</a>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download</button>
</p>
</object>
) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
<a href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14 }}>Download {file.original_name}</a>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download {file.original_name}</button>
</div>
)}
</div>
@@ -142,6 +160,14 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
)
}
function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLImageElement>; onMouseLeave?: React.MouseEventHandler<HTMLImageElement>; alt?: string }) {
const [authSrc, setAuthSrc] = useState('')
useEffect(() => {
getAuthUrl(src, 'download').then(setAuthSrc)
}, [src])
return authSrc ? <img src={authSrc} alt={alt} style={style} onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> : null
}
const NOTE_COLORS = [
{ value: '#6366f1', label: 'Indigo' },
{ value: '#ef4444', label: 'Red' },
@@ -216,7 +242,7 @@ function UserAvatar({ user, size = 14 }: UserAvatarProps) {
interface NoteFormModalProps {
onClose: () => void
onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise<void>
onDeleteFile: (noteId: number, fileId: number) => Promise<void>
onDeleteFile?: (noteId: number, fileId: number) => Promise<void>
existingCategories: string[]
categoryColors: Record<string, string>
getCategoryColor: (category: string) => string
@@ -226,6 +252,9 @@ interface NoteFormModalProps {
}
function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) {
const can = useCanDo()
const tripObj = useTripStore((s) => s.trip)
const canUploadFiles = can('file_upload', tripObj)
const isEdit = !!note
const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
@@ -298,6 +327,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
}}
onClick={e => e.stopPropagation()}
onPaste={e => {
if (!canUploadFiles) return
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
@@ -450,18 +480,18 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
</div>
{/* File attachments */}
<div>
{canUploadFiles && <div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
{t('collab.notes.attachFiles')}
</div>
<input id="note-file-input" ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { setPendingFiles(prev => [...prev, ...Array.from((e.target as HTMLInputElement).files)]); e.target.value = '' }} />
<input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { const files = e.target.files; if (files?.length) setPendingFiles(prev => [...prev, ...Array.from(files)]); e.target.value = '' }} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
{/* Existing attachments (edit mode) */}
{existingAttachments.map(a => {
const isImage = a.mime_type?.startsWith('image/')
return (
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
{isImage && <img src={a.url} style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 3 }} />}
{isImage && <AuthedImg src={a.url} style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 3 }} />}
{(a.original_name || '').length > 20 ? a.original_name.slice(0, 17) + '...' : a.original_name}
<button type="button" onClick={() => handleDeleteAttachment(a.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: 0, display: 'flex' }}>
<X size={10} />
@@ -478,12 +508,12 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
</button>
</div>
))}
<label htmlFor="note-file-input"
<button type="button" onClick={() => fileRef.current?.click()}
style={{ padding: '4px 10px', borderRadius: 8, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 11, fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<Plus size={11} /> {t('files.attach') || 'Add'}
</label>
</button>
</div>
</div>
</div>}
{/* Submit */}
<button
@@ -689,6 +719,7 @@ function CategorySettingsModal({ onClose, categories, categoryColors, onSave, on
interface NoteCardProps {
note: CollabNote
currentUser: User
canEdit: boolean
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
onDelete: (noteId: number) => Promise<void>
onEdit: (note: CollabNote) => void
@@ -699,7 +730,7 @@ interface NoteCardProps {
t: (key: string) => string
}
function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
const [hovered, setHovered] = useState(false)
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
@@ -760,24 +791,24 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPre
<Maximize2 size={10} />
</button>
)}
<button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
{canEdit && <button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = color}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
</button>
<button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
</button>}
{canEdit && <button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={10} />
</button>
<button onClick={handleDelete} title={t('collab.notes.delete')}
</button>}
{canEdit && <button onClick={handleDelete} title={t('collab.notes.delete')}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={10} />
</button>
</button>}
<div style={{ width: 1, height: 12, background: 'var(--border-faint)', flexShrink: 0, marginLeft: 1, marginRight: 1 }} />
{/* Author avatar */}
<div style={{ position: 'relative', flexShrink: 0 }}
@@ -838,7 +869,7 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPre
const isImage = a.mime_type?.startsWith('image/')
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
return isImage ? (
<img key={a.id} src={a.url} alt={a.original_name}
<AuthedImg key={a.id} src={a.url} alt={a.original_name}
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
onClick={() => onPreviewFile?.(a)}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
@@ -879,6 +910,9 @@ interface CollabNotesProps {
export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
const { t } = useTranslation()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('collab_edit', trip)
const [notes, setNotes] = useState([])
const [loading, setLoading] = useState(true)
const [showNewModal, setShowNewModal] = useState(false)
@@ -964,7 +998,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch {}
try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch (err) { console.error('Failed to upload note attachment:', err) }
}
// Reload note with attachments
const fresh = await collabApi.getNotes(tripId)
@@ -1124,17 +1158,17 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
{t('collab.notes.title')}
</h3>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Settings size={14} />
</button>
<button onClick={() => setShowNewModal(true)}
</button>}
{canEdit && <button onClick={() => setShowNewModal(true)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
<Plus size={12} />
{t('collab.notes.new')}
</button>
</button>}
</div>
</div>
@@ -1252,6 +1286,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
key={note.id}
note={note}
currentUser={currentUser}
canEdit={canEdit}
onUpdate={handleUpdateNote}
onDelete={handleDeleteNote}
onEdit={setEditingNote}
@@ -1303,12 +1338,12 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
)}
</div>
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
<button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
{canEdit && <button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={16} />
</button>
</button>}
<button onClick={() => setViewingNote(null)}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
@@ -1319,6 +1354,41 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
</div>
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
<Markdown remarkPlugins={[remarkGfm]}>{viewingNote.content || ''}</Markdown>
{(viewingNote.attachments || []).length > 0 && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{(viewingNote.attachments || []).map(a => {
const isImage = a.mime_type?.startsWith('image/')
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
return (
<div key={a.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, maxWidth: 72 }}>
{isImage ? (
<AuthedImg src={a.url} alt={a.original_name}
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
onClick={() => setPreviewFile(a)}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
) : (
<div title={a.original_name} onClick={() => setPreviewFile(a)}
style={{
width: 64, height: 64, borderRadius: 8, cursor: 'pointer',
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
transition: 'transform 0.12s, box-shadow 0.12s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
<span style={{ fontSize: 10, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div>
)}
<span style={{ fontSize: 9, color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
</div>
)
})}
</div>
</div>
)}
</div>
</div>
</div>,
@@ -1327,6 +1397,8 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
{showNewModal && (
<NoteFormModal
note={null}
tripId={tripId}
onClose={() => setShowNewModal(false)}
onSubmit={handleCreateNote}
existingCategories={categories}
+33 -23
View File
@@ -3,6 +3,8 @@ import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import ReactDOM from 'react-dom'
import type { User } from '../../types'
@@ -190,13 +192,14 @@ function VoterChip({ voter, offset }: VoterChipProps) {
interface PollCardProps {
poll: Poll
currentUser: User
canEdit: boolean
onVote: (pollId: number, optionId: number) => Promise<void>
onClose: (pollId: number) => Promise<void>
onDelete: (pollId: number) => Promise<void>
t: (key: string) => string
}
function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardProps) {
function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: PollCardProps) {
const total = totalVotes(poll)
const isClosed = poll.is_closed || isExpired(poll.deadline)
const remaining = timeRemaining(poll.deadline)
@@ -238,22 +241,24 @@ function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardP
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{!isClosed && (
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
{canEdit && (
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{!isClosed && (
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Lock size={12} />
</button>
)}
<button onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Lock size={12} />
<Trash2 size={12} />
</button>
)}
<button onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={12} />
</button>
</div>
</div>
)}
</div>
{/* Options */}
@@ -337,6 +342,9 @@ interface CollabPollsProps {
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
const { t } = useTranslation()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('collab_edit', trip)
const [polls, setPolls] = useState([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
@@ -426,13 +434,15 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
<BarChart3 size={14} color="var(--text-faint)" />
{t('collab.polls.title')}
</h3>
<button onClick={() => setShowForm(true)} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
fontFamily: FONT, border: 'none', cursor: 'pointer',
}}>
<Plus size={12} /> {t('collab.polls.new')}
</button>
{canEdit && (
<button onClick={() => setShowForm(true)} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
fontFamily: FONT, border: 'none', cursor: 'pointer',
}}>
<Plus size={12} /> {t('collab.polls.new')}
</button>
)}
</div>
{/* Content */}
@@ -446,7 +456,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{activePolls.length > 0 && activePolls.map(poll => (
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
<PollCard key={poll.id} poll={poll} currentUser={currentUser} canEdit={canEdit} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
))}
{closedPolls.length > 0 && (
<>
@@ -456,7 +466,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
</div>
)}
{closedPolls.map(poll => (
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
<PollCard key={poll.id} poll={poll} currentUser={currentUser} canEdit={canEdit} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
))}
</>
)}
@@ -23,7 +23,7 @@ function formatDayLabel(date, t, locale) {
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today'
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short' })
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
}
interface TripMember {
@@ -4,11 +4,17 @@ import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
const CURRENCIES = [
'EUR','USD','GBP','JPY','CHF','CAD','AUD','NZD','CNY','HKD',
'SGD','THB','TRY','SEK','NOK','DKK','PLN','CZK','HUF','RON',
'BGN','HRK','ISK','RUB','UAH','BRL','MXN','ARS','CLP','COP',
'INR','IDR','MYR','PHP','KRW','TWD','VND','ZAR','EGP','MAD',
'NGN','KES','AED','SAR','QAR','KWD','BHD','OMR','ILS',
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD',
'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLF', 'CLP',
'CNH', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR',
'FJD', 'FKP', 'FOK', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK',
'HTG', 'HUF', 'IDR', 'ILS', 'IMP', 'INR', 'IQD', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR',
'KID', 'KMF', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA',
'MKD', 'MMK', 'MNT', 'MOP', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK',
'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF',
'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLE', 'SOS', 'SRD', 'SSP', 'STN', 'SYP', 'SZL', 'THB',
'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TVD', 'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES',
'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XDR', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMW', 'ZWL'
]
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
+155 -49
View File
@@ -1,11 +1,15 @@
import ReactDOM from 'react-dom'
import { useState, useCallback, useRef } from 'react'
import { useState, useCallback, useRef, useEffect } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check } from 'lucide-react'
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { filesApi } from '../../api/client'
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { getAuthUrl } from '../../api/authUrl'
function isImage(mimeType) {
if (!mimeType) return false
@@ -33,41 +37,130 @@ function formatDateWithLocale(dateStr, locale) {
} catch { return '' }
}
// Image lightbox
// Image lightbox with gallery navigation
interface ImageLightboxProps {
file: TripFile & { url: string }
files: (TripFile & { url: string })[]
initialIndex: number
onClose: () => void
}
function ImageLightbox({ file, onClose }: ImageLightboxProps) {
function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
const { t } = useTranslation()
const [index, setIndex] = useState(initialIndex)
const [imgSrc, setImgSrc] = useState('')
const [touchStart, setTouchStart] = useState<number | null>(null)
const file = files[index]
useEffect(() => {
setImgSrc('')
if (file) getAuthUrl(file.url, 'download').then(setImgSrc)
}, [file?.url])
const goPrev = () => setIndex(i => Math.max(0, i - 1))
const goNext = () => setIndex(i => Math.min(files.length - 1, i + 1))
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowLeft') goPrev()
if (e.key === 'ArrowRight') goNext()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
if (!file) return null
const hasPrev = index > 0
const hasNext = index < files.length - 1
const navBtn = (side: 'left' | 'right', onClick: () => void, show: boolean): React.ReactNode => show ? (
<button onClick={e => { e.stopPropagation(); onClick() }}
style={{
position: 'absolute', top: '50%', [side]: 12, transform: 'translateY(-50%)', zIndex: 10,
background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: 40, height: 40,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
color: 'rgba(255,255,255,0.8)', transition: 'background 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.75)')}
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.5)')}>
{side === 'left' ? <ChevronLeft size={22} /> : <ChevronRight size={22} />}
</button>
) : null
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column' }}
onClick={onClose}
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
onTouchEnd={e => {
if (touchStart === null) return
const diff = e.changedTouches[0].clientX - touchStart
if (diff > 60) goPrev()
else if (diff < -60) goNext()
setTouchStart(null)
}}
>
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
<img
src={file.url}
alt={file.original_name}
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
/>
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8 }}>
<a href={file.url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }} title={t('files.openTab')}>
<ExternalLink size={16} />
</a>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
<X size={18} />
</button>
</div>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }} onClick={e => e.stopPropagation()}>
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
{file.original_name}
<span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span>
</span>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button
onClick={async () => { const u = await getAuthUrl(file.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
title={t('files.openTab')}>
<ExternalLink size={16} />
</button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}>
<X size={18} />
</button>
</div>
</div>
{/* Main image + nav */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', minHeight: 0 }}
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
{navBtn('left', goPrev, hasPrev)}
{imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} onClick={e => e.stopPropagation()} />}
{navBtn('right', goNext, hasNext)}
</div>
{/* Thumbnail strip */}
{files.length > 1 && (
<div style={{ display: 'flex', gap: 4, justifyContent: 'center', padding: '10px 16px', flexShrink: 0, overflowX: 'auto' }} onClick={e => e.stopPropagation()}>
{files.map((f, i) => (
<ThumbImg key={f.id} file={f} active={i === index} onClick={() => setIndex(i)} />
))}
</div>
)}
</div>
)
}
function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) {
const [src, setSrc] = useState('')
useEffect(() => { getAuthUrl(file.url, 'download').then(setSrc) }, [file.url])
return (
<button onClick={onClick} style={{
width: 48, height: 48, borderRadius: 6, overflow: 'hidden', border: active ? '2px solid #fff' : '2px solid transparent',
opacity: active ? 1 : 0.5, cursor: 'pointer', padding: 0, background: '#111', flexShrink: 0, transition: 'opacity 0.15s',
}}>
{src && <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />}
</button>
)
}
// Authenticated image — fetches a short-lived download token and renders the image
function AuthedImg({ src, style }: { src: string; style?: React.CSSProperties }) {
const [authSrc, setAuthSrc] = useState('')
useEffect(() => {
getAuthUrl(src, 'download').then(setAuthSrc)
}, [src])
return authSrc ? <img src={authSrc} alt="" style={style} /> : null
}
// Source badge
interface SourceBadgeProps {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
@@ -148,11 +241,13 @@ interface FileManagerProps {
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
const [uploading, setUploading] = useState(false)
const [filterType, setFilterType] = useState('all')
const [lightboxFile, setLightboxFile] = useState(null)
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
const [showTrash, setShowTrash] = useState(false)
const [trashFiles, setTrashFiles] = useState<TripFile[]>([])
const [loadingTrash, setLoadingTrash] = useState(false)
const toast = useToast()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const { t, locale } = useTranslation()
const loadTrash = useCallback(async () => {
@@ -247,6 +342,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
})
const handlePaste = useCallback((e) => {
if (!can('file_upload', trip)) return
const items = e.clipboardData?.items
if (!items) return
const pastedFiles = []
@@ -281,6 +377,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
}
const [previewFile, setPreviewFile] = useState(null)
const [previewFileUrl, setPreviewFileUrl] = useState('')
useEffect(() => {
if (previewFile) {
getAuthUrl(previewFile.url, 'download').then(setPreviewFileUrl)
} else {
setPreviewFileUrl('')
}
}, [previewFile?.url])
const [assignFileId, setAssignFileId] = useState<number | null>(null)
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
@@ -292,9 +396,12 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
}
}
const imageFiles = filteredFiles.filter(f => isImage(f.mime_type))
const openFile = (file) => {
if (isImage(file.mime_type)) {
setLightboxFile(file)
const idx = imageFiles.findIndex(f => f.id === file.id)
setLightboxIndex(idx >= 0 ? idx : 0)
} else {
setPreviewFile(file)
}
@@ -311,8 +418,6 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
if (file.reservation_id) allLinkedResIds.add(file.reservation_id)
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid)
const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean)
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
return (
<div key={file.id} style={{
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
@@ -326,7 +431,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
>
{/* Icon or thumbnail */}
<div
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
onClick={() => !isTrash && openFile(file)}
style={{
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
@@ -334,7 +439,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
}}
>
{isImage(file.mime_type)
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
? <AuthedImg src={file.url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: (() => {
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
const isPdf = file.mime_type === 'application/pdf'
@@ -355,7 +460,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
)}
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
<span
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
onClick={() => !isTrash && openFile(file)}
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }}
>
{file.original_name}
@@ -386,14 +491,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{isTrash ? (
<>
<button onClick={() => handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
{can('file_delete', trip) && <button onClick={() => handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#22c55e'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<RotateCcw size={14} />
</button>
<button onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
</button>}
{can('file_delete', trip) && <button onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={14} />
</button>
</button>}
</>
) : (
<>
@@ -401,18 +506,18 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
onMouseEnter={e => { if (!file.starred) e.currentTarget.style.color = '#facc15' }} onMouseLeave={e => { if (!file.starred) e.currentTarget.style.color = 'var(--text-faint)' }}>
<Star size={14} fill={file.starred ? '#facc15' : 'none'} />
</button>
<button onClick={() => setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
{can('file_edit', trip) && <button onClick={() => setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={14} />
</button>
<button onClick={() => openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
</button>}
<button onClick={() => openFile(file)} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ExternalLink size={14} />
</button>
<button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
{can('file_delete', trip) && <button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={14} />
</button>
</button>}
</>
)}
</div>
@@ -423,7 +528,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
return (
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} onPaste={handlePaste} tabIndex={-1}>
{/* Lightbox */}
{lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />}
{lightboxIndex !== null && <ImageLightbox files={imageFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
{/* Assign modal */}
{assignFileId && ReactDOM.createPortal(
@@ -622,12 +727,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noreferrer"
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
<button
onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
<ExternalLink size={13} /> {t('files.openTab')}
</a>
</button>
<button onClick={() => setPreviewFile(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
@@ -637,13 +743,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</div>
</div>
<object
data={`${previewFile.url || `/uploads/files/${previewFile.filename}`}#view=FitH`}
data={previewFileUrl ? `${previewFileUrl}#view=FitH` : undefined}
type="application/pdf"
style={{ flex: 1, width: '100%', border: 'none' }}
title={previewFile.original_name}
>
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>PDF herunterladen</a>
<button onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>PDF herunterladen</button>
</p>
</object>
</div>
@@ -675,7 +781,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{showTrash ? (
/* Trash view */
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
{trashFiles.length > 0 && (
{trashFiles.length > 0 && can('file_delete', trip) && (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
<button onClick={handleEmptyTrash} style={{
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
@@ -704,7 +810,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
) : (
<>
{/* Upload zone */}
<div
{can('file_upload', trip) && <div
{...getRootProps()}
style={{
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
@@ -729,7 +835,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</p>
</>
)}
</div>
</div>}
{/* Filter tabs */}
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
@@ -118,6 +118,70 @@ const texts: Record<string, DemoTexts> = {
selfHostLink: 'alójalo tú mismo',
close: 'Entendido',
},
zh: {
titleBefore: '欢迎来到 ',
titleAfter: '',
title: '欢迎来到 TREK 演示版',
description: '你可以查看、编辑和创建旅行。所有更改都会在每小时自动重置。',
resetIn: '下次重置将在',
minutes: '分钟后',
uploadNote: '演示模式下已禁用文件上传(照片、文档、封面)。',
fullVersionTitle: '完整版本还包括:',
features: [
'文件上传(照片、文档、封面)',
'API 密钥管理(Google Maps、天气)',
'用户和权限管理',
'自动备份',
'附加组件管理(启用/禁用)',
'OIDC / SSO 单点登录',
],
addonsTitle: '模块化附加组件(完整版本可禁用)',
addons: [
['Vacay', '带日历、节假日和用户融合的假期规划器'],
['Atlas', '带已访问国家和旅行统计的世界地图'],
['Packing', '按旅行管理清单'],
['Budget', '支持分摊的费用追踪'],
['Documents', '将文件附加到旅行'],
['Widgets', '货币换算和时区工具'],
],
whatIs: '什么是 TREK',
whatIsDesc: '一个支持实时协作、交互式地图、OIDC 登录和深色模式的自托管旅行规划器。',
selfHost: '开源项目 - ',
selfHostLink: '自行部署',
close: '知道了',
},
'zh-TW': {
titleBefore: '歡迎來到 ',
titleAfter: '',
title: '歡迎來到 TREK 展示版',
description: '你可以檢視、編輯和建立行程。所有變更都會在每小時自動重設。',
resetIn: '下次重設將在',
minutes: '分鐘後',
uploadNote: '展示模式下已停用檔案上傳(照片、文件、封面)。',
fullVersionTitle: '完整版本還包含:',
features: [
'檔案上傳(照片、文件、封面)',
'API 金鑰管理(Google Maps、天氣)',
'使用者與權限管理',
'自動備份',
'附加元件管理(啟用/停用)',
'OIDC / SSO 單一登入',
],
addonsTitle: '模組化附加元件(完整版本可停用)',
addons: [
['Vacay', '具備日曆、假日與使用者融合的假期規劃器'],
['Atlas', '顯示已造訪國家與旅行統計的世界地圖'],
['Packing', '依行程管理的檢查清單'],
['Budget', '支援分攤的費用追蹤'],
['Documents', '將檔案附加到行程'],
['Widgets', '貨幣換算與時區工具'],
],
whatIs: 'TREK 是什麼?',
whatIsDesc: '一個支援即時協作、互動式地圖、OIDC 登入和深色模式的自架旅行規劃器。',
selfHost: '開源專案 - ',
selfHostLink: '自行架設',
close: '知道了',
},
ar: {
titleBefore: 'مرحبًا بك في ',
titleAfter: '',
@@ -0,0 +1,171 @@
import React, { useState, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { useNavigate } from 'react-router-dom'
import { Bell, Trash2, CheckCheck } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts'
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
export default function InAppNotificationBell(): React.ReactElement {
const { t } = useTranslation()
const navigate = useNavigate()
const { settings } = useSettingsStore()
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const isAuthenticated = useAuthStore(s => s.isAuthenticated)
const { notifications, unreadCount, isLoading, fetchNotifications, fetchUnreadCount, markAllRead, deleteAll } = useInAppNotificationStore()
const [open, setOpen] = useState(false)
useEffect(() => {
if (isAuthenticated) {
fetchUnreadCount()
}
}, [isAuthenticated])
const handleOpen = () => {
if (!open) {
fetchNotifications(true)
}
setOpen(v => !v)
}
const handleShowAll = () => {
setOpen(false)
navigate('/notifications')
}
const displayCount = unreadCount > 99 ? '99+' : unreadCount
return (
<div className="relative flex-shrink-0">
<button
onClick={handleOpen}
title={t('notifications.title')}
className="relative p-2 rounded-lg transition-colors"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Bell className="w-4 h-4" />
{unreadCount > 0 && (
<span
className="absolute -top-0.5 -right-0.5 flex items-center justify-center rounded-full text-white font-bold"
style={{
background: '#ef4444',
fontSize: 9,
minWidth: 14,
height: 14,
padding: '0 3px',
lineHeight: 1,
}}
>
{displayCount}
</span>
)}
</button>
{open && ReactDOM.createPortal(
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
<div
className="rounded-xl shadow-xl border overflow-hidden"
style={{
position: 'fixed',
top: 'var(--nav-h)',
right: 8,
width: 360,
maxWidth: 'calc(100vw - 16px)',
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
zIndex: 9999,
background: 'var(--bg-card)',
borderColor: 'var(--border-primary)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 flex-shrink-0"
style={{ borderBottom: '1px solid var(--border-secondary)' }}
>
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('notifications.title')}
{unreadCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 rounded-full text-xs font-medium"
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
{unreadCount}
</span>
)}
</span>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<button
onClick={markAllRead}
title={t('notifications.markAllRead')}
className="p-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<CheckCheck className="w-3.5 h-3.5" />
</button>
)}
{notifications.length > 0 && (
<button
onClick={deleteAll}
title={t('notifications.deleteAll')}
className="p-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Notification list */}
<div className="overflow-y-auto flex-1">
{isLoading && notifications.length === 0 ? (
<div className="flex items-center justify-center py-10">
<div className="w-5 h-5 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 px-4 text-center gap-2">
<Bell className="w-8 h-8" style={{ color: 'var(--text-faint)' }} />
<p className="text-sm font-medium" style={{ color: 'var(--text-muted)' }}>{t('notifications.empty')}</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('notifications.emptyDescription')}</p>
</div>
) : (
notifications.slice(0, 10).map(n => (
<InAppNotificationItem key={n.id} notification={n} onClose={() => setOpen(false)} />
))
)}
</div>
{/* Footer */}
<button
onClick={handleShowAll}
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0"
style={{
borderTop: '1px solid var(--border-secondary)',
color: 'var(--text-primary)',
background: 'transparent',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
{t('notifications.showAll')}
</button>
</div>
</>,
document.body
)}
</div>
)
}
+28 -22
View File
@@ -3,10 +3,11 @@ import ReactDOM from 'react-dom'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useTranslation } from '../../i18n'
import { addonsApi } from '../../api/client'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx'
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe }
@@ -28,29 +29,21 @@ interface Addon {
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
const { user, logout } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const { addons: allAddons, loadAddons } = useAddonStore()
const { t, locale } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
const [appVersion, setAppVersion] = useState<string | null>(null)
const [globalAddons, setGlobalAddons] = useState<Addon[]>([])
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const loadAddons = () => {
if (user) {
addonsApi.enabled().then(data => {
setGlobalAddons(data.addons.filter(a => a.type === 'global'))
}).catch(() => {})
}
}
useEffect(loadAddons, [user, location.pathname])
// Listen for addon changes from AddonManager
// Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page
const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled)
useEffect(() => {
const handler = () => loadAddons()
window.addEventListener('addons-changed', handler)
return () => window.removeEventListener('addons-changed', handler)
}, [user])
if (user) loadAddons()
}, [user, location.pathname])
useEffect(() => {
import('../../api/client').then(({ authApi }) => {
@@ -60,7 +53,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
const handleLogout = () => {
logout()
navigate('/login')
navigate('/login', { state: { noRedirect: true } })
}
const toggleDarkMode = () => {
@@ -140,7 +133,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{tripTitle && (
<>
<span className="hidden sm:inline" style={{ color: 'var(--text-faint)' }}>/</span>
<span className="text-sm font-medium truncate max-w-48" style={{ color: 'var(--text-muted)' }}>
<span className="hidden sm:inline text-sm font-medium truncate max-w-48" style={{ color: 'var(--text-muted)' }}>
{tripTitle}
</span>
</>
@@ -162,15 +155,19 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
</button>
)}
{/* Dark mode toggle (light ↔ dark, overrides auto) */}
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
className="p-2 rounded-lg transition-colors flex-shrink-0"
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
{/* Notification bell — only in trip view on mobile, everywhere on desktop */}
{user && tripId && <InAppNotificationBell />}
{user && !tripId && <span className="hidden sm:block"><InAppNotificationBell /></span>}
{/* User menu */}
{user && (
<div className="relative">
@@ -236,9 +233,18 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
</button>
{appVersion && (
<div className="px-4 pt-2 pb-2.5 text-center" style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
</div>
<a href="https://discord.gg/nSdKaXgN" target="_blank" rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: 99, background: 'var(--bg-tertiary)', transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = '#5865F220'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
title="Discord">
<svg width="12" height="12" viewBox="0 0 24 24" fill="var(--text-faint)"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
</a>
</div>
</div>
)}
+182 -121
View File
@@ -1,12 +1,20 @@
import { useEffect, useRef, useState, useMemo, useCallback } from 'react'
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
import DOM from 'react-dom'
import { renderToStaticMarkup } from 'react-dom/server'
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import L from 'leaflet'
import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
import { mapsApi } from '../../api/client'
import { getCategoryIcon } from '../shared/categoryIcons'
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
try {
return renderToStaticMarkup(createElement(IconComponent, { size, color: 'white', strokeWidth: 2.5 }))
} catch { return '' }
}
import type { Place } from '../../types'
// Fix default marker icons for vite
@@ -26,7 +34,12 @@ function escAttr(s) {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
const iconCache = new Map<string, L.DivIcon>()
function createPlaceIcon(place, orderNumbers, isSelected) {
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}`
const cached = iconCache.get(cacheKey)
if (cached) return cached
const size = isSelected ? 44 : 36
const borderColor = isSelected ? '#111827' : 'white'
const borderWidth = isSelected ? 3 : 2.5
@@ -34,9 +47,8 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
: '0 2px 8px rgba(0,0,0,0.22)'
const bgColor = place.category_color || '#6b7280'
const icon = place.category_icon || '📍'
// Number badges (bottom-right), supports multiple numbers for duplicate places
// Number badges (bottom-right)
let badgeHtml = ''
if (orderNumbers && orderNumbers.length > 0) {
const label = orderNumbers.join(' · ')
@@ -54,18 +66,22 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
">${label}</span>`
}
if (place.image_url) {
return L.divIcon({
// Base64 data URL thumbnails — no external image fetch during zoom
// Only use base64 data URLs for markers — external URLs cause zoom lag
if (place.image_url && place.image_url.startsWith('data:')) {
const imgIcon = L.divIcon({
className: '',
html: `<div style="
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
box-shadow:${shadow};
overflow:visible;background:${bgColor};
cursor:pointer;flex-shrink:0;position:relative;
width:${size}px;height:${size}px;
cursor:pointer;position:relative;
">
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
<img src="${escAttr(place.image_url)}" loading="lazy" style="width:100%;height:100%;object-fit:cover;" />
<div style="
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
box-shadow:${shadow};
overflow:hidden;background:${bgColor};
">
<img src="${place.image_url}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
</div>
${badgeHtml}
</div>`,
@@ -73,9 +89,11 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
iconAnchor: [size / 2, size / 2],
tooltipAnchor: [size / 2 + 6, 0],
})
iconCache.set(cacheKey, imgIcon)
return imgIcon
}
return L.divIcon({
const fallbackIcon = L.divIcon({
className: '',
html: `<div style="
width:${size}px;height:${size}px;border-radius:50%;
@@ -84,14 +102,17 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
background:${bgColor};
display:flex;align-items:center;justify-content:center;
cursor:pointer;position:relative;
will-change:transform;contain:layout style;
">
<span style="font-size:${isSelected ? 18 : 15}px;line-height:1;">${icon}</span>
${categoryIconSvg(place.category_icon, isSelected ? 18 : 15)}
${badgeHtml}
</div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
tooltipAnchor: [size / 2 + 6, 0],
})
iconCache.set(cacheKey, fallbackIcon)
return fallbackIcon
}
interface SelectionControllerProps {
@@ -140,12 +161,13 @@ function MapController({ center, zoom }: MapControllerProps) {
// Fit bounds when places change (fitKey triggers re-fit)
interface BoundsControllerProps {
hasDayDetail?: boolean
places: Place[]
fitKey: number
paddingOpts: Record<string, number>
}
function BoundsController({ places, fitKey, paddingOpts }: BoundsControllerProps) {
function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsControllerProps) {
const map = useMap()
const prevFitKey = useRef(-1)
@@ -155,9 +177,14 @@ function BoundsController({ places, fitKey, paddingOpts }: BoundsControllerProps
if (places.length === 0) return
try {
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
if (bounds.isValid()) map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
if (bounds.isValid()) {
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
if (hasDayDetail) {
setTimeout(() => map.panBy([0, 150], { animate: true }), 300)
}
}
} catch {}
}, [fitKey, places, paddingOpts, map])
}, [fitKey, places, paddingOpts, map, hasDayDetail])
return null
}
@@ -166,6 +193,16 @@ interface MapClickHandlerProps {
onClick: ((e: L.LeafletMouseEvent) => void) | null
}
function ZoomTracker({ onZoomStart, onZoomEnd }: { onZoomStart: () => void; onZoomEnd: () => void }) {
const map = useMap()
useEffect(() => {
map.on('zoomstart', onZoomStart)
map.on('zoomend', onZoomEnd)
return () => { map.off('zoomstart', onZoomStart); map.off('zoomend', onZoomEnd) }
}, [map, onZoomStart, onZoomEnd])
return null
}
function MapClickHandler({ onClick }: MapClickHandlerProps) {
const map = useMap()
useEffect(() => {
@@ -237,8 +274,7 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
}
// Module-level photo cache shared with PlaceAvatar
const mapPhotoCache = new Map()
const mapPhotoInFlight = new Set()
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
function LocationTracker() {
@@ -330,7 +366,7 @@ function LocationTracker() {
)
}
export function MapView({
export const MapView = memo(function MapView({
places = [],
dayPlaces = [],
route = null,
@@ -347,65 +383,122 @@ export function MapView({
leftWidth = 0,
rightWidth = 0,
hasInspector = false,
hasDayDetail = false,
}) {
// Dynamic padding: account for sidebars + bottom inspector
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
const paddingOpts = useMemo(() => {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
if (isMobile) return { padding: [40, 20] }
const top = 60
const bottom = hasInspector ? 320 : 60
const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60
const left = leftWidth + 40
const right = rightWidth + 40
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
}, [leftWidth, rightWidth, hasInspector])
const [photoUrls, setPhotoUrls] = useState({})
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
// Fetch photos for places with concurrency limit to avoid blocking map rendering
// photoUrls: only base64 thumbs for smooth map zoom
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
// Fetch photos via shared service — subscribe to thumb (base64) availability
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
useEffect(() => {
const queue = places.filter(place => {
if (place.image_url) return false
if (!places || places.length === 0) return
const cleanups: (() => void)[] = []
const setThumb = (cacheKey: string, thumb: string) => {
iconCache.clear()
setPhotoUrls(prev => prev[cacheKey] === thumb ? prev : { ...prev, [cacheKey]: thumb })
}
for (const place of places) {
if (place.image_url && place.image_url.startsWith('data:')) continue
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
if (!cacheKey) return false
if (mapPhotoCache.has(cacheKey)) {
const cached = mapPhotoCache.get(cacheKey)
if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached }))
return false
if (!cacheKey) continue
const cached = getCached(cacheKey)
if (cached?.thumbDataUrl) {
setThumb(cacheKey, cached.thumbDataUrl)
continue
}
if (mapPhotoInFlight.has(cacheKey)) return false
const photoId = place.google_place_id || place.osm_id
if (!photoId && !(place.lat && place.lng)) return false
return true
})
let active = 0
const MAX_CONCURRENT = 3
let idx = 0
// Subscribe for when thumb becomes available
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
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}`
// Always fetch through API — returns fresh URL + converts to base64
if (!cached && !isLoading(cacheKey)) {
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() })
if (photoId || (place.lat && place.lng)) {
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
}
}
}
fetchNext()
}, [places])
return () => cleanups.forEach(fn => fn())
}, [placeIds])
const clusterIconCreateFunction = useCallback((cluster) => {
const count = cluster.getChildCount()
const size = count < 10 ? 36 : count < 50 ? 42 : 48
return L.divIcon({
html: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
className: 'marker-cluster-wrapper',
iconSize: L.point(size, size),
})
}, [])
const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
const markers = useMemo(() => places.map((place) => {
const isSelected = place.id === selectedPlaceId
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
const resolvedPhoto = (pck && photoUrls[pck]) || (place.image_url?.startsWith('data:') ? place.image_url : null) || null
const orderNumbers = dayOrderMap[place.id] ?? null
const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected)
return (
<Marker
key={place.id}
position={[place.lat, place.lng]}
icon={icon}
eventHandlers={{
click: () => onMarkerClick && onMarkerClick(place.id),
}}
zIndexOffset={isSelected ? 1000 : 0}
>
<Tooltip
direction="right"
offset={[0, 0]}
opacity={1}
className="map-tooltip"
permanent={isTouchDevice && isSelected}
>
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
{place.name}
</div>
{place.category_name && (() => {
const CatIcon = getCategoryIcon(place.category_icon)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
</div>
)
})()}
{place.address && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{place.address}
</div>
)}
</div>
</Tooltip>
</Marker>
)
}), [places, selectedPlaceId, dayOrderMap, photoUrls, onMarkerClick, isTouchDevice])
return (
<MapContainer
id="trek-map"
center={center}
zoom={zoom}
zoomControl={false}
@@ -416,10 +509,14 @@ export function MapView({
url={tileUrl}
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
maxZoom={19}
keepBuffer={8}
updateWhenZooming={false}
updateWhenIdle={true}
referrerPolicy="strict-origin-when-cross-origin"
/>
<MapController center={center} zoom={zoom} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} hasDayDetail={hasDayDetail} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
@@ -427,71 +524,17 @@ export function MapView({
<MarkerClusterGroup
chunkedLoading
chunkInterval={30}
chunkDelay={0}
maxClusterRadius={30}
disableClusteringAtZoom={11}
spiderfyOnMaxZoom
showCoverageOnHover={false}
zoomToBoundsOnClick
singleMarkerMode
iconCreateFunction={(cluster) => {
const count = cluster.getChildCount()
const size = count < 10 ? 36 : count < 50 ? 42 : 48
return L.divIcon({
html: `<div class="marker-cluster-custom"
style="width:${size}px;height:${size}px;">
<span>${count}</span>
</div>`,
className: 'marker-cluster-wrapper',
iconSize: L.point(size, size),
})
}}
animate={false}
iconCreateFunction={clusterIconCreateFunction}
>
{places.map((place) => {
const isSelected = place.id === selectedPlaceId
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
const resolvedPhotoUrl = place.image_url || (cacheKey && photoUrls[cacheKey]) || null
const orderNumbers = dayOrderMap[place.id] ?? null
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected)
return (
<Marker
key={place.id}
position={[place.lat, place.lng]}
icon={icon}
eventHandlers={{
click: () => onMarkerClick && onMarkerClick(place.id),
}}
zIndexOffset={isSelected ? 1000 : 0}
>
<Tooltip
direction="right"
offset={[0, 0]}
opacity={1}
className="map-tooltip"
>
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
{place.name}
</div>
{place.category_name && (() => {
const CatIcon = getCategoryIcon(place.category_icon)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
</div>
)
})()}
{place.address && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{place.address}
</div>
)}
</div>
</Tooltip>
</Marker>
)
})}
{markers}
</MarkerClusterGroup>
{route && route.length > 1 && (
@@ -508,6 +551,24 @@ export function MapView({
))}
</>
)}
{/* GPX imported route geometries */}
{places.map((place) => {
if (!place.route_geometry) return null
try {
const coords = JSON.parse(place.route_geometry) as [number, number][]
if (!coords || coords.length < 2) return null
return (
<Polyline
key={`gpx-${place.id}`}
positions={coords}
color={place.category_color || '#3b82f6'}
weight={3.5}
opacity={0.75}
/>
)
} catch { return null }
})}
</MapContainer>
)
}
})
+1 -1
View File
@@ -13,7 +13,7 @@ export async function calculateRoute(
}
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const url = `${OSRM_BASE}/driving/${coords}?overview=full&geometries=geojson&steps=false`
const url = `${OSRM_BASE}/${profile}/${coords}?overview=full&geometries=geojson&steps=false`
const response = await fetch(url, { signal })
if (!response.ok) {
+587 -149
View File
@@ -1,21 +1,47 @@
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 apiClient, { addonsApi } from '../../api/client'
import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen, Info, ChevronLeft, ChevronRight } from 'lucide-react'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { fetchImageAsBlob, clearImageQueue } from '../../api/authUrl'
import { useToast } from '../shared/Toast'
interface PhotoProvider {
id: string
name: string
icon?: string
config?: Record<string, unknown>
}
function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; provider: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
const [src, setSrc] = useState('')
useEffect(() => {
let revoke = ''
fetchImageAsBlob('/api' + baseUrl).then(blobUrl => {
revoke = blobUrl
setSrc(blobUrl)
})
return () => { if (revoke) URL.revokeObjectURL(revoke) }
}, [baseUrl])
return src ? <img src={src} alt="" loading={loading} style={style} /> : null
}
// ── Types ───────────────────────────────────────────────────────────────────
interface TripPhoto {
immich_asset_id: string
asset_id: string
provider: string
user_id: number
username: string
shared: number
added_at: string
city?: string | null
}
interface ImmichAsset {
interface Asset {
id: string
provider: string
takenAt: string
city: string | null
country: string | null
@@ -31,9 +57,13 @@ interface MemoriesPanelProps {
export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPanelProps) {
const { t } = useTranslation()
const toast = useToast()
const currentUser = useAuthStore(s => s.user)
const [connected, setConnected] = useState(false)
const [enabledProviders, setEnabledProviders] = useState<PhotoProvider[]>([])
const [availableProviders, setAvailableProviders] = useState<PhotoProvider[]>([])
const [selectedProvider, setSelectedProvider] = useState<string>('')
const [loading, setLoading] = useState(true)
// Trip photos (saved selections)
@@ -41,7 +71,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
// Photo picker
const [showPicker, setShowPicker] = useState(false)
const [pickerPhotos, setPickerPhotos] = useState<ImmichAsset[]>([])
const [pickerPhotos, setPickerPhotos] = useState<Asset[]>([])
const [pickerLoading, setPickerLoading] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
@@ -52,11 +82,126 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
const [sortAsc, setSortAsc] = useState(true)
const [locationFilter, setLocationFilter] = useState('')
// Album linking
const [showAlbumPicker, setShowAlbumPicker] = useState(false)
const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number }[]>([])
const [albumsLoading, setAlbumsLoading] = useState(false)
const [albumLinks, setAlbumLinks] = useState<{ id: number; provider: string; album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([])
const [syncing, setSyncing] = useState<number | null>(null)
//helpers for building urls
const ADDON_PREFIX = "/integrations/memories"
function buildUnifiedUrl(endpoint: string, lastParam?:string,): string {
return `${ADDON_PREFIX}/unified/trips/${tripId}/${endpoint}${lastParam ? `/${lastParam}` : ''}`;
}
function buildProviderUrl(provider: string, endpoint: string, item?: string): string {
if (endpoint === 'album-link-sync') {
endpoint = `trips/${tripId}/album-links/${item?.toString() || ''}/sync`
}
return `${ADDON_PREFIX}/${provider}/${endpoint}`;
}
function buildProviderAssetUrl(photo: TripPhoto, what: string): string {
return `${ADDON_PREFIX}/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/${what}`
}
function buildProviderAssetUrlFromAsset(asset: Asset, what: string, userId: number): string {
const photo: TripPhoto = {
asset_id: asset.id,
provider: asset.provider,
user_id: userId,
username: '',
shared: 0,
added_at: null
}
return buildProviderAssetUrl(photo, what)
}
const loadAlbumLinks = async () => {
try {
const res = await apiClient.get(buildUnifiedUrl('album-links'))
setAlbumLinks(res.data.links || [])
} catch { setAlbumLinks([]) }
}
const loadAlbums = async (provider: string = selectedProvider) => {
if (!provider) return
setAlbumsLoading(true)
try {
const res = await apiClient.get(buildProviderUrl(provider, 'albums'))
setAlbums(res.data.albums || [])
} catch {
setAlbums([])
toast.error(t('memories.error.loadAlbums'))
} finally {
setAlbumsLoading(false)
}
}
const openAlbumPicker = async () => {
setShowAlbumPicker(true)
await loadAlbums(selectedProvider)
}
const linkAlbum = async (albumId: string, albumName: string) => {
if (!selectedProvider) {
toast.error(t('memories.error.linkAlbum'))
return
}
try {
await apiClient.post(buildUnifiedUrl('album-links'), {
album_id: albumId,
album_name: albumName,
provider: selectedProvider,
})
setShowAlbumPicker(false)
await loadAlbumLinks()
// Auto-sync after linking
const linksRes = await apiClient.get(buildUnifiedUrl('album-links'))
const newLink = (linksRes.data.links || []).find((l: any) => l.album_id === albumId && l.provider === selectedProvider)
if (newLink) await syncAlbum(newLink.id)
} catch { toast.error(t('memories.error.linkAlbum')) }
}
const unlinkAlbum = async (linkId: number) => {
try {
await apiClient.delete(buildUnifiedUrl('album-links', linkId.toString()))
await loadAlbumLinks()
await loadPhotos()
} catch { toast.error(t('memories.error.unlinkAlbum')) }
}
const syncAlbum = async (linkId: number, provider?: string) => {
const targetProvider = provider || selectedProvider
if (!targetProvider) return
setSyncing(linkId)
try {
await apiClient.post(buildProviderUrl(targetProvider, 'album-link-sync', linkId.toString()))
await loadAlbumLinks()
await loadPhotos()
} catch { toast.error(t('memories.error.syncAlbum')) }
finally { setSyncing(null) }
}
// 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)
const [lightboxOriginalSrc, setLightboxOriginalSrc] = useState('')
const [showMobileInfo, setShowMobileInfo] = useState(false)
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768)
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
// ── Init ──────────────────────────────────────────────────────────────────
@@ -73,7 +218,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
const loadPhotos = async () => {
try {
const photosRes = await apiClient.get(`/integrations/immich/trips/${tripId}/photos`)
const photosRes = await apiClient.get(buildUnifiedUrl('photos'))
setTripPhotos(photosRes.data.photos || [])
} catch {
setTripPhotos([])
@@ -83,12 +228,41 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
const loadInitial = async () => {
setLoading(true)
try {
const statusRes = await apiClient.get('/integrations/immich/status')
setConnected(statusRes.data.connected)
const addonsRes = await addonsApi.enabled().catch(() => ({ addons: [] as any[] }))
const enabledAddons = addonsRes?.addons || []
const photoProviders = enabledAddons.filter((a: any) => a.type === 'photo_provider' && a.enabled)
setEnabledProviders(photoProviders.map((a: any) => ({ id: a.id, name: a.name, icon: a.icon, config: a.config })))
// Test connection status for each enabled provider
const statusResults = await Promise.all(
photoProviders.map(async (provider: any) => {
const statusUrl = (provider.config as Record<string, unknown>)?.status_get as string
if (!statusUrl) return { provider, connected: false }
try {
const res = await apiClient.get(statusUrl)
return { provider, connected: !!res.data?.connected }
} catch {
return { provider, connected: false }
}
})
)
const connectedProviders = statusResults
.filter(r => r.connected)
.map(r => ({ id: r.provider.id, name: r.provider.name, icon: r.provider.icon, config: r.provider.config }))
setAvailableProviders(connectedProviders)
setConnected(connectedProviders.length > 0)
if (connectedProviders.length > 0 && !selectedProvider) {
setSelectedProvider(connectedProviders[0].id)
}
} catch {
setAvailableProviders([])
setConnected(false)
}
await loadPhotos()
await loadAlbumLinks()
setLoading(false)
}
@@ -104,16 +278,38 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
await loadPickerPhotos(!!(startDate && endDate))
}
useEffect(() => {
if (showPicker) {
loadPickerPhotos(pickerDateFilter)
}
}, [selectedProvider])
useEffect(() => {
loadAlbumLinks()
}, [tripId])
useEffect(() => {
if (showAlbumPicker) {
loadAlbums(selectedProvider)
}
}, [showAlbumPicker, selectedProvider, tripId])
const loadPickerPhotos = async (useDate: boolean) => {
setPickerLoading(true)
try {
const res = await apiClient.post('/integrations/immich/search', {
const provider = availableProviders.find(p => p.id === selectedProvider)
if (!provider) {
setPickerPhotos([])
return
}
const res = await apiClient.post(buildProviderUrl(provider.id, 'search'), {
from: useDate && startDate ? startDate : undefined,
to: useDate && endDate ? endDate : undefined,
})
setPickerPhotos(res.data.assets || [])
setPickerPhotos((res.data.assets || []).map((asset: Asset) => ({ ...asset, provider: provider.id })))
} catch {
setPickerPhotos([])
toast.error(t('memories.error.loadPhotos'))
} finally {
setPickerLoading(false)
}
@@ -136,44 +332,59 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
const executeAddPhotos = async () => {
setShowConfirmShare(false)
try {
await apiClient.post(`/integrations/immich/trips/${tripId}/photos`, {
asset_ids: [...selectedIds],
const groupedByProvider = new Map<string, string[]>()
for (const key of selectedIds) {
const [provider, assetId] = key.split('::')
if (!provider || !assetId) continue
const list = groupedByProvider.get(provider) || []
list.push(assetId)
groupedByProvider.set(provider, list)
}
await apiClient.post(buildUnifiedUrl('photos'), {
selections: [...groupedByProvider.entries()].map(([provider, asset_ids]) => ({ provider, asset_ids })),
shared: true,
})
setShowPicker(false)
clearImageQueue()
loadInitial()
} catch {}
} catch { toast.error(t('memories.error.addPhotos')) }
}
// ── Remove photo ──────────────────────────────────────────────────────────
const removePhoto = async (assetId: string) => {
const removePhoto = async (photo: TripPhoto) => {
try {
await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`)
setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId))
} catch {}
await apiClient.delete(buildUnifiedUrl('photos'), {
data: {
asset_id: photo.asset_id,
provider: photo.provider,
},
})
setTripPhotos(prev => prev.filter(p => !(p.provider === photo.provider && p.asset_id === photo.asset_id)))
} catch { toast.error(t('memories.error.removePhoto')) }
}
// ── Toggle sharing ────────────────────────────────────────────────────────
const toggleSharing = async (assetId: string, shared: boolean) => {
const toggleSharing = async (photo: TripPhoto, shared: boolean) => {
try {
await apiClient.put(`/integrations/immich/trips/${tripId}/photos/${assetId}/sharing`, { shared })
await apiClient.put(buildUnifiedUrl('photos', 'sharing'), {
shared,
asset_id: photo.asset_id,
provider: photo.provider,
})
setTripPhotos(prev => prev.map(p =>
p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p
p.provider === photo.provider && p.asset_id === photo.asset_id ? { ...p, shared: shared ? 1 : 0 } : p
))
} catch {}
} catch { toast.error(t('memories.error.toggleSharing')) }
}
// ── 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 makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}`
const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared)
@@ -213,10 +424,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
<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')}
{t('memories.notConnected', { provider_name: enabledProviders.length === 1 ? enabledProviders[0]?.name : 'Photo provider' })}
</h3>
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)', maxWidth: 300 }}>
{t('memories.notConnectedHint')}
{enabledProviders.length === 1 ? t('memories.notConnectedHint', { provider_name: enabledProviders[0]?.name }) : t('memories.notConnectedMultipleHint', { provider_names: enabledProviders.map(p => p.name).join(', ') })}
</p>
</div>
)
@@ -224,8 +435,109 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
// ── Photo Picker Modal ────────────────────────────────────────────────────
const ProviderTabs = () => {
if (availableProviders.length < 2) return null
return (
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
{availableProviders.map(provider => (
<button
key={provider.id}
onClick={() => setSelectedProvider(provider.id)}
style={{
padding: '6px 12px',
borderRadius: 99,
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
fontFamily: 'inherit',
border: '1px solid',
transition: 'all 0.15s',
background: selectedProvider === provider.id ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: selectedProvider === provider.id ? 'var(--text-primary)' : 'var(--border-primary)',
color: selectedProvider === provider.id ? 'var(--bg-primary)' : 'var(--text-muted)',
textTransform: 'capitalize',
}}
>
{provider.name}
</button>
))}
</div>
)
}
// ── Album Picker Modal ──────────────────────────────────────────────────
if (showAlbumPicker) {
const linkedIds = new Set(albumLinks.map(l => l.album_id))
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border-secondary)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
{availableProviders.length > 1 ? t('memories.selectAlbumMultiple') : t('memories.selectAlbum', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })}
</h3>
<button onClick={() => setShowAlbumPicker(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>
</div>
<ProviderTabs />
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
{albumsLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<div style={{ width: 24, height: 24, border: '2px solid var(--border-primary)', borderTopColor: 'var(--text-primary)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto' }} />
</div>
) : albums.length === 0 ? (
<p style={{ textAlign: 'center', padding: 40, fontSize: 13, color: 'var(--text-faint)' }}>
{t('memories.noAlbums')}
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{albums.map(album => {
const isLinked = linkedIds.has(album.id)
return (
<button key={album.id} onClick={() => !isLinked && linkAlbum(album.id, album.albumName)}
disabled={isLinked}
style={{
display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px',
borderRadius: 10, border: 'none', cursor: isLinked ? 'default' : 'pointer',
background: isLinked ? 'var(--bg-tertiary)' : 'transparent', fontFamily: 'inherit', textAlign: 'left',
opacity: isLinked ? 0.5 : 1,
}}
onMouseEnter={e => { if (!isLinked) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!isLinked) e.currentTarget.style.background = 'transparent' }}
>
<FolderOpen size={20} color="var(--text-muted)" />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{album.albumName}</div>
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>
{album.assetCount} {t('memories.photos')}
</div>
</div>
{isLinked ? (
<Check size={16} color="var(--text-faint)" />
) : (
<Link2 size={16} color="var(--text-muted)" />
)}
</button>
)
})}
</div>
)}
</div>
</div>
)
}
// ── Photo Picker Modal ────────────────────────────────────────────────────
if (showPicker) {
const alreadyAdded = new Set(tripPhotos.filter(p => p.user_id === currentUser?.id).map(p => p.immich_asset_id))
const alreadyAdded = new Set(
tripPhotos
.filter(p => p.user_id === currentUser?.id)
.map(p => makePickerKey(p.provider, p.asset_id))
)
return (
<>
@@ -234,10 +546,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
<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')}
{availableProviders.length > 1 ? t('memories.selectPhotosMultiple') : t('memories.selectPhotos', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })}
</h3>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => setShowPicker(false)}
<button onClick={() => { clearImageQueue(); 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>
@@ -252,6 +564,9 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
</button>
</div>
</div>
<div style={{ marginBottom: 10 }}>
<ProviderTabs />
</div>
{/* Filter tabs */}
<div style={{ display: 'flex', gap: 6 }}>
{startDate && endDate && (
@@ -295,10 +610,17 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
<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>
{
pickerDateFilter && (
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: '0 0 16px' }}>
{t('memories.noPhotosHint', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })}
</p>
)
}
</div>
) : (() => {
// Group photos by month
const byMonth: Record<string, ImmichAsset[]> = {}
const byMonth: Record<string, Asset[]> = {}
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'
@@ -316,11 +638,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
</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)
const pickerKey = makePickerKey(asset.provider, asset.id)
const isSelected = selectedIds.has(pickerKey)
const isAlready = alreadyAdded.has(pickerKey)
return (
<div key={asset.id}
onClick={() => !isAlready && togglePickerSelect(asset.id)}
<div key={pickerKey}
onClick={() => !isAlready && togglePickerSelect(pickerKey)}
style={{
position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden',
cursor: isAlready ? 'default' : 'pointer',
@@ -328,7 +651,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
outlineOffset: -3,
}}>
<img src={thumbnailUrl(asset.id, currentUser!.id)} alt="" loading="lazy"
<ProviderImg baseUrl={buildProviderAssetUrlFromAsset(asset, 'thumbnail', currentUser!.id)} provider={asset.provider} loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
{isSelected && (
<div style={{
@@ -404,16 +727,52 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
</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 style={{ display: 'flex', gap: 6 }}>
<button onClick={openAlbumPicker}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 10,
border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-muted)',
fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
}}>
<Link2 size={13} /> {t('memories.linkAlbum')}
</button>
<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>
{/* Linked Albums */}
{albumLinks.length > 0 && (
<div style={{ padding: '8px 20px', borderBottom: '1px solid var(--border-secondary)', display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{albumLinks.map(link => (
<div key={link.id} style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 8,
background: 'var(--bg-tertiary)', fontSize: 11, color: 'var(--text-muted)',
}}>
<FolderOpen size={11} />
<span style={{ fontWeight: 500 }}>{link.album_name}</span>
{link.username !== currentUser?.username && <span style={{ color: 'var(--text-faint)' }}>({link.username})</span>}
<button onClick={() => syncAlbum(link.id, link.provider)} disabled={syncing === link.id} title={t('memories.syncAlbum')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: 'var(--text-faint)' }}>
<RefreshCw size={11} style={{ animation: syncing === link.id ? 'spin 1s linear infinite' : 'none' }} />
</button>
{link.user_id === currentUser?.id && (
<button onClick={() => unlinkAlbum(link.id)} title={t('memories.unlinkAlbum')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: 'var(--text-faint)' }}>
<X size={11} />
</button>
)}
</div>
))}
</div>
)}
</div>
{/* Filter & Sort bar */}
@@ -446,12 +805,9 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{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' }}>
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 12px' }}>
{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,
@@ -466,16 +822,19 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{allVisible.map(photo => {
const isOwn = photo.user_id === currentUser?.id
return (
<div key={photo.immich_asset_id} className="group"
<div key={`${photo.provider}:${photo.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)
setLightboxId(photo.asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('')
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
setLightboxInfoLoading(true)
apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`)
apiClient.get(buildProviderAssetUrl(photo, 'info'))
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
}}>
<img src={thumbnailUrl(photo.immich_asset_id, photo.user_id)} alt="" loading="lazy"
<ProviderImg baseUrl={buildProviderAssetUrl(photo, 'thumbnail')} provider={photo.provider} loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10 }} />
{/* Other user's avatar */}
@@ -506,7 +865,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{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) }}
<button onClick={e => { e.stopPropagation(); toggleSharing(photo, !photo.shared) }}
title={photo.shared ? t('memories.stopSharing') : t('memories.sharePhotos')}
style={{
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
@@ -515,7 +874,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
}}>
{photo.shared ? <Eye size={12} color="white" /> : <EyeOff size={12} color="white" />}
</button>
<button onClick={e => { e.stopPropagation(); removePhoto(photo.immich_asset_id) }}
<button onClick={e => { e.stopPropagation(); removePhoto(photo) }}
style={{
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
@@ -576,117 +935,196 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
)}
{/* 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' }}
/>
{lightboxId && lightboxUserId && (() => {
const closeLightbox = () => {
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('')
setLightboxId(null)
setLightboxUserId(null)
setShowMobileInfo(false)
}
{/* 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 && (
const currentIdx = allVisible.findIndex(p => p.asset_id === lightboxId)
const hasPrev = currentIdx > 0
const hasNext = currentIdx < allVisible.length - 1
const navigateTo = (idx: number) => {
const photo = allVisible[idx]
if (!photo) return
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('')
setLightboxId(photo.asset_id)
setLightboxUserId(photo.user_id)
setLightboxInfo(null)
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
setLightboxInfoLoading(true)
apiClient.get(buildProviderAssetUrl(photo, 'info'))
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
}
const exifContent = lightboxInfo ? (
<>
{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>
)}
{(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>
)}
{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>
)}
{(lightboxInfo.focalLength || lightboxInfo.aperture || lightboxInfo.iso) && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{lightboxInfo.focalLength && (
<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 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>
)}
{/* Location */}
{(lightboxInfo.city || lightboxInfo.country) && (
{lightboxInfo.aperture && (
<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 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>
)}
{/* Camera */}
{lightboxInfo.camera && (
{lightboxInfo.shutter && (
<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 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>
)}
{/* 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>
)}
{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>
)}
{(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>
)}
</>
) : null
{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' }} />
return (
<div onClick={closeLightbox}
onKeyDown={e => { if (e.key === 'ArrowLeft' && hasPrev) navigateTo(currentIdx - 1); if (e.key === 'ArrowRight' && hasNext) navigateTo(currentIdx + 1); if (e.key === 'Escape') closeLightbox() }}
tabIndex={0} ref={el => el?.focus()}
onTouchStart={e => (e.currentTarget as any)._touchX = e.touches[0].clientX}
onTouchEnd={e => { const start = (e.currentTarget as any)._touchX; if (start == null) return; const diff = e.changedTouches[0].clientX - start; if (diff > 60 && hasPrev) navigateTo(currentIdx - 1); else if (diff < -60 && hasNext) navigateTo(currentIdx + 1) }}
style={{
position: 'absolute', inset: 0, zIndex: 100, outline: 'none',
background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{/* Close button */}
<button onClick={closeLightbox}
style={{
position: 'absolute', top: 16, right: 16, zIndex: 10, 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>
{/* Counter */}
{allVisible.length > 1 && (
<div style={{ position: 'absolute', top: 20, left: 20, zIndex: 10, fontSize: 12, color: 'rgba(255,255,255,0.5)' }}>
{currentIdx + 1} / {allVisible.length}
</div>
)}
{/* Prev/Next buttons */}
{hasPrev && (
<button onClick={e => { e.stopPropagation(); navigateTo(currentIdx - 1) }}
style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', zIndex: 10, background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: 40, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: 'rgba(255,255,255,0.8)' }}>
<ChevronLeft size={22} />
</button>
)}
{hasNext && (
<button onClick={e => { e.stopPropagation(); navigateTo(currentIdx + 1) }}
style={{ position: 'absolute', right: isMobile ? 12 : 280, top: '50%', transform: 'translateY(-50%)', zIndex: 10, background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: 40, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: 'rgba(255,255,255,0.8)' }}>
<ChevronRight size={22} />
</button>
)}
{/* Mobile info toggle button */}
{isMobile && (lightboxInfo || lightboxInfoLoading) && (
<button onClick={e => { e.stopPropagation(); setShowMobileInfo(prev => !prev) }}
style={{
position: 'absolute', top: 16, right: 68, zIndex: 10, width: 40, height: 40, borderRadius: '50%',
background: showMobileInfo ? 'rgba(255,255,255,0.25)' : 'rgba(255,255,255,0.1)',
border: 'none', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Info size={20} color="white" />
</button>
)}
<div onClick={e => { if (e.target === e.currentTarget) closeLightbox() }} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}>
<img
src={lightboxOriginalSrc}
alt=""
onClick={e => e.stopPropagation()}
style={{ maxWidth: (!isMobile && lightboxInfo) ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }}
/>
{/* Desktop info panel — liquid glass */}
{!isMobile && 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',
}}>
{exifContent}
</div>
)}
{!isMobile && 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>
{/* Mobile bottom sheet */}
{isMobile && showMobileInfo && lightboxInfo && (
<div onClick={e => e.stopPropagation()} style={{
position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 5,
maxHeight: '60vh', overflowY: 'auto',
borderRadius: '16px 16px 0 0', padding: 18,
background: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
border: '1px solid rgba(255,255,255,0.12)', borderBottom: 'none',
color: 'white', display: 'flex', flexDirection: 'column', gap: 14,
}}>
{exifContent}
</div>
)}
</div>
</div>
)}
)
})()}
</div>
)
}
@@ -0,0 +1,191 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { User, Check, X, ArrowRight, Trash2, CheckCheck } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useInAppNotificationStore, InAppNotification } from '../../store/inAppNotificationStore'
import { useSettingsStore } from '../../store/settingsStore'
function relativeTime(dateStr: string, locale: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return locale === 'ar' ? 'الآن' : 'just now'
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
return `${days}d`
}
interface NotificationItemProps {
notification: InAppNotification
onClose?: () => void
}
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
const { t, locale } = useTranslation()
const navigate = useNavigate()
const { settings } = useSettingsStore()
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const [responding, setResponding] = useState(false)
const { markRead, markUnread, deleteNotification, respondToBoolean } = useInAppNotificationStore()
const handleNavigate = async () => {
if (!notification.is_read) await markRead(notification.id)
if (notification.navigate_target) {
navigate(notification.navigate_target)
onClose?.()
}
}
const handleRespond = async (response: 'positive' | 'negative') => {
if (responding || notification.response !== null) return
setResponding(true)
await respondToBoolean(notification.id, response)
setResponding(false)
}
const titleText = t(notification.title_key, notification.title_params)
const bodyText = t(notification.text_key, notification.text_params)
const hasUnknownTitle = titleText === notification.title_key
const hasUnknownBody = bodyText === notification.text_key
return (
<div
className="relative px-4 py-3 transition-colors"
style={{
background: notification.is_read ? 'transparent' : (dark ? 'rgba(99,102,241,0.07)' : 'rgba(99,102,241,0.05)'),
borderBottom: '1px solid var(--border-secondary)',
}}
>
<div className="flex gap-3 items-start">
{/* Sender avatar */}
<div className="flex-shrink-0 mt-0.5">
{notification.sender_avatar ? (
<img
src={notification.sender_avatar}
alt=""
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold"
style={{ background: dark ? '#27272a' : '#f1f5f9', color: 'var(--text-muted)' }}
>
{notification.sender_username
? notification.sender_username.charAt(0).toUpperCase()
: <User className="w-4 h-4" />
}
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-snug" style={{ color: 'var(--text-primary)' }}>
{hasUnknownTitle ? notification.title_key : titleText}
</p>
<div className="flex items-center gap-0.5 flex-shrink-0">
<span className="text-xs mr-1" style={{ color: 'var(--text-faint)' }}>
{relativeTime(notification.created_at, locale)}
</span>
{!notification.is_read && (
<button
onClick={() => markRead(notification.id)}
title={t('notifications.markRead')}
className="p-1 rounded transition-colors"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
>
<CheckCheck className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => deleteNotification(notification.id)}
title={t('notifications.delete')}
className="p-1 rounded transition-colors"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(239,68,68,0.1)'; e.currentTarget.style.color = '#ef4444' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<p className="text-xs mt-0.5 leading-relaxed" style={{ color: 'var(--text-muted)' }}>
{hasUnknownBody ? notification.text_key : bodyText}
</p>
{/* Boolean actions */}
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
<div className="flex gap-2 mt-2">
<button
onClick={() => handleRespond('positive')}
disabled={responding || notification.response !== null}
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
style={{
background: notification.response === 'positive'
? 'var(--text-primary)'
: notification.response === 'negative'
? (dark ? '#27272a' : '#f1f5f9')
: (dark ? '#27272a' : '#f1f5f9'),
color: notification.response === 'positive'
? '#fff'
: notification.response === 'negative'
? 'var(--text-faint)'
: 'var(--text-secondary)',
opacity: notification.response === 'negative' ? 0.5 : 1,
cursor: notification.response !== null || responding ? 'default' : 'pointer',
}}
>
<Check className="w-3 h-3" />
{t(notification.positive_text_key)}
</button>
<button
onClick={() => handleRespond('negative')}
disabled={responding || notification.response !== null}
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
style={{
background: notification.response === 'negative'
? '#ef4444'
: notification.response === 'positive'
? (dark ? '#27272a' : '#f1f5f9')
: (dark ? '#27272a' : '#f1f5f9'),
color: notification.response === 'negative'
? '#fff'
: notification.response === 'positive'
? 'var(--text-faint)'
: 'var(--text-secondary)',
opacity: notification.response === 'positive' ? 0.5 : 1,
cursor: notification.response !== null || responding ? 'default' : 'pointer',
}}
>
<X className="w-3 h-3" />
{t(notification.negative_text_key)}
</button>
</div>
)}
{/* Navigate action */}
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
<button
onClick={handleNavigate}
className="flex items-center gap-1 mt-2 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
style={{ background: dark ? '#27272a' : '#f1f5f9', color: 'var(--text-secondary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = dark ? '#27272a' : '#f1f5f9'}
>
<ArrowRight className="w-3 h-3" />
{t(notification.navigate_text_key)}
</button>
)}
</div>
</div>
</div>
)
}
+76 -13
View File
@@ -1,22 +1,33 @@
// Trip PDF via browser print window
import { createElement } from 'react'
import { getCategoryIcon } from '../shared/categoryIcons'
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark } from 'lucide-react'
import { mapsApi } from '../../api/client'
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, LucideIcon } from 'lucide-react'
import { accommodationsApi, mapsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
function renderLucideIcon(icon:LucideIcon, props = {}) {
if (!_renderToStaticMarkup) return ''
return _renderToStaticMarkup(
createElement(icon, props)
);
}
const NOTE_ICON_MAP = { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark }
function noteIconSvg(iconId) {
if (!_renderToStaticMarkup) return ''
const Icon = NOTE_ICON_MAP[iconId] || FileText
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' }))
return renderLucideIcon(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' }))
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' })
}
const ACCOMMODATION_ICON_MAP = { accommodation: Hotel, checkin: LogIn, checkout: LogOut, location: MapPin, note: FileText, confirmation: KeyRound }
function accommodationIconSvg(type) {
const Icon = ACCOMMODATION_ICON_MAP[type] || BedDouble
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#03398f', className: 'accommodation-icon' })
}
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
@@ -61,15 +72,15 @@ function categoryIconSvg(iconName, color = '#6366f1', size = 24) {
function shortDate(d, locale) {
if (!d) return ''
return new Date(d + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
}
function longDateRange(days, locale) {
const dd = [...days].filter(d => d.date).sort((a, b) => a.day_number - b.day_number)
if (!dd.length) return null
const f = new Date(dd[0].date + 'T00:00:00')
const l = new Date(dd[dd.length - 1].date + 'T00:00:00')
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long' })} ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })}`
const f = new Date(dd[0].date + 'T00:00:00Z')
const l = new Date(dd[dd.length - 1].date + 'T00:00:00Z')
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long', timeZone: 'UTC' })} ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })}`
}
function dayCost(assignments, dayId, locale) {
@@ -115,6 +126,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number)
const range = longDateRange(sorted, loc)
const coverImg = safeImg(trip?.cover_image)
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
const accommodations = await accommodationsApi.list(trip.id);
// Pre-fetch place photos from Google
const photoMap = await fetchPlacePhotos(assignments)
@@ -223,7 +236,41 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
</div>
</div>`
}).join('')
}).join('')
const accommodationsForDay = (accommodations.accommodations || []).filter(a =>
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
).sort((a, b) => a.start_day_id - b.start_day_id)
const accommodationDetails = accommodationsForDay.map(item => {
const isCheckIn = day.id === item.start_day_id
const isCheckOut = day.id === item.end_day_id
const actionLabel = isCheckIn ? tr('reservations.meta.checkIn')
: isCheckOut ? tr('reservations.meta.checkOut')
: tr('reservations.meta.linkAccommodation')
const actionIcon = isCheckIn ? accommodationIconSvg('checkin')
: isCheckOut ? accommodationIconSvg('checkout')
: accommodationIconSvg('accommodation')
const timeStr = isCheckIn ? (item.check_in || '')
: isCheckOut ? (item.check_out || '')
: ''
return `
<div class="day-accommodation">
<div class="day-accommodation-title accommodation-center-icon">${actionIcon} ${escHtml(actionLabel)}</div>
${timeStr ? `<div class="accommodation-center-icon">${accommodationIconSvg('checkin')} <b>${escHtml(timeStr)}</b></div>` : ''}
<div class="accommodation-center-icon">${accommodationIconSvg('accommodation')} ${escHtml(item.place_name)}</div>
${item.place_address ? `<div class="accommodation-center-icon">${accommodationIconSvg('location')} ${escHtml(item.place_address)}</div>` : ''}
${item.notes ? `<div class="accommodation-center-icon">${accommodationIconSvg('note')} ${escHtml(item.notes)}</div>` : ''}
${isCheckIn && item.confirmation ? `<div class="accommodation-center-icon">${accommodationIconSvg('confirmation')} ${escHtml(item.confirmation)}</div>` : ''}
</div>`
}).join('')
const accommodationsHtml = accommodationsForDay.length > 0
? `<div class="day-accommodations-overview">
<div class="day-accommodations ${accommodationsForDay.length === 1 ? 'single' : ''}">${accommodationDetails}</div>
</div>`
: ''
return `
<div class="day-section${di > 0 ? ' page-break' : ''}">
@@ -233,8 +280,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
${day.date ? `<span class="day-date">${shortDate(day.date, loc)}</span>` : ''}
${cost ? `<span class="day-cost">${cost}</span>` : ''}
</div>
<div class="day-body">${itemsHtml}</div>
</div>`
<div class="day-body">${accommodationsHtml}${itemsHtml}</div>
</div>`
}).join('')
const html = `<!DOCTYPE html>
@@ -317,6 +364,22 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
.day-cost { font-size: 9px; font-weight: 600; color: rgba(255,255,255,0.65); }
.day-body { padding: 12px 28px 6px; }
/* accommodation info */
.day-accommodations-overview { font-size: 12px; }
.day-accommodations { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; }
.day-accommodations.single { justify-content: center; }
.day-accommodation {
flex: 1 1 45%; min-width: 200px; margin: 4px 0; padding: 10px;
border: 2px solid #e2e8f0; border-radius: 12px;
display: flex; flex-direction: column;
}
.day-accommodation-title {
font-size: 16px; font-weight: 600; text-align: center;
margin-bottom: 4px; align-self: center;
}
.accommodation-center-icon { display: flex; align-items: center; gap: 4px; }
/* ── Place card ────────────────────────────────── */
.place-card {
display: flex; align-items: stretch;
@@ -1,5 +1,6 @@
import { useState, useMemo, useRef, useEffect } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { packingApi, tripsApi, adminApi } from '../../api/client'
@@ -66,7 +67,134 @@ function katColor(kat, allCategories) {
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length]
}
interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null }
interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null; user_id?: number | null; assigned_username?: string | null }
// ── Bag Card ──────────────────────────────────────────────────────────────
interface BagCardProps {
bag: PackingBag; bagItems: PackingItem[]; totalWeight: number; pct: number; tripId: number
tripMembers: TripMember[]; canEdit: boolean; onDelete: () => void
onUpdate: (bagId: number, data: Record<string, any>) => void
onSetMembers: (bagId: number, userIds: number[]) => void; t: any; compact?: boolean
}
function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers, canEdit, onDelete, onUpdate, onSetMembers, t, compact }: BagCardProps) {
const [editingName, setEditingName] = useState(false)
const [nameVal, setNameVal] = useState(bag.name)
const [showUserPicker, setShowUserPicker] = useState(false)
useEffect(() => setNameVal(bag.name), [bag.name])
const saveName = () => {
if (nameVal.trim() && nameVal.trim() !== bag.name) onUpdate(bag.id, { name: nameVal.trim() })
setEditingName(false)
}
const memberIds = (bag.members || []).map(m => m.user_id)
const toggleMember = (userId: number) => {
const next = memberIds.includes(userId) ? memberIds.filter(id => id !== userId) : [...memberIds, userId]
onSetMembers(bag.id, next)
}
const sz = compact ? { dot: 10, name: 12, weight: 11, bar: 6, count: 10, gap: 6, mb: 14, icon: 11, avatar: 18 } : { dot: 12, name: 14, weight: 13, bar: 8, count: 11, gap: 8, mb: 16, icon: 13, avatar: 22 }
return (
<div style={{ marginBottom: sz.mb }}>
<div style={{ display: 'flex', alignItems: 'center', gap: sz.gap, marginBottom: 4 }}>
<span style={{ width: sz.dot, height: sz.dot, borderRadius: '50%', background: bag.color, flexShrink: 0 }} />
{editingName && canEdit ? (
<input autoFocus value={nameVal} onChange={e => setNameVal(e.target.value)}
onBlur={saveName} onKeyDown={e => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') { setEditingName(false); setNameVal(bag.name) } }}
style={{ flex: 1, fontSize: sz.name, fontWeight: 600, padding: '1px 4px', borderRadius: 4, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', color: 'var(--text-primary)', background: 'transparent' }} />
) : (
<span onClick={() => canEdit && setEditingName(true)} style={{ flex: 1, fontSize: sz.name, fontWeight: 600, color: compact ? 'var(--text-secondary)' : 'var(--text-primary)', cursor: canEdit ? 'text' : 'default' }}>{bag.name}</span>
)}
<span style={{ fontSize: sz.weight, color: 'var(--text-faint)', fontWeight: 500 }}>
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
</span>
{canEdit && <button onClick={onDelete} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}><X size={sz.icon} /></button>}
</div>
{/* Members */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4, flexWrap: 'wrap', position: 'relative' }}>
{(bag.members || []).map(m => (
<span key={m.user_id} title={m.username} onClick={() => canEdit && toggleMember(m.user_id)} style={{ cursor: canEdit ? 'pointer' : 'default', display: 'inline-flex' }}>
{m.avatar ? (
<img src={m.avatar} alt={m.username} style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', objectFit: 'cover', border: `1.5px solid ${bag.color}`, boxSizing: 'border-box' }} />
) : (
<span style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', background: bag.color + '25', color: bag.color, fontSize: sz.avatar * 0.45, fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', border: `1.5px solid ${bag.color}`, boxSizing: 'border-box' }}>
{m.username[0].toUpperCase()}
</span>
)}
</span>
))}
{canEdit && (
<button onClick={() => setShowUserPicker(v => !v)} style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', border: '1.5px dashed var(--border-primary)', background: 'none', color: 'var(--text-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, boxSizing: 'border-box' }}>
<Plus size={sz.avatar * 0.5} />
</button>
)}
{showUserPicker && (
<div style={{ position: 'absolute', left: 0, top: '100%', marginTop: 4, zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.1)', padding: 4, minWidth: 160 }}>
{tripMembers.map(m => {
const isSelected = memberIds.includes(m.id)
return (
<button key={m.id} onClick={() => { toggleMember(m.id); }}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', background: isSelected ? 'var(--bg-tertiary)' : 'transparent', cursor: 'pointer', fontSize: 11, color: 'var(--text-primary)', fontFamily: 'inherit' }}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-secondary)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}>
{m.avatar ? (
<img src={m.avatar} alt="" style={{ width: 20, height: 20, borderRadius: '50%', objectFit: 'cover' }} />
) : (
<span style={{ width: 20, height: 20, borderRadius: '50%', background: 'var(--bg-tertiary)', fontSize: 10, fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-faint)' }}>
{m.username[0].toUpperCase()}
</span>
)}
<span style={{ flex: 1, fontWeight: isSelected ? 600 : 400 }}>{m.username}</span>
{isSelected && <Check size={12} style={{ color: '#10b981' }} />}
</button>
)
})}
{tripMembers.length === 0 && <div style={{ padding: '8px 10px', fontSize: 11, color: 'var(--text-faint)' }}>{t('packing.noMembers')}</div>}
<div style={{ borderTop: '1px solid var(--border-secondary)', marginTop: 4, paddingTop: 4 }}>
<button onClick={() => setShowUserPicker(false)} style={{ width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', background: 'transparent', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit', textAlign: 'center' }}>
{t('common.close')}
</button>
</div>
</div>
)}
</div>
<div style={{ height: sz.bar, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
</div>
<div style={{ fontSize: sz.count, color: 'var(--text-faint)', marginTop: 2 }}>{bagItems.length} {t('admin.packingTemplates.items')}</div>
</div>
)
}
// ── Quantity Input ─────────────────────────────────────────────────────────
function QuantityInput({ value, onSave }: { value: number; onSave: (qty: number) => void }) {
const [local, setLocal] = useState(String(value))
useEffect(() => setLocal(String(value)), [value])
const commit = () => {
const qty = Math.max(1, Math.min(999, Number(local) || 1))
setLocal(String(qty))
if (qty !== value) onSave(qty)
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent', flexShrink: 0 }}>
<input
type="text" inputMode="numeric"
value={local}
onChange={e => setLocal(e.target.value.replace(/\D/g, ''))}
onBlur={commit}
onKeyDown={e => { if (e.key === 'Enter') { commit(); (e.target as HTMLInputElement).blur() } }}
style={{ width: 24, border: 'none', outline: 'none', background: 'transparent', fontSize: 12, textAlign: 'right', fontFamily: 'inherit', color: 'var(--text-secondary)', padding: 0 }}
/>
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 500 }}>x</span>
</div>
)
}
// ── Artikel-Zeile ──────────────────────────────────────────────────────────
interface ArtikelZeileProps {
@@ -77,9 +205,10 @@ interface ArtikelZeileProps {
bagTrackingEnabled?: boolean
bags?: PackingBag[]
onCreateBag: (name: string) => Promise<PackingBag | undefined>
canEdit?: boolean
}
function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag }: ArtikelZeileProps) {
function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(item.name)
const [hovered, setHovered] = useState(false)
@@ -130,7 +259,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
{item.checked ? <CheckSquare size={18} /> : <Square size={18} />}
</button>
{editing ? (
{editing && canEdit ? (
<input
type="text" value={editName} autoFocus
onChange={e => setEditName(e.target.value)}
@@ -140,10 +269,10 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
/>
) : (
<span
onClick={() => !item.checked && setEditing(true)}
onClick={() => canEdit && !item.checked && setEditing(true)}
style={{
flex: 1, fontSize: 13.5,
cursor: item.checked ? 'default' : 'text',
cursor: !canEdit || item.checked ? 'default' : 'text',
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
textDecoration: item.checked ? 'line-through' : 'none',
}}
@@ -152,6 +281,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
</span>
)}
{/* Quantity */}
{canEdit && <QuantityInput value={item.quantity || 1} onSave={qty => updatePackingItem(tripId, item.id, { quantity: qty })} />}
{/* Weight + Bag (when enabled) */}
{bagTrackingEnabled && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
@@ -159,7 +291,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
<input
type="text" inputMode="numeric"
value={item.weight_grams ?? ''}
readOnly={!canEdit}
onChange={async e => {
if (!canEdit) return
const raw = e.target.value.replace(/[^0-9]/g, '')
const v = raw === '' ? null : parseInt(raw)
try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch {}
@@ -171,9 +305,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
</div>
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowBagPicker(p => !p)}
onClick={() => canEdit && setShowBagPicker(p => !p)}
style={{
width: 22, height: 22, borderRadius: '50%', cursor: 'pointer', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 22, height: 22, borderRadius: '50%', cursor: canEdit ? 'pointer' : 'default', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
border: item.bag_id ? `2.5px solid ${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}` : '2px dashed var(--border-primary)',
background: item.bag_id ? `${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}30` : 'transparent',
}}
@@ -247,6 +381,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
</div>
)}
{canEdit && (
<div className="sm:opacity-0 sm:group-hover:opacity-100" style={{ display: 'flex', gap: 2, alignItems: 'center', transition: 'opacity 0.12s', flexShrink: 0 }}>
<div style={{ position: 'relative' }}>
<button
@@ -287,6 +422,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
<Trash2 size={13} />
</button>
</div>
)}
</div>
)
}
@@ -319,9 +455,10 @@ interface KategorieGruppeProps {
bagTrackingEnabled?: boolean
bags?: PackingBag[]
onCreateBag: (name: string) => Promise<PackingBag | undefined>
canEdit?: boolean
}
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag }: KategorieGruppeProps) {
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
const [offen, setOffen] = useState(true)
const [editingName, setEditingName] = useState(false)
const [editKatName, setEditKatName] = useState(kategorie)
@@ -380,7 +517,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
{editingName ? (
{editingName && canEdit ? (
<input
autoFocus value={editKatName}
onChange={e => setEditKatName(e.target.value)}
@@ -398,11 +535,11 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
<div style={{ display: 'flex', alignItems: 'center', gap: 3, flex: 1, minWidth: 0, marginLeft: 4 }}>
{assignees.map(a => (
<div key={a.user_id} style={{ position: 'relative' }}
onClick={e => { e.stopPropagation(); onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }}
onClick={e => { e.stopPropagation(); if (canEdit) onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }}
>
<div className="assignee-chip"
style={{
width: 22, height: 22, borderRadius: '50%', flexShrink: 0, cursor: 'pointer',
width: 22, height: 22, borderRadius: '50%', flexShrink: 0, cursor: canEdit ? 'pointer' : 'default',
background: `hsl(${a.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
@@ -422,6 +559,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
</div>
</div>
))}
{canEdit && (
<div ref={assigneeDropdownRef} style={{ position: 'relative' }}>
<button onClick={e => { e.stopPropagation(); setShowAssigneeDropdown(v => !v) }}
style={{
@@ -479,6 +617,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
</div>
)}
</div>
)}
</div>
<span style={{
@@ -497,11 +636,13 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
{showMenu && (
<div style={{ position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}
onMouseLeave={() => setShowMenu(false)}>
<MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />
{canEdit && <MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />}
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
{canEdit && <>
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
</>}
</div>
)}
</div>
@@ -510,10 +651,10 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
{offen && (
<div style={{ padding: '4px 4px 6px' }}>
{items.map(item => (
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} />
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
))}
{/* Inline add item */}
{showAddItem ? (
{canEdit && (showAddItem ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px' }}>
<input
ref={addItemRef}
@@ -548,7 +689,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Plus size={12} /> {t('packing.addItem')}
</button>
)}
))}
</div>
)}
</div>
@@ -589,6 +730,9 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
const [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('')
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('packing_edit', trip)
const toast = useToast()
const { t } = useTranslation()
@@ -724,10 +868,26 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
} catch { toast.error(t('packing.toast.deleteError')) }
}
const handleUpdateBag = async (bagId: number, data: Record<string, any>) => {
try {
const result = await packingApi.updateBag(tripId, bagId, data)
setBags(prev => prev.map(b => b.id === bagId ? { ...b, ...result.bag } : b))
} catch { toast.error(t('common.error')) }
}
const handleSetBagMembers = async (bagId: number, userIds: number[]) => {
try {
const result = await packingApi.setBagMembers(tripId, bagId, userIds)
setBags(prev => prev.map(b => b.id === bagId ? { ...b, members: result.members } : b))
} catch { toast.error(t('common.error')) }
}
// Templates
const [availableTemplates, setAvailableTemplates] = useState<{ id: number; name: string; item_count: number }[]>([])
const [showTemplateDropdown, setShowTemplateDropdown] = useState(false)
const [applyingTemplate, setApplyingTemplate] = useState(false)
const [showSaveTemplate, setShowSaveTemplate] = useState(false)
const [saveTemplateName, setSaveTemplateName] = useState('')
const [showImportModal, setShowImportModal] = useState(false)
const [importText, setImportText] = useState('')
const csvInputRef = useRef<HTMLInputElement>(null)
@@ -761,10 +921,38 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
}
}
const handleSaveAsTemplate = async () => {
if (!saveTemplateName.trim()) return
try {
await packingApi.saveAsTemplate(tripId, saveTemplateName.trim())
toast.success(t('packing.templateSaved'))
setShowSaveTemplate(false)
setSaveTemplateName('')
adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
} catch {
toast.error(t('common.error'))
}
}
// Parse CSV line respecting quoted values (e.g. "Shirt, blue" stays as one field)
const parseCsvLine = (line: string): string[] => {
const parts: string[] = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const ch = line[i]
if (ch === '"') { inQuotes = !inQuotes; continue }
if (!inQuotes && (ch === ',' || ch === ';' || ch === '\t')) { parts.push(current.trim()); current = ''; continue }
current += ch
}
parts.push(current.trim())
return parts
}
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())
const parts = parseCsvLine(line)
if (parts.length >= 2) {
const category = parts[0]
const name = parts[1]
@@ -814,7 +1002,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
</p>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{abgehakt > 0 && (
{canEdit && abgehakt > 0 && (
<button onClick={handleClearChecked} style={{
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
@@ -823,6 +1011,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
</button>
)}
{canEdit && (
<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',
@@ -830,7 +1019,8 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
}}>
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
</button>
{availableTemplates.length > 0 && (
)}
{canEdit && availableTemplates.length > 0 && (
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
@@ -869,6 +1059,32 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
)}
</div>
)}
{canEdit && items.length > 0 && (
<div style={{ position: 'relative' }}>
{showSaveTemplate ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input
type="text" autoFocus
value={saveTemplateName}
onChange={e => setSaveTemplateName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }}
placeholder={t('packing.templateName')}
style={{ fontSize: 12, padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }}
/>
<button onClick={handleSaveAsTemplate} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#10b981' }}><Check size={14} /></button>
<button onClick={() => { setShowSaveTemplate(false); setSaveTemplateName('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}><X size={14} /></button>
</div>
) : (
<button onClick={() => setShowSaveTemplate(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)',
}}>
<FolderPlus size={12} /> <span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span>
</button>
)}
</div>
)}
{bagTrackingEnabled && (
<button onClick={() => setShowBagModal(true)} className="xl:!hidden"
style={{
@@ -899,7 +1115,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
</div>
)}
{addingCategory ? (
{canEdit && (addingCategory ? (
<div style={{ display: 'flex', gap: 6 }}>
<input
autoFocus
@@ -924,7 +1140,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
<FolderPlus size={14} /> {t('packing.addCategory')}
</button>
)}
))}
</div>
{/* ── Filter-Tabs ── */}
@@ -972,6 +1188,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
bagTrackingEnabled={bagTrackingEnabled}
bags={bags}
onCreateBag={handleCreateBagByName}
canEdit={canEdit}
/>
))}
</div>
@@ -991,23 +1208,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
return (
<div key={bag.id} style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: bag.color, flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{bag.name}</span>
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 500 }}>
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
</span>
<button onClick={() => handleDeleteBag(bag.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
<X size={11} />
</button>
</div>
<div style={{ height: 6, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
</div>
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2 }}>{bagItems.length} {t('admin.packingTemplates.items')}</div>
</div>
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
)
})}
@@ -1039,7 +1240,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
</div>
{/* Add bag */}
{showAddBag ? (
{canEdit && (showAddBag ? (
<div style={{ display: 'flex', gap: 4, marginTop: 12 }}>
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
@@ -1054,16 +1255,16 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 12, padding: '5px 8px', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%' }}>
<Plus size={11} /> {t('packing.addBag')}
</button>
)}
))}
</div>
)}
</div>
{/* ── Bag Modal (mobile + click) ── */}
{showBagModal && bagTrackingEnabled && (
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, overflowY: 'auto' }}
onClick={() => setShowBagModal(false)}>
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: '80vh', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)' }}
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: 'calc(100vh - 80px)', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)', flexShrink: 0 }}
onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.bags')}</h3>
@@ -1076,23 +1277,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
return (
<div key={bag.id} style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ width: 12, height: 12, borderRadius: '50%', background: bag.color, flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>{bag.name}</span>
<span style={{ fontSize: 13, color: 'var(--text-muted)', fontWeight: 500 }}>
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
</span>
<button onClick={() => handleDeleteBag(bag.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
<Trash2 size={13} />
</button>
</div>
<div style={{ height: 8, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
</div>
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>{bagItems.length} {t('admin.packingTemplates.items')}</div>
</div>
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
)
})}
@@ -1124,7 +1309,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
</div>
{/* Add bag */}
{showAddBag ? (
{canEdit && (showAddBag ? (
<div style={{ display: 'flex', gap: 6, marginTop: 14 }}>
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
@@ -1142,7 +1327,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
<Plus size={14} /> {t('packing.addBag')}
</button>
)}
))}
</div>
</div>
)}
@@ -1166,18 +1351,29 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
}} 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', border: '1px solid var(--border-primary)', borderRadius: 10, overflow: 'hidden', background: 'var(--bg-input)' }}>
<div style={{
padding: '10px 0', fontSize: 13, fontFamily: 'monospace', lineHeight: 1.5,
color: 'var(--text-faint)', textAlign: 'right', userSelect: 'none',
background: 'var(--bg-hover)', borderRight: '1px solid var(--border-faint)',
minWidth: 32, flexShrink: 0,
}}>
{(importText || ' ').split('\n').map((_, i) => (
<div key={i} style={{ padding: '0 6px' }}>{i + 1}</div>
))}
</div>
<textarea
value={importText}
onChange={e => setImportText(e.target.value)}
rows={10}
placeholder={t('packing.importPlaceholder')}
style={{
flex: 1, border: 'none', padding: '10px 12px', fontSize: 13, fontFamily: 'monospace',
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)',
background: 'transparent', resize: 'vertical', lineHeight: 1.5,
}}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<input ref={csvInputRef} type="file" accept=".csv,.txt" style={{ display: 'none' }} onChange={handleCsvFile} />
@@ -213,5 +213,5 @@ function PhotoThumbnail({ photo, days, places, onClick }: PhotoThumbnailProps) {
function formatDate(dateStr, locale) {
if (!dateStr) return ''
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
}
@@ -5,6 +5,8 @@ import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
import { weatherApi, accommodationsApi } from '../../api/client'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker'
import { useSettingsStore } from '../../store/settingsStore'
@@ -56,6 +58,9 @@ interface 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 can = useCanDo()
const tripObj = useTripStore((s) => s.trip)
const canEditDays = can('day_edit', tripObj)
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)
@@ -111,8 +116,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
check_out: hotelForm.check_out || null,
confirmation: hotelForm.confirmation || null,
})
setAccommodation(data.accommodation)
setAccommodations(prev => [...prev, data.accommodation])
const newAcc = data.accommodation
const updated = [...accommodations, newAcc]
setAccommodations(updated)
setAccommodation(newAcc)
setDayAccommodations(updated.filter(a =>
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
))
setShowHotelPicker(false)
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
onAccommodationChange?.()
@@ -132,7 +142,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
if (!accommodation) return
try {
await accommodationsApi.delete(tripId, accommodation.id)
setAccommodations(prev => prev.filter(a => a.id !== accommodation.id))
const updated = accommodations.filter(a => a.id !== accommodation.id)
setAccommodations(updated)
setDayAccommodations(updated.filter(a =>
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
))
setAccommodation(null)
onAccommodationChange?.()
} catch {}
@@ -140,9 +154,9 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
if (!day) return null
const formattedDate = day.date ? new Date(day.date + 'T00:00:00').toLocaleDateString(
const formattedDate = day.date ? new Date(day.date + 'T00:00:00Z').toLocaleDateString(
getLocaleForLanguage(language),
{ weekday: 'long', day: 'numeric', month: 'long' }
{ weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }
) : null
const placesWithCoords = places.filter(p => p.lat && p.lng)
@@ -328,13 +342,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</div>
{acc.place_address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_address}</div>}
</div>
<button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
{canEditDays && <button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
<Pencil size={12} style={{ color: 'var(--text-faint)' }} />
</button>
<button onClick={() => { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
</button>}
{canEditDays && <button onClick={() => { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
<X size={12} style={{ color: 'var(--text-faint)' }} />
</button>
</button>}
</div>
{/* Details grid */}
<div style={{ display: 'flex', gap: 0, margin: '0 12px 8px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}>
@@ -385,22 +399,22 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
)
})}
{/* Add another hotel */}
<button onClick={() => setShowHotelPicker(true)} style={{
{canEditDays && <button onClick={() => setShowHotelPicker(true)} style={{
width: '100%', padding: 8, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
fontSize: 10, color: 'var(--text-faint)', fontFamily: 'inherit',
}}>
<Hotel size={10} /> {t('day.addAccommodation')}
</button>
</button>}
</div>
) : (
<button onClick={() => setShowHotelPicker(true)} style={{
canEditDays ? <button onClick={() => setShowHotelPicker(true)} style={{
width: '100%', padding: 10, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit',
}}>
<Hotel size={12} /> {t('day.addAccommodation')}
</button>
</button> : null
)}
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
@@ -431,7 +445,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
options={days.map((d, i) => ({
value: d.id,
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`,
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
}))}
size="sm"
/>
@@ -443,7 +457,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
options={days.map((d, i) => ({
value: d.id,
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`,
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
}))}
size="sm"
/>
@@ -549,8 +563,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
// Reload
accommodationsApi.list(tripId).then(d => {
setAccommodations(d.accommodations || [])
const acc = (d.accommodations || []).find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
const all = d.accommodations || []
setAccommodations(all)
setDayAccommodations(all.filter(a =>
days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id)
))
const acc = all.find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
setAccommodation(acc || null)
})
onAccommodationChange?.()
+387 -157
View File
@@ -2,9 +2,9 @@
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string }
declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect, useRef, useMemo } from 'react'
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'
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, Undo2 } 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'
@@ -12,10 +12,13 @@ import { downloadTripPDF } from '../PDF/TripPDF'
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import WeatherWidget from '../Weather/WeatherWidget'
import { useToast } from '../shared/Toast'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
@@ -76,9 +79,14 @@ interface DayPlanSidebarProps {
reservations?: Reservation[]
onAddReservation: () => void
onNavigateToFiles?: () => void
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
canUndo?: boolean
lastActionLabel?: string | null
onUndo?: () => void
}
export default function DayPlanSidebar({
const DayPlanSidebar = React.memo(function DayPlanSidebar({
tripId,
trip, days, places, categories, assignments,
selectedDayId, selectedPlaceId, selectedAssignmentId,
@@ -88,12 +96,19 @@ export default function DayPlanSidebar({
reservations = [],
onAddReservation,
onNavigateToFiles,
onExpandedDaysChange,
pushUndo,
canUndo = false,
lastActionLabel = null,
onUndo,
}: DayPlanSidebarProps) {
const toast = useToast()
const { t, language, locale } = useTranslation()
const ctxMenu = useContextMenu()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const tripStore = useTripStore()
const tripActions = useRef(useTripStore.getState()).current
const can = useCanDo()
const canEditDays = can('day_edit', trip)
const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
@@ -104,6 +119,7 @@ export default function DayPlanSidebar({
} catch {}
return new Set(days.map(d => d.id))
})
useEffect(() => { onExpandedDaysChange?.(expandedDays) }, [expandedDays])
const [editingDayId, setEditingDayId] = useState(null)
const [editTitle, setEditTitle] = useState('')
const [isCalculating, setIsCalculating] = useState(false)
@@ -111,12 +127,16 @@ export default function DayPlanSidebar({
const [draggingId, setDraggingId] = useState(null)
const [lockedIds, setLockedIds] = useState(new Set())
const [lockHoverId, setLockHoverId] = useState(null)
const [undoHover, setUndoHover] = useState(false)
const [pdfHover, setPdfHover] = useState(false)
const [icsHover, setIcsHover] = useState(false)
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 [transportPosVersion, setTransportPosVersion] = useState(0)
const [timeConfirm, setTimeConfirm] = useState<{
dayId: number; fromId: number; time: string;
// For drag & drop reorder
@@ -192,13 +212,69 @@ export default function DayPlanSidebar({
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
// Determine if a reservation's end_time represents a different date (multi-day)
const getEndDate = (r: Reservation) => {
const endStr = r.reservation_end_time || ''
return endStr.includes('T') ? endStr.split('T')[0] : null
}
// Get span phase: how a reservation relates to a specific day's date
const getSpanPhase = (r: Reservation, dayDate: string): 'single' | 'start' | 'middle' | 'end' => {
if (!r.reservation_time) return 'single'
const startDate = r.reservation_time.split('T')[0]
const endDate = getEndDate(r) || startDate
if (startDate === endDate) return 'single'
if (dayDate === startDate) return 'start'
if (dayDate === endDate) return 'end'
return 'middle'
}
// Get the appropriate display time for a reservation on a specific day
const getDisplayTimeForDay = (r: Reservation, dayDate: string): string | null => {
const phase = getSpanPhase(r, dayDate)
if (phase === 'end') return r.reservation_end_time || null
if (phase === 'middle') return null
return r.reservation_time || null
}
// Get phase label for multi-day badge
const getSpanLabel = (r: Reservation, phase: string): string | null => {
if (phase === 'single') return null
if (r.type === 'flight') return t(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
if (r.type === 'car') return t(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
return t(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
}
const getTransportForDay = (dayId: number) => {
const day = days.find(d => d.id === dayId)
if (!day?.date) return []
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
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
if (!r.reservation_time || r.type === 'hotel') return false
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
const startDate = r.reservation_time.split('T')[0]
const endDate = getEndDate(r)
if (endDate && endDate !== startDate) {
// Multi-day: show on any day in range (car middle handled elsewhere)
return day.date >= startDate && day.date <= endDate
} else {
// Single-day: show all non-hotel reservations that match this day's date
return startDate === day.date
}
})
}
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
const getActiveRentalsForDay = (dayId: number) => {
const day = days.find(d => d.id === dayId)
if (!day?.date) return []
return reservations.filter(r => {
if (r.type !== 'car' || !r.reservation_time) return false
const startDate = r.reservation_time.split('T')[0]
const endDate = getEndDate(r)
if (!endDate || endDate === startDate) return false
return day.date > startDate && day.date < endDate
})
}
@@ -260,47 +336,45 @@ export default function DayPlanSidebar({
const da = getDayAssignments(dayId)
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
const transport = getTransportForDay(dayId)
const dayDate = days.find(d => d.id === dayId)?.date || ''
// 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)
// All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
const baseItems = [
...freePlaces.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
...da.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)
// Transports are inserted among places based on time
const timedTransports = transport.map(r => ({
type: 'transport' as const,
data: r,
minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayDate)) ?? 0,
})).sort((a, b) => a.minutes - b.minutes)
if (allTimed.length === 0) return baseItems
if (timedTransports.length === 0) return baseItems
if (baseItems.length === 0) {
return allTimed.map((item, i) => ({ ...item, sortKey: i }))
return timedTransports.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.
// Insert transports among places based on per-day position or time
const result = [...baseItems]
for (let ti = 0; ti < allTimed.length; ti++) {
const timed = allTimed[ti]
for (let ti = 0; ti < timedTransports.length; ti++) {
const timed = timedTransports[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 })
// Use per-day position if explicitly set by user reorder
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
if (perDayPos != null) {
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
continue
}
// Find insertion position: after the last base item with time <= this item's time
// Find insertion position: after the last place with time <= this transport's time
let insertAfterKey = -Infinity
for (const item of result) {
if (item.type === 'place') {
@@ -323,6 +397,16 @@ export default function DayPlanSidebar({
return result.sort((a, b) => a.sortKey - b.sortKey)
}
// Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover)
// eslint-disable-next-line react-hooks/exhaustive-deps
const mergedItemsMap = useMemo(() => {
const map: Record<number, ReturnType<typeof getMergedItems>> = {}
days.forEach(day => { map[day.id] = getMergedItems(day.id) })
return map
// getMergedItems is redefined each render but captures assignments/dayNotes/reservations/days via closure
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [days, assignments, dayNotes, reservations, transportPosVersion])
const openAddNote = (dayId, e) => {
e?.stopPropagation()
_openAddNote(dayId, getMergedItems, (id) => {
@@ -377,6 +461,9 @@ export default function DayPlanSidebar({
// Unified reorder: assigns positions to ALL item types based on new visual order
const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => {
// Capture previous place order for undo
const prevAssignmentIds = getDayAssignments(dayId).map(a => a.id)
// Places get sequential integer positions (0, 1, 2, ...)
// Non-place items between place N-1 and place N get fractional positions
const assignmentIds: number[] = []
@@ -410,14 +497,27 @@ export default function DayPlanSidebar({
try {
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
for (const n of noteUpdates) {
await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
await tripActions.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
if (res) {
res.day_plan_position = tu.day_plan_position
// Update per-day position for multi-day reservations
if (!res.day_positions) res.day_positions = {}
res.day_positions[dayId] = tu.day_plan_position
}
}
await reservationsApi.updatePositions(tripId, transportUpdates)
setTransportPosVersion(v => v + 1)
await reservationsApi.updatePositions(tripId, transportUpdates, dayId)
}
if (prevAssignmentIds.length) {
const capturedDayId = dayId
const capturedPrevIds = prevAssignmentIds
pushUndo?.(t('undo.reorder'), async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}
@@ -503,7 +603,7 @@ export default function DayPlanSidebar({
currentAssignments[key] = currentAssignments[key].map(a =>
a.id === fromId ? { ...a, place: { ...a.place, place_time: null, end_time: null } } : a
)
tripStore.setAssignments(currentAssignments)
tripActions.setAssignments(currentAssignments)
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Unknown error')
@@ -581,12 +681,14 @@ export default function DayPlanSidebar({
}
const toggleLock = (assignmentId) => {
const prevLocked = new Set(lockedIds)
setLockedIds(prev => {
const next = new Set(prev)
if (next.has(assignmentId)) next.delete(assignmentId)
else next.add(assignmentId)
return next
})
pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) })
}
const handleOptimize = async () => {
@@ -594,6 +696,8 @@ export default function DayPlanSidebar({
const da = getDayAssignments(selectedDayId)
if (da.length < 3) return
const prevIds = da.map(a => a.id)
// Separate locked (stay at their index) and unlocked assignments
const locked = new Map() // index -> assignment
const unlocked = []
@@ -620,6 +724,10 @@ export default function DayPlanSidebar({
await onReorder(selectedDayId, result.map(a => a.id))
toast.success(t('dayplan.toast.routeOptimized'))
const capturedDayId = selectedDayId
pushUndo?.(t('undo.optimize'), async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
})
}
const handleGoogleMaps = () => {
@@ -632,14 +740,24 @@ export default function DayPlanSidebar({
const handleDropOnDay = (e, dayId) => {
e.preventDefault()
e.stopPropagation()
setDragOverDayId(null)
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
if (placeId) {
onAssignToDay?.(parseInt(placeId), dayId)
} else if (assignmentId && fromDayId !== dayId) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
const srcAssignment = (useTripStore.getState().assignments[String(fromDayId)] || []).find(a => a.id === Number(assignmentId))
const capturedFromDayId = fromDayId
const capturedOrderIndex = srcAssignment?.order_index ?? 0
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId)
.then(() => {
pushUndo?.(t('undo.moveDay'), async () => {
await tripActions.moveAssignment(tripId, Number(assignmentId), dayId, capturedFromDayId, capturedOrderIndex)
})
})
.catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (noteId && fromDayId !== dayId) {
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
}
setDraggingId(null)
setDropTargetKey(null)
@@ -668,10 +786,10 @@ export default function DayPlanSidebar({
setDraggingId(null)
}
const totalCost = days.reduce((s, d) => {
const totalCost = useMemo(() => days.reduce((s, d) => {
const da = assignments[String(d.id)] || []
return s + da.reduce((s2, a) => s2 + (parseFloat(a.place?.price) || 0), 0)
}, 0)
}, 0), [days, assignments])
// Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort
const anyGeoAssignment = Object.values(assignments).flatMap(da => da).find(a => a.place?.lat && a.place?.lng)
@@ -686,62 +804,124 @@ export default function DayPlanSidebar({
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', lineHeight: '1.3' }}>{trip?.title}</div>
{(trip?.start_date || trip?.end_date) && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })).join(' ')}
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })).join(' ')}
{days.length > 0 && ` · ${days.length} ${t('dayplan.days')}`}
</div>
)}
</div>
<button
onClick={async () => {
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
notes.map(n => ({ ...n, day_id: Number(dayId) }))
)
try {
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)))
}
}}
title={t('dayplan.pdfTooltip')}
style={{
flexShrink: 0, display: 'flex', alignItems: 'center', gap: 5,
padding: '5px 10px', borderRadius: 8, border: 'none',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<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 style={{ position: 'relative', flexShrink: 0 }}>
<button
onClick={async () => {
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
notes.map(n => ({ ...n, day_id: Number(dayId) }))
)
try {
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)))
}
}}
onMouseEnter={() => setPdfHover(true)}
onMouseLeave={() => setPdfHover(false)}
style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '5px 10px', borderRadius: 8, border: 'none',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<FileDown size={13} strokeWidth={2} />
{t('dayplan.pdf')}
</button>
{pdfHover && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 200,
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)',
}}>
{t('dayplan.pdfTooltip')}
</div>
)}
</div>
<div style={{ position: 'relative', flexShrink: 0 }}>
<button
onClick={async () => {
try {
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
credentials: 'include',
})
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') }
}}
onMouseEnter={() => setIcsHover(true)}
onMouseLeave={() => setIcsHover(false)}
style={{
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>
{icsHover && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 200,
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)',
}}>
{t('dayplan.icsTooltip')}
</div>
)}
</div>
{onUndo && (
<div style={{ position: 'relative', flexShrink: 0 }}>
<button
onClick={onUndo}
disabled={!canUndo}
onMouseEnter={() => setUndoHover(true)}
onMouseLeave={() => setUndoHover(false)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 30, height: 30, borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'none',
color: canUndo ? 'var(--text-primary)' : 'var(--border-primary)',
cursor: canUndo ? 'pointer' : 'default', fontFamily: 'inherit',
transition: 'color 0.15s, border-color 0.15s',
}}
>
<Undo2 size={14} strokeWidth={2} />
</button>
{undoHover && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 200,
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)',
}}>
{canUndo && lastActionLabel ? t('undo.tooltip', { action: lastActionLabel }) : t('undo.button')}
</div>
)}
</div>
)}
</div>
</div>
@@ -755,7 +935,7 @@ export default function DayPlanSidebar({
const formattedDate = formatDate(day.date, locale)
const loc = da.find(a => a.place?.lat && a.place?.lng)
const isDragTarget = dragOverDayId === day.id
const merged = getMergedItems(day.id)
const merged = mergedItemsMap[day.id] || []
const dayNoteUi = noteUi[day.id]
const placeItems = merged.filter(i => i.type === 'place')
@@ -777,6 +957,7 @@ export default function DayPlanSidebar({
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
outlineOffset: -2,
borderRadius: isDragTarget ? 8 : 0,
touchAction: 'manipulation',
}}
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
@@ -810,15 +991,15 @@ export default function DayPlanSidebar({
/>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
{day.title || t('dayplan.dayN', { n: index + 1 })}
</span>
<button
{canEditDays && <button
onClick={e => startEditTitle(day, e)}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '2px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '4px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
>
<Pencil size={10} strokeWidth={1.8} color="var(--text-secondary)" />
</button>
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
</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
@@ -848,6 +1029,17 @@ export default function DayPlanSidebar({
)
})
})()}
{/* Active rental car badges */}
{(() => {
const activeRentals = getActiveRentalsForDay(day.id)
if (activeRentals.length === 0) return null
return activeRentals.map(r => (
<span key={`rental-${r.id}`} onClick={e => { e.stopPropagation(); setTransportDetail(r) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: 'rgba(59,130,246,0.08)', border: '1px solid rgba(59,130,246,0.2)', flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}>
<Car size={8} style={{ color: '#3b82f6', flexShrink: 0 }} />
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
</span>
))
})()}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2, flexWrap: 'wrap' }}>
@@ -862,20 +1054,20 @@ export default function DayPlanSidebar({
</div>
</div>
<button
{canEditDays && <button
onClick={e => openAddNote(day.id, e)}
title={t('dayplan.addNote')}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
>
<FileText size={13} strokeWidth={2} />
</button>
<FileText size={16} strokeWidth={2} />
</button>}
<button
onClick={e => toggleDay(day.id, e)}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
>
{isExpanded ? <ChevronDown size={15} strokeWidth={2} /> : <ChevronRight size={15} strokeWidth={2} />}
{isExpanded ? <ChevronDown size={18} strokeWidth={2} /> : <ChevronRight size={18} strokeWidth={2} />}
</button>
</div>
@@ -886,21 +1078,24 @@ export default function DayPlanSidebar({
onDragOver={e => { e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }}
onDrop={e => {
e.preventDefault()
e.stopPropagation()
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-', ''))
const isAfter = dropTargetRef.current.startsWith('transport-after-')
const parts = dropTargetRef.current.replace('transport-after-', '').replace('transport-', '').split('-')
const transportId = Number(parts[0])
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'))
tripActions.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)
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter)
} 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'))
tripActions.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)
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter)
}
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
return
@@ -912,11 +1107,11 @@ export default function DayPlanSidebar({
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'))
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
if (noteId && fromDayId !== day.id) {
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
const m = getMergedItems(day.id)
@@ -941,8 +1136,9 @@ export default function DayPlanSidebar({
</div>
) : (
merged.map((item, idx) => {
const itemKey = item.type === 'transport' ? `transport-${item.data.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
const itemKey = item.type === 'transport' ? `transport-${item.data.id}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey
const showDropLineAfter = item.type === 'transport' && (!!draggingId || !!dropTargetKey) && dropTargetKey === `transport-after-${item.data.id}-${day.id}`
if (item.type === 'place') {
const assignment = item.data
@@ -992,8 +1188,9 @@ export default function DayPlanSidebar({
<React.Fragment key={`place-${assignment.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div
draggable
draggable={canEditDays}
onDragStart={e => {
if (!canEditDays) { e.preventDefault(); return }
e.dataTransfer.setData('assignmentId', String(assignment.id))
e.dataTransfer.setData('fromDayId', String(day.id))
e.dataTransfer.effectAllowed = 'move'
@@ -1010,7 +1207,7 @@ export default function DayPlanSidebar({
setDropTargetKey(null); window.__dragData = null
} else if (fromAssignmentId && fromDayId !== day.id) {
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
@@ -1018,7 +1215,7 @@ export default function DayPlanSidebar({
const tm = getMergedItems(day.id)
const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
@@ -1027,12 +1224,12 @@ export default function DayPlanSidebar({
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
onContextMenu={e => ctxMenu.open(e, [
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
{ divider: true },
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])}
onMouseEnter={() => setHoveredId(assignment.id)}
onMouseLeave={() => setHoveredId(null)}
@@ -1050,9 +1247,9 @@ export default function DayPlanSidebar({
opacity: isDraggingThis ? 0.4 : 1,
}}
>
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>
</div>}
<div
onClick={e => { e.stopPropagation(); toggleLock(assignment.id) }}
onMouseEnter={e => { e.stopPropagation(); setLockHoverId(assignment.id) }}
@@ -1103,10 +1300,8 @@ export default function DayPlanSidebar({
)}
</div>
{(place.description || place.address || cat?.name) && (
<div style={{ marginTop: 2 }}>
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
{place.description || place.address || cat?.name}
</span>
<div className="collab-note-md" style={{ marginTop: 2, fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2, maxHeight: '1.2em' }}>
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.address || cat?.name || ''}</Markdown>
</div>
)}
{(() => {
@@ -1155,14 +1350,14 @@ export default function DayPlanSidebar({
</div>
)}
</div>
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
<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={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>
</div>}
</div>
</React.Fragment>
)
@@ -1171,6 +1366,11 @@ export default function DayPlanSidebar({
// Transport booking (flight, train, bus, car, cruise)
if (item.type === 'transport') {
const res = item.data
const spanPhase = getSpanPhase(res, day.date)
// Car "active" (middle) days are shown in the day header, skip here
if (res.type === 'car' && spanPhase === 'middle') return null
const TransportIcon = RES_ICONS[res.type] || Ticket
const color = '#3b82f6'
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
@@ -1187,25 +1387,37 @@ export default function DayPlanSidebar({
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ')
}
// Multi-day span phase
const spanLabel = getSpanLabel(res, spanPhase)
const displayTime = getDisplayTimeForDay(res, day.date)
return (
<React.Fragment key={`transport-${res.id}`}>
<React.Fragment key={`transport-${res.id}-${day.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}`) }}
onDragOver={e => {
e.preventDefault(); e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
const inBottom = e.clientY > rect.top + rect.height / 2
const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}`
if (dropTargetRef.current !== key) setDropTargetKey(key)
}}
onDrop={e => {
e.preventDefault(); e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
const insertAfter = e.clientY > rect.top + rect.height / 2
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'))
tripActions.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)
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter)
} 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'))
tripActions.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)
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter)
}
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
}}
@@ -1220,6 +1432,7 @@ export default function DayPlanSidebar({
background: isTransportHovered ? `${color}12` : `${color}08`,
cursor: 'pointer', userSelect: 'none',
transition: 'background 0.1s',
opacity: spanPhase === 'middle' ? 0.65 : 1,
}}
>
<div style={{
@@ -1230,14 +1443,27 @@ export default function DayPlanSidebar({
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{spanLabel && (
<span style={{
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4, flexShrink: 0,
background: `${color}20`, color: color, textTransform: 'uppercase', letterSpacing: '0.03em',
}}>
{spanLabel}
</span>
)}
<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') && (
{displayTime?.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' })}`}
{new Date(displayTime).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
{spanPhase === 'single' && res.reservation_end_time && (() => {
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (displayTime.split('T')[0] + 'T' + res.reservation_end_time)
return ` ${new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`
})()}
{meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`}
{meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`}
</span>
)}
</div>
@@ -1248,6 +1474,7 @@ export default function DayPlanSidebar({
)}
</div>
</div>
{showDropLineAfter && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
</React.Fragment>
)
}
@@ -1261,8 +1488,8 @@ export default function DayPlanSidebar({
<React.Fragment key={`note-${note.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div
draggable
onDragStart={e => { e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
draggable={canEditDays}
onDragStart={e => { if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
onDrop={e => {
@@ -1272,7 +1499,7 @@ export default function DayPlanSidebar({
const tm = getMergedItems(day.id)
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null)
} else if (fromNoteId && fromNoteId !== String(note.id)) {
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
@@ -1280,17 +1507,17 @@ export default function DayPlanSidebar({
const tm = getMergedItems(day.id)
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null)
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
}
}}
onContextMenu={e => ctxMenu.open(e, [
onContextMenu={canEditDays ? e => ctxMenu.open(e, [
{ label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
{ divider: true },
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
])}
]) : undefined}
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
onMouseLeave={() => setHoveredId(null)}
style={{
@@ -1304,9 +1531,9 @@ export default function DayPlanSidebar({
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
}}
>
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>
</div>}
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
<NoteIcon size={13} strokeWidth={1.8} color="var(--text-muted)" />
</div>
@@ -1315,17 +1542,17 @@ export default function DayPlanSidebar({
{note.text}
</span>
{note.time && (
<div style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}>{note.time}</div>
<div className="collab-note-md" style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{note.time}</Markdown></div>
)}
</div>
<div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
<button onClick={e => openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Pencil size={10} /></button>
<button onClick={e => deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Trash2 size={10} /></button>
</div>
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
</div>}
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
</div>
</div>}
</div>
</React.Fragment>
)
@@ -1345,11 +1572,11 @@ export default function DayPlanSidebar({
}
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
if (assignmentId && fromDayId !== day.id) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
if (noteId && fromDayId !== day.id) {
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
const m = getMergedItems(day.id)
@@ -1406,7 +1633,7 @@ export default function DayPlanSidebar({
{/* Notiz-Popup-Modal — über Portal gerendert, um den backdropFilter-Stapelkontext zu umgehen */}
{Object.entries(noteUi).map(([dayId, ui]) => ui && ReactDOM.createPortal(
<div key={dayId} style={{
position: 'fixed', inset: 0, zIndex: 1000,
position: 'fixed', inset: 0, zIndex: 10000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
}} onClick={() => cancelNote(Number(dayId))}>
@@ -1423,8 +1650,8 @@ export default function DayPlanSidebar({
{NOTE_ICONS.map(({ id, Icon }) => (
<button key={id} onClick={() => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], icon: id } }))}
title={id}
style={{ width: 34, height: 34, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', background: ui.icon === id ? 'var(--bg-hover)' : 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
<Icon size={15} strokeWidth={1.8} color={ui.icon === id ? 'var(--text-primary)' : 'var(--text-muted)'} />
style={{ width: 45, height: 45, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', background: ui.icon === id ? 'var(--bg-hover)' : 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
<Icon size={18} strokeWidth={1.8} color={ui.icon === id ? 'var(--text-primary)' : 'var(--text-muted)'} />
</button>
))}
</div>
@@ -1434,8 +1661,9 @@ export default function DayPlanSidebar({
value={ui.text}
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], text: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }}
placeholder={t('dayplan.noteTitle')}
style={{ fontSize: 13, fontWeight: 500, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
placeholder={t('dayplan.noteTitle') + ' *'}
required
style={{ fontSize: 13, fontWeight: 500, border: `1px solid ${!ui.text?.trim() ? 'var(--border-primary)' : 'var(--border-primary)'}`, borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
/>
<textarea
value={ui.time}
@@ -1446,10 +1674,10 @@ export default function DayPlanSidebar({
placeholder={t('dayplan.noteSubtitle')}
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)', resize: 'none', lineHeight: 1.4 }}
/>
<div style={{ textAlign: 'right', fontSize: 9, color: (ui.time?.length || 0) >= 140 ? '#d97706' : 'var(--text-faint)', marginTop: -2 }}>{ui.time?.length || 0}/150</div>
<div style={{ textAlign: 'right', fontSize: 11, color: (ui.time?.length || 0) >= 140 ? '#d97706' : 'var(--text-faint)', marginTop: -2 }}>{ui.time?.length || 0}/150</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => cancelNote(Number(dayId))} style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={() => saveNote(Number(dayId))} style={{ fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit' }}>
<button onClick={() => saveNote(Number(dayId))} disabled={!ui.text?.trim()} style={{ fontSize: 12, background: !ui.text?.trim() ? 'var(--border-primary)' : 'var(--accent)', color: !ui.text?.trim() ? 'var(--text-faint)' : 'var(--accent-text)', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: !ui.text?.trim() ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
{ui.mode === 'add' ? t('common.add') : t('common.save')}
</button>
</div>
@@ -1550,7 +1778,7 @@ export default function DayPlanSidebar({
{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' })
? new Date(res.reservation_time + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
: ''
}
{res.reservation_end_time?.includes('T') && ` ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}
@@ -1594,13 +1822,13 @@ export default function DayPlanSidebar({
{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 className="collab-note-md" style={{ fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>
</div>
)}
{/* Dateien */}
{(() => {
const resFiles = (tripStore.files || []).filter(f =>
const resFiles = (useTripStore.getState().files || []).filter(f =>
!f.deleted_at && (
f.reservation_id === res.id ||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(res.id))
@@ -1661,4 +1889,6 @@ export default function DayPlanSidebar({
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
</div>
)
}
})
export default DayPlanSidebar
@@ -3,6 +3,8 @@ import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { mapsApi } from '../../api/client'
import { useAuthStore } from '../../store/authStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
@@ -66,6 +68,9 @@ export default function PlaceFormModal({
const toast = useToast()
const { t, language } = useTranslation()
const { hasMapsKey } = useAuthStore()
const can = useCanDo()
const tripObj = useTripStore((s) => s.trip)
const canUploadFiles = can('file_upload', tripObj)
useEffect(() => {
if (place) {
@@ -171,6 +176,7 @@ export default function PlaceFormModal({
// Paste support for files/images
const handlePaste = (e) => {
if (!canUploadFiles) return
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
@@ -386,7 +392,7 @@ export default function PlaceFormModal({
</div>
{/* File Attachments */}
{true && (
{canUploadFiles && (
<div className="border border-gray-200 rounded-xl p-3 space-y-2">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700">{t('files.title')}</label>
+104 -11
View File
@@ -1,5 +1,8 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users } from 'lucide-react'
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { getAuthUrl } from '../../api/authUrl'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { mapsApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
@@ -116,7 +119,7 @@ interface PlaceInspectorProps {
onAssignToDay: (placeId: number, dayId: number) => void
onRemoveAssignment: (assignmentId: number, dayId: number) => void
files: TripFile[]
onFileUpload: (fd: FormData) => Promise<void>
onFileUpload?: (fd: FormData) => Promise<void>
tripMembers?: TripMember[]
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
@@ -339,10 +342,8 @@ export default function PlaceInspector({
{/* Description / Summary */}
{(place.description || place.notes || googleDetails?.summary) && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px' }}>
{place.description || place.notes || googleDetails?.summary}
</p>
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.notes || googleDetails?.summary || ''}</Markdown>
</div>
)}
@@ -372,7 +373,7 @@ export default function PlaceInspector({
{res.reservation_time && (
<div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date((res.reservation_time.includes('T') ? res.reservation_time.split('T')[0] : res.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
</div>
)}
{res.reservation_time?.includes('T') && (
@@ -391,7 +392,7 @@ export default function PlaceInspector({
</div>
)}
</div>
{res.notes && <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}>{res.notes}</div>}
{res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
@@ -461,6 +462,98 @@ export default function PlaceInspector({
)}
{/* GPX Track stats */}
{place.route_geometry && (() => {
try {
const pts: number[][] = JSON.parse(place.route_geometry)
if (!pts || pts.length < 2) return null
const hasEle = pts[0].length >= 3
// Haversine distance
const toRad = (d: number) => d * Math.PI / 180
let totalDist = 0
for (let i = 1; i < pts.length; i++) {
const [lat1, lng1] = pts[i - 1], [lat2, lng2] = pts[i]
const dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1)
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2
totalDist += 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
const distKm = totalDist / 1000
// Elevation stats
let minEle = Infinity, maxEle = -Infinity, totalUp = 0, totalDown = 0
if (hasEle) {
for (let i = 0; i < pts.length; i++) {
const e = pts[i][2]
if (e < minEle) minEle = e
if (e > maxEle) maxEle = e
if (i > 0) {
const diff = e - pts[i - 1][2]
if (diff > 0) totalUp += diff; else totalDown += Math.abs(diff)
}
}
}
// Elevation profile SVG
const chartW = 280, chartH = 60
const elevations = hasEle ? pts.map(p => p[2]) : []
let pathD = ''
if (elevations.length > 1) {
const step = Math.max(1, Math.floor(elevations.length / chartW))
const sampled = elevations.filter((_, i) => i % step === 0)
const eMin = Math.min(...sampled), eMax = Math.max(...sampled)
const range = eMax - eMin || 1
pathD = sampled.map((e, i) => {
const x = (i / (sampled.length - 1)) * chartW
const y = chartH - ((e - eMin) / range) * (chartH - 4) - 2
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
}
return (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<TrendingUp size={13} color="#9ca3af" />
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{t('inspector.trackStats')}</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<MapPin size={12} color="#3b82f6" />
{distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`}
</div>
{hasEle && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<Mountain size={12} color="#22c55e" />
{Math.round(maxEle)} m
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<Mountain size={12} color="#ef4444" />
{Math.round(minEle)} m
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{Math.round(totalUp)} m &nbsp;{Math.round(totalDown)} m
</div>
</>
)}
</div>
{pathD && (
<svg width="100%" viewBox={`0 0 ${chartW} ${chartH}`} preserveAspectRatio="none" style={{ display: 'block', borderRadius: 6, background: 'var(--bg-tertiary)' }}>
<defs>
<linearGradient id={`ele-grad-${place.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.25" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.02" />
</linearGradient>
</defs>
<path d={`${pathD} L${chartW},${chartH} L0,${chartH} Z`} fill={`url(#ele-grad-${place.id})`} />
<path d={pathD} fill="none" stroke="#3b82f6" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
</svg>
)}
</div>
)
} catch { return null }
})()}
{/* Files section */}
{(placeFiles.length > 0 || onFileUpload) && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
@@ -489,11 +582,11 @@ export default function PlaceInspector({
{filesExpanded && placeFiles.length > 0 && (
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{placeFiles.map(f => (
<a key={f.id} href={`/uploads/files/${f.filename}`} target="_blank" rel="noopener noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer' }}>
<button key={f.id} onClick={async () => { const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer', background: 'none', border: 'none', width: '100%', textAlign: 'left' }}>
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
</a>
</button>
))}
</div>
)}
+206 -59
View File
@@ -2,7 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom'
import { useState, useRef, useMemo, useCallback } from 'react'
import DOM from 'react-dom'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check } from 'lucide-react'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
@@ -11,6 +11,7 @@ import CustomSelect from '../shared/CustomSelect'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import { placesApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
interface PlacesSidebarProps {
@@ -28,17 +29,21 @@ interface PlacesSidebarProps {
days: Day[]
isMobile: boolean
onCategoryFilterChange?: (categoryId: string) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
}
export default function PlacesSidebar({
const PlacesSidebar = React.memo(function PlacesSidebar({
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange,
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange, pushUndo,
}: PlacesSidebarProps) {
const { t } = useTranslation()
const toast = useToast()
const ctxMenu = useContextMenu()
const gpxInputRef = useRef<HTMLInputElement>(null)
const tripStore = useTripStore()
const trip = useTripStore((s) => s.trip)
const loadTrip = useTripStore((s) => s.loadTrip)
const can = useCanDo()
const canEditPlaces = can('place_edit', trip)
const handleGpxImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
@@ -46,12 +51,51 @@ export default function PlacesSidebar({
e.target.value = ''
try {
const result = await placesApi.importGpx(tripId, file)
await tripStore.loadTrip(tripId)
await loadTrip(tripId)
toast.success(t('places.gpxImported', { count: result.count }))
if (result.places?.length > 0) {
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
pushUndo?.(t('undo.importGpx'), async () => {
for (const id of importedIds) {
try { await placesApi.delete(tripId, id) } catch {}
}
await loadTrip(tripId)
})
}
} catch (err: any) {
toast.error(err?.response?.data?.error || t('places.gpxError'))
}
}
const [googleListOpen, setGoogleListOpen] = useState(false)
const [googleListUrl, setGoogleListUrl] = useState('')
const [googleListLoading, setGoogleListLoading] = useState(false)
const handleGoogleListImport = async () => {
if (!googleListUrl.trim()) return
setGoogleListLoading(true)
try {
const result = await placesApi.importGoogleList(tripId, googleListUrl.trim())
await loadTrip(tripId)
toast.success(t('places.googleListImported', { count: result.count, list: result.listName }))
setGoogleListOpen(false)
setGoogleListUrl('')
if (result.places?.length > 0) {
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
pushUndo?.(t('undo.importGoogleList'), async () => {
for (const id of importedIds) {
try { await placesApi.delete(tripId, id) } catch {}
}
await loadTrip(tripId)
})
}
} catch (err: any) {
toast.error(err?.response?.data?.error || t('places.googleListError'))
} finally {
setGoogleListLoading(false)
}
}
const [search, setSearch] = useState('')
const [filter, setFilter] = useState('all')
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
@@ -67,11 +111,12 @@ export default function PlacesSidebar({
}
const [dayPickerPlace, setDayPickerPlace] = useState(null)
const [catDropOpen, setCatDropOpen] = useState(false)
const [mobileShowDays, setMobileShowDays] = useState(false)
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
const plannedIds = new Set(
const plannedIds = useMemo(() => new Set(
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
)
), [assignments])
const filtered = useMemo(() => places.filter(p => {
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
@@ -79,7 +124,7 @@ export default function PlacesSidebar({
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
return true
}), [places, filter, categoryFilters, search, plannedIds.size])
}), [places, filter, categoryFilters, search, plannedIds])
const isAssignedToSelectedDay = (placeId) =>
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
@@ -88,7 +133,7 @@ export default function PlacesSidebar({
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Kopfbereich */}
<div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
<button
{canEditPlaces && <button
onClick={onAddPlace}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
@@ -98,20 +143,36 @@ export default function PlacesSidebar({
}}
>
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
</button>
</button>}
{canEditPlaces && <>
<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>
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
<button
onClick={() => gpxInputRef.current?.click()}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
flex: 1, padding: '5px 12px', borderRadius: 8,
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>
<button
onClick={() => setGoogleListOpen(true)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
flex: 1, padding: '5px 12px', borderRadius: 8,
border: '1px dashed var(--border-primary)', background: 'none',
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<MapPin size={11} strokeWidth={2} /> {t('places.importGoogleList')}
</button>
</div>
</>}
{/* Filter-Tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
@@ -223,9 +284,9 @@ export default function PlacesSidebar({
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
</span>
<button onClick={onAddPlace} style={{ fontSize: 12, color: 'var(--text-primary)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
{canEditPlaces && <button onClick={onAddPlace} style={{ fontSize: 12, color: 'var(--text-primary)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
{t('places.addPlace')}
</button>
</button>}
</div>
) : (
filtered.map(place => {
@@ -245,19 +306,19 @@ export default function PlacesSidebar({
window.__dragData = { placeId: String(place.id) }
}}
onClick={() => {
if (isMobile && days?.length > 0) {
if (isMobile) {
setDayPickerPlace(place)
} else {
onPlaceClick(isSelected ? null : place.id)
}
}}
onContextMenu={e => ctxMenu.open(e, [
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
{ divider: true },
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])}
style={{
display: 'flex', alignItems: 'center', gap: 10,
@@ -312,49 +373,133 @@ export default function PlacesSidebar({
)}
</div>
{dayPickerPlace && days?.length > 0 && ReactDOM.createPortal(
{dayPickerPlace && ReactDOM.createPortal(
<div
onClick={() => setDayPickerPlace(null)}
onClick={() => { setDayPickerPlace(null); setMobileShowDays(false) }}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}
>
<div
onClick={e => e.stopPropagation()}
style={{ background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 500, maxHeight: '60vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingBottom: 'env(safe-area-inset-bottom)' }}
style={{ background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 500, maxHeight: '70vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingBottom: 'env(safe-area-inset-bottom)' }}
>
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-secondary)' }}>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>{dayPickerPlace.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2 }}>{t('places.assignToDay')}</div>
{dayPickerPlace.address && <div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2 }}>{dayPickerPlace.address}</div>}
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 16px' }}>
{days.map((day, i) => {
return (
<div style={{ overflowY: 'auto', padding: '8px 12px' }}>
{/* View details */}
<button
onClick={() => { onPlaceClick(dayPickerPlace.id); setDayPickerPlace(null); setMobileShowDays(false) }}
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: 'var(--text-primary)' }}
>
<Eye size={18} color="var(--text-muted)" /> {t('places.viewDetails')}
</button>
{/* Edit */}
{canEditPlaces && (
<button
onClick={() => { onEditPlace(dayPickerPlace); setDayPickerPlace(null); setMobileShowDays(false) }}
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: 'var(--text-primary)' }}
>
<Pencil size={18} color="var(--text-muted)" /> {t('common.edit')}
</button>
)}
{/* Assign to day */}
{days?.length > 0 && (
<>
<button
key={day.id}
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null) }}
style={{
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer',
background: 'transparent', fontFamily: 'inherit', textAlign: 'left',
transition: 'background 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
onClick={() => setMobileShowDays(v => !v)}
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: 'var(--text-primary)' }}
>
<div style={{
width: 32, height: 32, borderRadius: '50%', background: 'var(--bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 700, color: 'var(--text-primary)', flexShrink: 0,
}}>{i + 1}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
{day.title || `${t('dayplan.dayN', { n: i + 1 })}`}
</div>
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00').toLocaleDateString()}</div>}
</div>
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}></span>}
<CalendarDays size={18} color="var(--text-muted)" /> {t('places.assignToDay')}
<ChevronDown size={14} style={{ marginLeft: 'auto', color: 'var(--text-faint)', transform: mobileShowDays ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
</button>
)
})}
{mobileShowDays && (
<div style={{ paddingLeft: 20 }}>
{days.map((day, i) => (
<button
key={day.id}
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null); setMobileShowDays(false) }}
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '10px 14px', borderRadius: 10, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left' }}
>
<div style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', flexShrink: 0 }}>{i + 1}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{day.title || t('dayplan.dayN', { n: i + 1 })}</div>
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { timeZone: 'UTC' })}</div>}
</div>
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <Check size={14} color="var(--text-faint)" />}
</button>
))}
</div>
)}
</>
)}
{/* Delete */}
{canEditPlaces && (
<button
onClick={() => { onDeletePlace(dayPickerPlace.id); setDayPickerPlace(null); setMobileShowDays(false) }}
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer', background: 'transparent', fontFamily: 'inherit', textAlign: 'left', fontSize: 14, color: '#ef4444' }}
>
<Trash2 size={18} /> {t('common.delete')}
</button>
)}
</div>
</div>
</div>,
document.body
)}
{googleListOpen && ReactDOM.createPortal(
<div
onClick={() => { setGoogleListOpen(false); setGoogleListUrl('') }}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
>
<div
onClick={e => e.stopPropagation()}
style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 440, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}
>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 4 }}>
{t('places.importGoogleList')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 16 }}>
{t('places.googleListHint')}
</div>
<input
type="text"
value={googleListUrl}
onChange={e => setGoogleListUrl(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !googleListLoading) handleGoogleListImport() }}
placeholder="https://maps.app.goo.gl/..."
autoFocus
style={{
width: '100%', padding: '10px 14px', borderRadius: 10,
border: '1px solid var(--border-primary)', background: 'var(--bg-tertiary)',
fontSize: 13, color: 'var(--text-primary)', outline: 'none',
fontFamily: 'inherit', boxSizing: 'border-box',
}}
/>
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
<button
onClick={() => { setGoogleListOpen(false); setGoogleListUrl('') }}
style={{
padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)',
background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
{t('common.cancel')}
</button>
<button
onClick={handleGoogleListImport}
disabled={!googleListUrl.trim() || googleListLoading}
style={{
padding: '8px 16px', borderRadius: 10, border: 'none',
background: !googleListUrl.trim() || googleListLoading ? 'var(--bg-tertiary)' : 'var(--accent)',
color: !googleListUrl.trim() || googleListLoading ? 'var(--text-faint)' : 'var(--accent-text)',
fontSize: 13, fontWeight: 500, cursor: !googleListUrl.trim() || googleListLoading ? 'default' : 'pointer',
fontFamily: 'inherit',
}}
>
{googleListLoading ? t('common.loading') : t('common.import')}
</button>
</div>
</div>
</div>,
@@ -363,4 +508,6 @@ export default function PlacesSidebar({
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
</div>
)
}
})
export default PlacesSidebar
@@ -2,6 +2,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 { useAddonStore } from '../../store/addonStore'
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'
@@ -9,6 +10,7 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import { getAuthUrl } from '../../api/authUrl'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
const TYPE_OPTIONS = [
@@ -59,7 +61,7 @@ interface ReservationModalProps {
assignments: AssignmentsMap
selectedDayId: number | null
files?: TripFile[]
onFileUpload: (fd: FormData) => Promise<void>
onFileUpload?: (fd: FormData) => Promise<void>
onFileDelete: (fileId: number) => Promise<void>
accommodations?: Accommodation[]
}
@@ -71,11 +73,21 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const { t, locale } = useTranslation()
const fileInputRef = useRef(null)
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [budgetItems])
const [form, setForm] = useState({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: '', accommodation_id: '',
price: '', budget_category: '',
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
meta_departure_timezone: '', meta_arrival_timezone: '',
meta_train_number: '', meta_platform: '', meta_seat: '',
meta_check_in_time: '', meta_check_out_time: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
@@ -95,12 +107,24 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
useEffect(() => {
if (reservation) {
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
// Parse end_date from reservation_end_time if it's a full ISO datetime
const rawEnd = reservation.reservation_end_time || ''
let endDate = ''
let endTime = rawEnd
if (rawEnd.includes('T')) {
endDate = rawEnd.split('T')[0]
endTime = rawEnd.split('T')[1]?.slice(0, 5) || ''
} else if (/^\d{4}-\d{2}-\d{2}$/.test(rawEnd)) {
endDate = rawEnd
endTime = ''
}
setForm({
title: reservation.title || '',
type: reservation.type || 'other',
status: reservation.status || 'pending',
reservation_time: reservation.reservation_time ? reservation.reservation_time.slice(0, 16) : '',
reservation_end_time: reservation.reservation_end_time || '',
reservation_end_time: endTime,
end_date: endDate,
location: reservation.location || '',
confirmation_number: reservation.confirmation_number || '',
notes: reservation.notes || '',
@@ -110,6 +134,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
meta_flight_number: meta.flight_number || '',
meta_departure_airport: meta.departure_airport || '',
meta_arrival_airport: meta.arrival_airport || '',
meta_departure_timezone: meta.departure_timezone || '',
meta_arrival_timezone: meta.arrival_timezone || '',
meta_train_number: meta.train_number || '',
meta_platform: meta.platform || '',
meta_seat: meta.seat || '',
@@ -118,13 +144,17 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
price: meta.price || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
})
} else {
setForm({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: '', accommodation_id: '',
price: '', budget_category: '',
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
meta_departure_timezone: '', meta_arrival_timezone: '',
meta_train_number: '', meta_platform: '', meta_seat: '',
meta_check_in_time: '', meta_check_out_time: '',
})
@@ -134,9 +164,37 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
// Validate that end datetime is after start datetime
const isEndBeforeStart = (() => {
if (!form.end_date || !form.reservation_time) return false
const startDate = form.reservation_time.split('T')[0]
const startTime = form.reservation_time.split('T')[1] || '00:00'
const endTime = form.reservation_end_time || '00:00'
// For flights, compare in UTC using timezone offsets
if (form.type === 'flight') {
const parseOffset = (tz: string): number | null => {
if (!tz) return null
const m = tz.trim().match(/^(?:UTC|GMT)?\s*([+-])(\d{1,2})(?::(\d{2}))?$/i)
if (!m) return null
const sign = m[1] === '+' ? 1 : -1
return sign * (parseInt(m[2]) * 60 + parseInt(m[3] || '0'))
}
const depOffset = parseOffset(form.meta_departure_timezone)
const arrOffset = parseOffset(form.meta_arrival_timezone)
if (depOffset === null || arrOffset === null) return false
const depMinutes = new Date(`${startDate}T${startTime}`).getTime() - depOffset * 60000
const arrMinutes = new Date(`${form.end_date}T${endTime}`).getTime() - arrOffset * 60000
return arrMinutes <= depMinutes
}
const startFull = `${startDate}T${startTime}`
const endFull = `${form.end_date}T${endTime}`
return endFull <= startFull
})()
const handleSubmit = async (e) => {
e.preventDefault()
if (!form.title.trim()) return
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
setIsSaving(true)
try {
const metadata: Record<string, string> = {}
@@ -145,6 +203,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
if (form.meta_departure_airport) metadata.departure_airport = form.meta_departure_airport
if (form.meta_arrival_airport) metadata.arrival_airport = form.meta_arrival_airport
if (form.meta_departure_timezone) metadata.departure_timezone = form.meta_departure_timezone
if (form.meta_arrival_timezone) metadata.arrival_timezone = form.meta_arrival_timezone
} else if (form.type === 'hotel') {
if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time
if (form.meta_check_out_time) metadata.check_out_time = form.meta_check_out_time
@@ -153,15 +213,31 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat
}
// Combine end_date + end_time into reservation_end_time
let combinedEndTime = form.reservation_end_time
if (form.end_date) {
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
}
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const saveData: Record<string, any> = {
title: form.title, type: form.type, status: form.status,
reservation_time: form.reservation_time, reservation_end_time: form.reservation_end_time,
reservation_time: form.type === 'hotel' ? null : form.reservation_time,
reservation_end_time: form.type === 'hotel' ? null : combinedEndTime,
location: form.location, confirmation_number: form.confirmation_number,
notes: form.notes,
assignment_id: form.assignment_id || null,
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
metadata: Object.keys(metadata).length > 0 ? metadata : null,
}
// Auto-create/update budget entry if price is set, or signal removal if cleared
if (isBudgetEnabled) {
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
// If hotel with place + days, pass hotel data for auto-creation or update
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
saveData.create_accommodation = {
@@ -257,10 +333,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
</div>
{/* Assignment Picker + Date (hidden for hotels) */}
{form.type !== 'hotel' && (
<div style={{ display: 'flex', gap: 8 }}>
{assignmentOptions.length > 0 && (
{/* Assignment Picker (hidden for hotels) */}
{form.type !== 'hotel' && assignmentOptions.length > 0 && (
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>
<Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
@@ -287,54 +362,82 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
size="sm"
/>
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.date')}</label>
<CustomDatePicker
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
onChange={d => {
const [, t] = (form.reservation_time || '').split('T')
set('reservation_time', d ? (t ? `${d}T${t}` : d) : '')
}}
/>
</div>
</div>
)}
{/* Start Time + End Time + Status */}
<div style={{ display: 'flex', gap: 8 }}>
{form.type !== 'hotel' && (
<>
{/* Start Date/Time + End Date/Time + Status (hidden for hotels) */}
{form.type !== 'hotel' && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}</label>
<CustomDatePicker
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
onChange={d => {
const [, t] = (form.reservation_time || '').split('T')
set('reservation_time', d ? (t ? `${d}T${t}` : d) : '')
}}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.departureTime') : form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}</label>
<CustomTimePicker
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
onChange={t => {
const [d] = (form.reservation_time || '').split('T')
const selectedDay = days.find(dy => dy.id === selectedDayId)
const date = d || selectedDay?.date || new Date().toISOString().split('T')[0]
set('reservation_time', t ? `${date}T${t}` : date)
}}
/>
</div>
{form.type === 'flight' && (
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.startTime')}</label>
<CustomTimePicker
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
onChange={t => {
const [d] = (form.reservation_time || '').split('T')
const date = d || new Date().toISOString().split('T')[0]
set('reservation_time', t ? `${date}T${t}` : date)
}}
/>
<label style={labelStyle}>{t('reservations.meta.departureTimezone')}</label>
<input type="text" value={form.meta_departure_timezone} onChange={e => set('meta_departure_timezone', e.target.value)}
placeholder="e.g. CET, UTC+1" style={inputStyle} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.endTime')}</label>
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
</div>
</>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}</label>
<CustomDatePicker
value={form.end_date}
onChange={d => set('end_date', d || '')}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}</label>
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
</div>
{form.type === 'flight' && (
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.meta.arrivalTimezone')}</label>
<input type="text" value={form.meta_arrival_timezone} onChange={e => set('meta_arrival_timezone', e.target.value)}
placeholder="e.g. JST, UTC+9" style={inputStyle} />
</div>
)}
</div>
{isEndBeforeStart && (
<div style={{ fontSize: 11, color: '#ef4444', marginTop: -6 }}>{t('reservations.validation.endBeforeStart')}</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
</div>
</div>
</>
)}
{/* Location + Booking Code */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
@@ -422,8 +525,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
/>
</div>
</div>
{/* Check-in/out times */}
<div className="grid grid-cols-2 gap-3">
{/* Check-in/out times + Status */}
<div className="grid grid-cols-3 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.checkIn')}</label>
<CustomTimePicker value={form.meta_check_in_time} onChange={v => set('meta_check_in_time', v)} />
@@ -432,6 +535,18 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<label style={labelStyle}>{t('reservations.meta.checkOut')}</label>
<CustomTimePicker value={form.meta_check_out_time} onChange={v => set('meta_check_out_time', v)} />
</div>
<div>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
</div>
</div>
</>
)}
@@ -472,7 +587,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}><ExternalLink size={11} /></a>
<a href="#" onClick={async (e) => { e.preventDefault(); const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noreferrer') }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
<button type="button" onClick={async () => {
// Always unlink, never delete the file
// Clear primary reservation_id if it points to this reservation
@@ -504,14 +619,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
))}
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
{onFileUpload && <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>
</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' }}>
@@ -556,12 +671,45 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div>
</div>
{/* Price + Budget Category — only shown when budget addon is enabled */}
{isBudgetEnabled && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.price')}</label>
<input type="text" inputMode="decimal" value={form.price}
onChange={e => { const v = e.target.value; if (v === '' || /^\d*\.?\d{0,2}$/.test(v)) set('price', v) }}
placeholder="0.00"
style={inputStyle} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
<CustomSelect
value={form.budget_category}
onChange={v => set('budget_category', v)}
options={[
{ value: '', label: t('reservations.budgetCategoryAuto') },
...budgetCategories.map(c => ({ value: c, label: c })),
]}
placeholder={t('reservations.budgetCategoryAuto')}
size="sm"
/>
</div>
</div>
{form.price && parseFloat(form.price) > 0 && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
{t('reservations.budgetHint')}
</div>
)}
</>
)}
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
<button type="submit" disabled={isSaving || !form.title.trim() || isEndBeforeStart} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() || isEndBeforeStart ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
@@ -572,6 +720,6 @@ 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 || undefined, { day: 'numeric', month: 'short' })
const d = new Date(dateStr + 'T00:00:00Z')
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short', timeZone: 'UTC' })
}
@@ -1,6 +1,7 @@
import { useState, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
@@ -9,6 +10,7 @@ import {
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
} from 'lucide-react'
import { getAuthUrl } from '../../api/authUrl'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
interface AssignmentLookupEntry {
@@ -56,9 +58,10 @@ interface ReservationCardProps {
files?: TripFile[]
onNavigateToFiles: () => void
assignmentLookup: Record<number, AssignmentLookupEntry>
canEdit: boolean
}
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }: ReservationCardProps) {
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit }: ReservationCardProps) {
const { toggleReservationStatus } = useTripStore()
const toast = useToast()
const { t, locale } = useTranslation()
@@ -82,8 +85,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
}
const fmtDate = (str) => {
const d = new Date(str)
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
const dateOnly = str.includes('T') ? str.split('T')[0] : str
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
}
const fmtTime = (str) => {
const d = new Date(str)
@@ -95,24 +98,34 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{/* Header bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)' }}>
<div style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
</button>
{canEdit ? (
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
</button>
) : (
<span style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', padding: 0 }}>
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
</span>
)}
<div style={{ width: 1, height: 10, background: 'var(--border-faint)' }} />
<TypeIcon size={11} style={{ color: typeInfo.color, flexShrink: 0 }} />
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{t(typeInfo.labelKey)}</span>
<span style={{ flex: 1 }} />
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={11} />
</button>
<button onClick={() => 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} />
</button>
{canEdit && (
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={11} />
</button>
)}
{canEdit && (
<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} />
</button>
)}
</div>
{/* Details */}
@@ -124,14 +137,19 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{r.reservation_time && (
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.date')}</div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{fmtDate(r.reservation_time)}</div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
{fmtDate(r.reservation_time)}
{r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
<> {fmtDate(r.reservation_end_time)}</>
)}
</div>
</div>
)}
{r.reservation_time?.includes('T') && (
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` ${r.reservation_end_time}` : ''}
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''}
</div>
</div>
)}
@@ -167,8 +185,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: meta.check_in_time })
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: meta.check_out_time })
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) })
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) })
if (cells.length === 0) return null
return (
<div style={{ display: 'flex', gap: 0, borderRadius: 8, overflow: 'hidden', background: 'var(--bg-secondary)', boxShadow: '0 1px 6px rgba(0,0,0,0.08)' }}>
@@ -235,7 +253,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('files.title')}</div>
<div style={{ padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', display: 'flex', flexDirection: 'column', gap: 3 }}>
{attachedFiles.map(f => (
<a key={f.id} href={f.url} target="_blank" rel="noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
<a key={f.id} href="#" onClick={async (e) => { e.preventDefault(); const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noreferrer') }} style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
<FileText size={9} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
</a>
@@ -330,6 +348,9 @@ interface ReservationsPanelProps {
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
const { t, locale } = useTranslation()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('reservation_edit', trip)
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
@@ -348,13 +369,15 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
</p>
</div>
<button onClick={onAdd} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}>
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
</button>
{canEdit && (
<button onClick={onAdd} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}>
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
</button>
)}
</div>
{/* Content */}
@@ -370,14 +393,14 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
{allPending.length > 0 && (
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
</div>
</Section>
)}
{allConfirmed.length > 0 && (
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
</div>
</Section>
)}
+146
View File
@@ -0,0 +1,146 @@
import React from 'react'
import { Info, Coffee, Heart, ExternalLink, Bug, Lightbulb, BookOpen } from 'lucide-react'
import { useTranslation } from '../../i18n'
import Section from './Section'
interface Props {
appVersion: string
}
export default function AboutTab({ appVersion }: Props): React.ReactElement {
const { t } = useTranslation()
return (
<Section title={t('settings.about')} icon={Info}>
<style>{`
@keyframes heartPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
`}</style>
<p style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-secondary)', marginBottom: 6, marginTop: -4 }}>
{t('settings.about.description')}
</p>
<p style={{ fontSize: 12, lineHeight: 1.6, color: 'var(--text-faint)', marginBottom: 16 }}>
{t('settings.about.madeWith')}{' '}
<Heart size={11} fill="#991b1b" stroke="#991b1b" style={{ display: 'inline-block', verticalAlign: '-1px', animation: 'heartPulse 1.5s ease-in-out infinite' }} />
{' '}{t('settings.about.madeBy')}{' '}
<span style={{ display: 'inline-flex', alignItems: 'center', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '1px 7px', fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', verticalAlign: '1px' }}>v{appVersion}</span>
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<a
href="https://ko-fi.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ff5e5b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Coffee size={20} style={{ color: '#ff5e5b' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Ko-fi</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://buymeacoffee.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ffdd0015', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Heart size={20} style={{ color: '#ffdd00' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Buy Me a Coffee</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://discord.gg/nSdKaXgN"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#5865F215', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-3">
<a
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ef444415', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Bug size={20} style={{ color: '#ef4444' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.reportBug')}</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.reportBugHint')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#f59e0b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Lightbulb size={20} style={{ color: '#f59e0b' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.featureRequest')}</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.featureRequestHint')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://github.com/mauriceboe/TREK/wiki"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#6366f115', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<BookOpen size={20} style={{ color: '#6366f1' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Wiki</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.wikiHint')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
</div>
</Section>
)
}
@@ -0,0 +1,598 @@
import React, { useState, useEffect } from 'react'
import { User, Save, Lock, KeyRound, AlertTriangle, Shield, Camera, Trash2, Copy, Download, Printer } from 'lucide-react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useTranslation } from '../../i18n'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast'
import { authApi, adminApi } from '../../api/client'
import { getApiErrorMessage } from '../../types'
import type { UserWithOidc } from '../../types'
import Section from './Section'
const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending'
export default function AccountTab(): React.ReactElement {
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore()
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const { t } = useTranslation()
const toast = useToast()
const avatarInputRef = React.useRef<HTMLInputElement>(null)
const [saving, setSaving] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
// Profile
const [username, setUsername] = useState<string>(user?.username || '')
const [email, setEmail] = useState<string>(user?.email || '')
useEffect(() => {
setUsername(user?.username || '')
setEmail(user?.email || '')
}, [user])
// Password
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [oidcOnlyMode, setOidcOnlyMode] = useState(false)
useEffect(() => {
authApi.getAppConfig?.().then(config => {
if (config?.oidc_only_mode) setOidcOnlyMode(true)
}).catch(() => {})
}, [])
// MFA
const [mfaQr, setMfaQr] = useState<string | null>(null)
const [mfaSecret, setMfaSecret] = useState<string | null>(null)
const [mfaSetupCode, setMfaSetupCode] = useState('')
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
const [mfaDisableCode, setMfaDisableCode] = useState('')
const [mfaLoading, setMfaLoading] = useState(false)
const [backupCodes, setBackupCodes] = useState<string[] | null>(null)
const mfaRequiredByPolicy =
!demoMode &&
!user?.mfa_enabled &&
(searchParams.get('mfa') === 'required' || appRequireMfa)
const backupCodesText = backupCodes?.join('\n') || ''
useEffect(() => {
if (!user?.mfa_enabled || backupCodes) return
try {
const raw = sessionStorage.getItem(MFA_BACKUP_SESSION_KEY)
if (!raw) return
const parsed = JSON.parse(raw) as unknown
if (Array.isArray(parsed) && parsed.length > 0 && parsed.every(x => typeof x === 'string')) {
setBackupCodes(parsed)
}
} catch {
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
}
}, [user?.mfa_enabled, backupCodes])
const dismissBackupCodes = () => {
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
setBackupCodes(null)
}
const copyBackupCodes = async () => {
if (!backupCodesText) return
try {
await navigator.clipboard.writeText(backupCodesText)
toast.success(t('settings.mfa.backupCopied'))
} catch {
toast.error(t('common.error'))
}
}
const downloadBackupCodes = () => {
if (!backupCodesText) return
const blob = new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'trek-mfa-backup-codes.txt'
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
const printBackupCodes = () => {
if (!backupCodesText) return
const html = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title>
<style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style>
</head><body><h1>TREK MFA Backup Codes</h1><p>${new Date().toLocaleString()}</p><pre>${backupCodesText}</pre></body></html>`
const w = window.open('', '_blank', 'width=900,height=700')
if (!w) return
w.document.open()
w.document.write(html)
w.document.close()
w.focus()
w.print()
}
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
try {
await uploadAvatar(file)
toast.success(t('settings.avatarUploaded'))
} catch {
toast.error(t('settings.avatarError'))
}
if (avatarInputRef.current) avatarInputRef.current.value = ''
}
const handleAvatarRemove = async () => {
try {
await deleteAvatar()
toast.success(t('settings.avatarRemoved'))
} catch {
toast.error(t('settings.avatarError'))
}
}
const saveProfile = async () => {
setSaving(true)
try {
await updateProfile({ username, email })
toast.success(t('settings.toast.profileSaved'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Error')
} finally {
setSaving(false)
}
}
return (
<>
<Section title={t('settings.account')} icon={User}>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
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>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.email')}</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
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>
{/* Change Password */}
{!oidcOnlyMode && (
<div style={{ paddingTop: 16, marginTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
<label className="block text-sm font-medium text-slate-700 mb-3">{t('settings.changePassword')}</label>
<div className="space-y-3">
<input
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
placeholder={t('settings.currentPassword')}
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"
/>
<input
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder={t('settings.newPassword')}
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"
/>
<input
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
placeholder={t('settings.confirmPassword')}
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"
/>
<button
onClick={async () => {
if (!currentPassword) return toast.error(t('settings.currentPasswordRequired'))
if (!newPassword) return toast.error(t('settings.passwordRequired'))
if (newPassword.length < 8) return toast.error(t('settings.passwordTooShort'))
if (newPassword !== confirmPassword) return toast.error(t('settings.passwordMismatch'))
try {
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
toast.success(t('settings.passwordChanged'))
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
await loadUser({ silent: true })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<Lock size={14} />
{t('settings.updatePassword')}
</button>
</div>
</div>
)}
{/* MFA */}
<div style={{ paddingTop: 16, marginTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
<div className="flex items-center gap-2 mb-3">
<KeyRound className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
<h3 className="font-semibold text-base m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.title')}</h3>
</div>
<div className="space-y-3">
{mfaRequiredByPolicy && (
<div className="flex gap-3 p-3 rounded-lg border text-sm"
style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-amber-600" />
<p className="m-0 leading-relaxed">{t('settings.mfa.requiredByPolicy')}</p>
</div>
)}
<p className="text-sm m-0" style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.mfa.description')}</p>
{demoMode ? (
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
) : (
<>
<p className="text-sm font-medium m-0" style={{ color: 'var(--text-secondary)' }}>
{user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
</p>
{!user?.mfa_enabled && !mfaQr && (
<button
type="button"
disabled={mfaLoading}
onClick={async () => {
setMfaLoading(true)
try {
const data = await authApi.mfaSetup() as { qr_svg: string; secret: string }
setMfaQr(data.qr_svg)
setMfaSecret(data.secret)
setMfaSetupCode('')
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setMfaLoading(false)
}
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{mfaLoading ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> : <KeyRound size={14} />}
{t('settings.mfa.setup')}
</button>
)}
{!user?.mfa_enabled && mfaQr && (
<div className="space-y-3">
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.scanQr')}</p>
<div className="rounded-lg border mx-auto block overflow-hidden" style={{ width: 'fit-content', borderColor: 'var(--border-primary)' }} dangerouslySetInnerHTML={{ __html: mfaQr! }} />
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.secretLabel')}</label>
<code className="block text-xs p-2 rounded break-all" style={{ background: 'var(--bg-hover)', color: 'var(--text-primary)' }}>{mfaSecret}</code>
</div>
<input
type="text"
inputMode="numeric"
value={mfaSetupCode}
onChange={e => setMfaSetupCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder={t('settings.mfa.codePlaceholder')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={mfaLoading || mfaSetupCode.length < 6}
onClick={async () => {
setMfaLoading(true)
try {
const resp = await authApi.mfaEnable({ code: mfaSetupCode }) as { backup_codes?: string[] }
toast.success(t('settings.mfa.toastEnabled'))
setMfaQr(null)
setMfaSecret(null)
setMfaSetupCode('')
const codes = resp.backup_codes || null
if (codes?.length) {
try { sessionStorage.setItem(MFA_BACKUP_SESSION_KEY, JSON.stringify(codes)) } catch { /* ignore */ }
}
setBackupCodes(codes)
await loadUser({ silent: true })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setMfaLoading(false)
}
}}
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
>
{t('settings.mfa.enable')}
</button>
<button
type="button"
onClick={() => { setMfaQr(null); setMfaSecret(null); setMfaSetupCode('') }}
className="px-4 py-2 rounded-lg text-sm border"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}
>
{t('settings.mfa.cancelSetup')}
</button>
</div>
</div>
)}
{user?.mfa_enabled && (
<div className="space-y-3">
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.disableTitle')}</p>
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.disableHint')}</p>
<input
type="password"
value={mfaDisablePwd}
onChange={e => setMfaDisablePwd(e.target.value)}
placeholder={t('settings.currentPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
<input
type="text"
inputMode="numeric"
value={mfaDisableCode}
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder={t('settings.mfa.codePlaceholder')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
<button
type="button"
disabled={mfaLoading || !mfaDisablePwd || mfaDisableCode.length < 6}
onClick={async () => {
setMfaLoading(true)
try {
await authApi.mfaDisable({ password: mfaDisablePwd, code: mfaDisableCode })
toast.success(t('settings.mfa.toastDisabled'))
setMfaDisablePwd('')
setMfaDisableCode('')
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
setBackupCodes(null)
await loadUser({ silent: true })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setMfaLoading(false)
}
}}
className="px-4 py-2 rounded-lg text-sm font-medium text-red-600 border border-red-200 hover:bg-red-50 disabled:opacity-50"
>
{t('settings.mfa.disable')}
</button>
</div>
)}
{backupCodes && backupCodes.length > 0 && (
<div className="space-y-3 p-3 rounded-lg border" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-hover)' }}>
<p className="text-sm font-semibold m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.backupTitle')}</p>
<p className="text-xs m-0" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.backupDescription')}</p>
<pre className="text-xs m-0 p-2 rounded border overflow-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)', maxHeight: 220 }}>{backupCodesText}</pre>
<p className="text-xs m-0" style={{ color: '#b45309' }}>{t('settings.mfa.backupWarning')}</p>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={copyBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
<Copy size={13} /> {t('settings.mfa.backupCopy')}
</button>
<button type="button" onClick={downloadBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
<Download size={13} /> {t('settings.mfa.backupDownload')}
</button>
<button type="button" onClick={printBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
<Printer size={13} /> {t('settings.mfa.backupPrint')}
</button>
<button type="button" onClick={dismissBackupCodes} className="px-3 py-2 rounded-lg text-xs border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.ok')}
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
{/* Avatar */}
<div className="flex items-center gap-4">
<div style={{ position: 'relative', flexShrink: 0 }}>
{user?.avatar_url ? (
<img src={user.avatar_url} alt="" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover' }} />
) : (
<div style={{
width: 64, height: 64, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 24, fontWeight: 700,
background: 'var(--bg-hover)', color: 'var(--text-secondary)',
}}>
{user?.username?.charAt(0).toUpperCase()}
</div>
)}
<input ref={avatarInputRef} type="file" accept="image/*" onChange={handleAvatarUpload} style={{ display: 'none' }} />
<button
onClick={() => avatarInputRef.current?.click()}
style={{
position: 'absolute', bottom: -3, right: -3,
width: 28, height: 28, borderRadius: '50%',
background: 'var(--text-primary)', color: 'var(--bg-card)',
border: '2px solid var(--bg-card)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0, transition: 'transform 0.15s, opacity 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.15)'; e.currentTarget.style.opacity = '0.85' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.opacity = '1' }}
>
<Camera size={14} />
</button>
{user?.avatar_url && (
<button
onClick={handleAvatarRemove}
style={{
position: 'absolute', top: -2, right: -2,
width: 20, height: 20, borderRadius: '50%',
background: '#ef4444', color: 'white',
border: '2px solid var(--bg-card)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
}}
>
<Trash2 size={10} />
</button>
)}
</div>
<div className="flex flex-col gap-1">
<div className="text-sm" style={{ color: 'var(--text-muted)' }}>
<span className="font-medium" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-secondary)' }}>
{user?.role === 'admin' ? <><Shield size={13} /> {t('settings.roleAdmin')}</> : t('settings.roleUser')}
</span>
{(user as UserWithOidc)?.oidc_issuer && (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 10, fontWeight: 500, padding: '1px 8px', borderRadius: 99,
background: '#dbeafe', color: '#1d4ed8', marginLeft: 6,
}}>
SSO
</span>
)}
</div>
{(user as UserWithOidc)?.oidc_issuer && (
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -2 }}>
{t('settings.oidcLinked')} {(user as UserWithOidc).oidc_issuer!.replace('https://', '').replace(/\/+$/, '')}
</p>
)}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
<button
onClick={saveProfile}
disabled={saving}
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"
>
{saving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
<span className="hidden sm:inline">{t('settings.saveProfile')}</span>
<span className="sm:hidden">{t('common.save')}</span>
</button>
<button
onClick={async () => {
if (user?.role === 'admin') {
try {
await adminApi.stats()
const adminUsers = (await adminApi.users()).users.filter((u: { role: string }) => u.role === 'admin')
if (adminUsers.length <= 1) {
setShowDeleteConfirm('blocked')
return
}
} catch {}
}
setShowDeleteConfirm(true)
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors text-red-500 hover:bg-red-50"
style={{ border: '1px solid #fecaca' }}
>
<Trash2 size={14} />
<span className="hidden sm:inline">{t('settings.deleteAccount')}</span>
<span className="sm:hidden">{t('common.delete')}</span>
</button>
</div>
</Section>
{/* Delete Account Blocked */}
{showDeleteConfirm === 'blocked' && (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
}} onClick={() => setShowDeleteConfirm(false)}>
<div style={{
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef3c7', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Shield size={18} style={{ color: '#d97706' }} />
</div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteBlockedTitle')}</h3>
</div>
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}>
{t('settings.deleteBlockedMessage')}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
onClick={() => setShowDeleteConfirm(false)}
style={{
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
cursor: 'pointer', fontFamily: 'inherit',
}}
>
{t('common.ok') || 'OK'}
</button>
</div>
</div>
</div>
)}
{/* Delete Account Confirm */}
{showDeleteConfirm === true && (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
}} onClick={() => setShowDeleteConfirm(false)}>
<div style={{
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Trash2 size={18} style={{ color: '#ef4444' }} />
</div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteAccountTitle')}</h3>
</div>
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}>
{t('settings.deleteAccountWarning')}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button
onClick={() => setShowDeleteConfirm(false)}
style={{
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
cursor: 'pointer', fontFamily: 'inherit',
}}
>
{t('common.cancel')}
</button>
<button
onClick={async () => {
try {
await authApi.deleteOwnAccount()
logout()
navigate('/login', { state: { noRedirect: true } })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
setShowDeleteConfirm(false)
}
}}
style={{
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 600,
border: 'none', background: '#ef4444', color: 'white',
cursor: 'pointer', fontFamily: 'inherit',
}}
>
{t('settings.deleteAccountConfirm')}
</button>
</div>
</div>
</div>
)}
</>
)
}
@@ -0,0 +1,206 @@
import React, { useState, useEffect } from 'react'
import { Palette, Sun, Moon, Monitor } from 'lucide-react'
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import Section from './Section'
export default function DisplaySettingsTab(): React.ReactElement {
const { settings, updateSetting } = useSettingsStore()
const { t } = useTranslation()
const toast = useToast()
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
useEffect(() => {
setTempUnit(settings.temperature_unit || 'celsius')
}, [settings.temperature_unit])
return (
<Section title={t('settings.display')} icon={Palette}>
{/* Color Mode */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.colorMode')}</label>
<div className="flex gap-3" style={{ flexWrap: 'wrap' }}>
{[
{ value: 'light', label: t('settings.light'), icon: Sun },
{ value: 'dark', label: t('settings.dark'), icon: Moon },
{ value: 'auto', label: t('settings.auto'), icon: Monitor },
].map(opt => {
const current = settings.dark_mode
const isActive = current === opt.value || (opt.value === 'light' && current === false) || (opt.value === 'dark' && current === true)
return (
<button
key={opt.value}
onClick={async () => {
try {
await updateSetting('dark_mode', opt.value)
} catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
}}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '10px 14px', borderRadius: 10, cursor: 'pointer', flex: '1 1 0', justifyContent: 'center', minWidth: 0,
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: isActive ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: isActive ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
<opt.icon size={16} />
{opt.label}
</button>
)
})}
</div>
</div>
{/* Language */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
<div className="flex flex-wrap gap-3">
{SUPPORTED_LANGUAGES.map(opt => (
<button
key={opt.value}
onClick={async () => {
try { await updateSetting('language', 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.language === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: settings.language === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Temperature */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.temperature')}</label>
<div className="flex gap-3">
{[
{ value: 'celsius', label: '°C Celsius' },
{ value: 'fahrenheit', label: '°F Fahrenheit' },
].map(opt => (
<button
key={opt.value}
onClick={async () => {
setTempUnit(opt.value)
try { await updateSetting('temperature_unit', 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: tempUnit === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: tempUnit === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Time Format */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label>
<div className="flex gap-3">
{[
{ value: '24h', label: '24h (14:30)' },
{ value: '12h', label: '12h (2:30 PM)' },
].map(opt => (
<button
key={opt.value}
onClick={async () => {
try { await updateSetting('time_format', 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.time_format === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: settings.time_format === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Route Calculation */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.routeCalculation')}</label>
<div className="flex gap-3">
{[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
].map(opt => (
<button
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('route_calculation', opt.value) }
catch (e: 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.route_calculation !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: (settings.route_calculation !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
</div>
{/* 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>
)
}
@@ -0,0 +1,253 @@
import Section from './Section'
import React, { useEffect, useState } from 'react'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { Trash2, Copy, Terminal, Plus, Check } from 'lucide-react'
import { authApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import PhotoProvidersSection from './PhotoProvidersSection'
interface McpToken {
id: number
name: string
token_prefix: string
created_at: string
last_used_at: string | null
}
export default function IntegrationsTab(): React.ReactElement {
const { t, locale } = useTranslation()
const toast = useToast()
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
const mcpEnabled = addonEnabled('mcp')
useEffect(() => {
loadAddons()
}, [loadAddons])
// MCP state
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([])
const [mcpModalOpen, setMcpModalOpen] = useState(false)
const [mcpNewName, setMcpNewName] = useState('')
const [mcpCreatedToken, setMcpCreatedToken] = useState<string | null>(null)
const [mcpCreating, setMcpCreating] = useState(false)
const [mcpDeleteId, setMcpDeleteId] = useState<number | null>(null)
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const mcpEndpoint = `${window.location.origin}/mcp`
const mcpJsonConfig = `{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"${mcpEndpoint}",
"--header",
"Authorization: Bearer <your_token>"
]
}
}
}`
useEffect(() => {
if (mcpEnabled) {
authApi.mcpTokens.list().then(d => setMcpTokens(d.tokens || [])).catch(() => {})
}
}, [mcpEnabled])
const handleCreateMcpToken = async () => {
if (!mcpNewName.trim()) return
setMcpCreating(true)
try {
const d = await authApi.mcpTokens.create(mcpNewName.trim())
setMcpCreatedToken(d.token.raw_token)
setMcpNewName('')
setMcpTokens(prev => [{ id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null }, ...prev])
} catch {
toast.error(t('settings.mcp.toast.createError'))
} finally {
setMcpCreating(false)
}
}
const handleDeleteMcpToken = async (id: number) => {
try {
await authApi.mcpTokens.delete(id)
setMcpTokens(prev => prev.filter(tk => tk.id !== id))
setMcpDeleteId(null)
toast.success(t('settings.mcp.toast.deleted'))
} catch {
toast.error(t('settings.mcp.toast.deleteError'))
}
}
const handleCopy = (text: string, key: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedKey(key)
setTimeout(() => setCopiedKey(null), 2000)
})
}
return (
<>
<PhotoProvidersSection />
{mcpEnabled && (
<Section title={t('settings.mcp.title')} icon={Terminal}>
{/* Endpoint URL */}
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.endpoint')}</label>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 rounded-lg text-sm font-mono border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
{mcpEndpoint}
</code>
<button onClick={() => handleCopy(mcpEndpoint, 'endpoint')}
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)' }} title={t('settings.mcp.copy')}>
{copiedKey === 'endpoint' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
</button>
</div>
</div>
{/* JSON config box */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="block text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</label>
<button onClick={() => handleCopy(mcpJsonConfig, 'json')}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{copiedKey === 'json' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
{copiedKey === 'json' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
</button>
</div>
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
{mcpJsonConfig}
</pre>
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p>
</div>
{/* Token list */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.apiTokens')}</label>
<button onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
style={{ background: 'var(--accent-primary, #4f46e5)', color: '#fff' }}>
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
</button>
</div>
{mcpTokens.length === 0 ? (
<p className="text-sm py-3 text-center rounded-lg border" style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)' }}>
{t('settings.mcp.noTokens')}
</p>
) : (
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
{mcpTokens.map((token, i) => (
<div key={token.id} className="flex items-center gap-3 px-4 py-3"
style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{token.token_prefix}...
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
{token.last_used_at && (
<span className="ml-2">· {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
)}
</p>
</div>
<button onClick={() => setMcpDeleteId(token.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('settings.mcp.deleteTokenTitle')}>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
</Section>
)}
{/* Create MCP Token modal */}
{mcpModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget && !mcpCreatedToken) setMcpModalOpen(false) }}>
<div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
{!mcpCreatedToken ? (
<>
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.modal.createTitle')}</h3>
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.modal.tokenName')}</label>
<input type="text" value={mcpNewName} onChange={e => setMcpNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()}
placeholder={t('settings.mcp.modal.tokenNamePlaceholder')}
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
autoFocus />
</div>
<div className="flex gap-2 justify-end pt-1">
<button onClick={() => setMcpModalOpen(false)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={handleCreateMcpToken} disabled={!mcpNewName.trim() || mcpCreating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50"
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{mcpCreating ? t('settings.mcp.modal.creating') : t('settings.mcp.modal.create')}
</button>
</div>
</>
) : (
<>
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.modal.createdTitle')}</h3>
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
<span className="text-amber-500 mt-0.5"></span>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.modal.createdWarning')}</p>
</div>
<div className="relative">
<pre className="p-3 pr-10 rounded-lg text-xs font-mono break-all border whitespace-pre-wrap" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
{mcpCreatedToken}
</pre>
<button onClick={() => handleCopy(mcpCreatedToken, 'new-token')}
className="absolute top-2 right-2 p-1.5 rounded transition-colors hover:bg-slate-200 dark:hover:bg-slate-600"
style={{ color: 'var(--text-secondary)' }} title={t('settings.mcp.copy')}>
{copiedKey === 'new-token' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<div className="flex justify-end">
<button onClick={() => { setMcpModalOpen(false); setMcpCreatedToken(null) }}
className="px-4 py-2 rounded-lg text-sm font-medium text-white"
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{t('settings.mcp.modal.done')}
</button>
</div>
</>
)}
</div>
</div>
)}
{/* Delete MCP Token confirm */}
{mcpDeleteId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setMcpDeleteId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.deleteTokenTitle')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.deleteTokenMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setMcpDeleteId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={() => handleDeleteMcpToken(mcpDeleteId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
{t('settings.mcp.deleteTokenTitle')}
</button>
</div>
</div>
</div>
)}
</>
)
}
@@ -0,0 +1,162 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { Map, Save } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView'
import Section from './Section'
import type { Place } from '../../types'
interface MapPreset {
name: string
url: string
}
const MAP_PRESETS: MapPreset[] = [
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
]
export default function MapSettingsTab(): React.ReactElement {
const { settings, updateSettings } = useSettingsStore()
const { t } = useTranslation()
const toast = useToast()
const [saving, setSaving] = useState(false)
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
const [defaultLng, setDefaultLng] = useState<number | string>(settings.default_lng || 2.3522)
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
useEffect(() => {
setMapTileUrl(settings.map_tile_url || '')
setDefaultLat(settings.default_lat || 48.8566)
setDefaultLng(settings.default_lng || 2.3522)
setDefaultZoom(settings.default_zoom || 10)
}, [settings])
const handleMapClick = useCallback((mapInfo) => {
setDefaultLat(mapInfo.latlng.lat)
setDefaultLng(mapInfo.latlng.lng)
}, [])
const mapPlaces = useMemo((): Place[] => [{
id: 1,
trip_id: 1,
name: 'Default map center',
description: '',
lat: defaultLat as number,
lng: defaultLng as number,
address: '',
category_id: 0,
icon: null,
price: null,
image_url: null,
google_place_id: null,
osm_id: null,
route_geometry: null,
place_time: null,
end_time: null,
created_at: Date(),
}], [defaultLat, defaultLng])
const saveMapSettings = async (): Promise<void> => {
setSaving(true)
try {
await updateSettings({
map_tile_url: mapTileUrl,
default_lat: parseFloat(String(defaultLat)),
default_lng: parseFloat(String(defaultLng)),
default_zoom: parseInt(String(defaultZoom)),
})
toast.success(t('settings.toast.mapSaved'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Error')
} finally {
setSaving(false)
}
}
return (
<Section title={t('settings.map')} icon={Map}>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
<CustomSelect
value={mapTileUrl}
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
placeholder={t('settings.mapTemplatePlaceholder.select')}
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
size="sm"
style={{ marginBottom: 8 }}
/>
<input
type="text"
value={mapTileUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs text-slate-400 mt-1">{t('settings.mapDefaultHint')}</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.latitude')}</label>
<input
type="number"
step="any"
value={defaultLat}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDefaultLat(e.target.value)}
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>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.longitude')}</label>
<input
type="number"
step="any"
value={defaultLng}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDefaultLng(e.target.value)}
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>
</div>
<div>
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{React.createElement(MapView as any, {
places: mapPlaces,
dayPlaces: [],
route: null,
routeSegments: null,
selectedPlaceId: null,
onMarkerClick: null,
onMapClick: handleMapClick,
onMapContextMenu: null,
center: [settings.default_lat, settings.default_lng],
zoom: defaultZoom,
tileUrl: mapTileUrl,
fitKey: null,
dayOrderMap: [],
leftWidth: 0,
rightWidth: 0,
hasInspector: false,
})}
</div>
</div>
<button
onClick={saveMapSettings}
disabled={saving}
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"
>
{saving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
{t('settings.saveMap')}
</button>
</Section>
)
}
@@ -0,0 +1,196 @@
import React, { useState, useEffect } from 'react'
import { Lock } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { notificationsApi, settingsApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import ToggleSwitch from './ToggleSwitch'
import Section from './Section'
interface PreferencesMatrix {
preferences: Record<string, Record<string, boolean>>
available_channels: { email: boolean; webhook: boolean; inapp: boolean }
event_types: string[]
implemented_combos: Record<string, string[]>
}
const CHANNEL_LABEL_KEYS: Record<string, string> = {
email: 'settings.notificationPreferences.email',
webhook: 'settings.notificationPreferences.webhook',
inapp: 'settings.notificationPreferences.inapp',
}
const EVENT_LABEL_KEYS: Record<string, string> = {
trip_invite: 'settings.notifyTripInvite',
booking_change: 'settings.notifyBookingChange',
trip_reminder: 'settings.notifyTripReminder',
vacay_invite: 'settings.notifyVacayInvite',
photos_shared: 'settings.notifyPhotosShared',
collab_message: 'settings.notifyCollabMessage',
packing_tagged: 'settings.notifyPackingTagged',
version_available: 'settings.notifyVersionAvailable',
}
export default function NotificationsTab(): React.ReactElement {
const { t } = useTranslation()
const toast = useToast()
const [matrix, setMatrix] = useState<PreferencesMatrix | null>(null)
const [saving, setSaving] = useState(false)
const [webhookUrl, setWebhookUrl] = useState('')
const [webhookIsSet, setWebhookIsSet] = useState(false)
const [webhookSaving, setWebhookSaving] = useState(false)
const [webhookTesting, setWebhookTesting] = useState(false)
useEffect(() => {
notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {})
settingsApi.get().then((data: { settings: Record<string, unknown> }) => {
const val = (data.settings?.webhook_url as string) || ''
if (val === '••••••••') {
setWebhookIsSet(true)
setWebhookUrl('')
} else {
setWebhookUrl(val)
}
}).catch(() => {})
}, [])
const visibleChannels = matrix
? (['email', 'webhook', 'inapp'] as const).filter(ch => {
if (!matrix.available_channels[ch]) return false
return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch))
})
: []
const toggle = async (eventType: string, channel: string) => {
if (!matrix) return
const current = matrix.preferences[eventType]?.[channel] ?? true
const updated = {
...matrix.preferences,
[eventType]: { ...matrix.preferences[eventType], [channel]: !current },
}
setMatrix(m => m ? { ...m, preferences: updated } : m)
setSaving(true)
try {
await notificationsApi.updatePreferences(updated)
} catch {
setMatrix(m => m ? { ...m, preferences: matrix.preferences } : m)
} finally {
setSaving(false)
}
}
const saveWebhookUrl = async () => {
setWebhookSaving(true)
try {
await settingsApi.set('webhook_url', webhookUrl)
if (webhookUrl) setWebhookIsSet(true)
else setWebhookIsSet(false)
toast.success(t('settings.webhookUrl.saved'))
} catch {
toast.error(t('common.error'))
} finally {
setWebhookSaving(false)
}
}
const testWebhookUrl = async () => {
if (!webhookUrl && !webhookIsSet) return
setWebhookTesting(true)
try {
const result = await notificationsApi.testWebhook(webhookUrl || undefined)
if (result.success) toast.success(t('settings.webhookUrl.testSuccess'))
else toast.error(result.error || t('settings.webhookUrl.testFailed'))
} catch {
toast.error(t('settings.webhookUrl.testFailed'))
} finally {
setWebhookTesting(false)
}
}
const renderContent = () => {
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>Loading</p>
if (visibleChannels.length === 0) {
return (
<p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>
{t('settings.notificationPreferences.noChannels')}
</p>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{saving && <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>Saving</p>}
{matrix.available_channels.webhook && (
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
{t('settings.webhookUrl.label')}
</label>
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>{t('settings.webhookUrl.hint')}</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="text"
value={webhookUrl}
onChange={e => setWebhookUrl(e.target.value)}
placeholder={webhookIsSet ? '••••••••' : t('settings.webhookUrl.placeholder')}
style={{ flex: 1, fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)' }}
/>
<button
onClick={saveWebhookUrl}
disabled={webhookSaving}
style={{ fontSize: 12, padding: '6px 12px', background: 'var(--text-primary)', color: 'var(--bg-primary)', border: 'none', borderRadius: 6, cursor: webhookSaving ? 'not-allowed' : 'pointer', opacity: webhookSaving ? 0.6 : 1 }}
>
{t('settings.webhookUrl.save')}
</button>
<button
onClick={testWebhookUrl}
disabled={(!webhookUrl && !webhookIsSet) || webhookTesting}
style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: ((!webhookUrl && !webhookIsSet) || webhookTesting) ? 'not-allowed' : 'pointer', opacity: ((!webhookUrl && !webhookIsSet) || webhookTesting) ? 0.5 : 1 }}
>
{t('settings.webhookUrl.test')}
</button>
</div>
</div>
)}
{/* Header row */}
<div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}>
<span />
{visibleChannels.map(ch => (
<span key={ch} style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{t(CHANNEL_LABEL_KEYS[ch]) || ch}
</span>
))}
</div>
{/* Event rows */}
{matrix.event_types.map(eventType => {
const implementedForEvent = matrix.implemented_combos[eventType] ?? []
const relevantChannels = visibleChannels.filter(ch => implementedForEvent.includes(ch))
if (relevantChannels.length === 0) return null
return (
<div key={eventType} style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--border-primary)' }}>
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>
{t(EVENT_LABEL_KEYS[eventType]) || eventType}
</span>
{visibleChannels.map(ch => {
if (!implementedForEvent.includes(ch)) {
return <span key={ch} style={{ textAlign: 'center', color: 'var(--text-faint)', fontSize: 14 }}></span>
}
const isOn = matrix.preferences[eventType]?.[ch] ?? true
return (
<div key={ch} style={{ display: 'flex', justifyContent: 'center' }}>
<ToggleSwitch on={isOn} onToggle={() => toggle(eventType, ch)} />
</div>
)
})}
</div>
)
})}
</div>
)
}
return (
<Section title={t('settings.notifications')} icon={Lock}>
{renderContent()}
</Section>
)
}
@@ -0,0 +1,248 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Camera, Save } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useToast } from '../../components/shared/Toast'
import apiClient from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import Section from './Section'
interface ProviderField {
key: string
label: string
input_type: string
placeholder?: string | null
required: boolean
secret: boolean
settings_key?: string | null
payload_key?: string | null
sort_order: number
}
interface PhotoProviderAddon {
id: string
name: string
type: string
enabled: boolean
config?: Record<string, unknown>
fields?: ProviderField[]
}
interface ProviderConfig {
settings_get?: string
settings_put?: string
status_get?: string
test_get?: string
test_post?: string
}
const getProviderConfig = (provider: PhotoProviderAddon): ProviderConfig => {
const raw = provider.config || {}
return {
settings_get: typeof raw.settings_get === 'string' ? raw.settings_get : undefined,
settings_put: typeof raw.settings_put === 'string' ? raw.settings_put : undefined,
status_get: typeof raw.status_get === 'string' ? raw.status_get : undefined,
test_get: typeof raw.test_get === 'string' ? raw.test_get : undefined,
test_post: typeof raw.test_post === 'string' ? raw.test_post : undefined,
}
}
const getProviderFields = (provider: PhotoProviderAddon): ProviderField[] => {
return [...(provider.fields || [])].sort((a, b) => a.sort_order - b.sort_order)
}
export default function PhotoProvidersSection(): React.ReactElement {
const { t } = useTranslation()
const toast = useToast()
const { isEnabled: addonEnabled, addons } = useAddonStore()
const memoriesEnabled = addonEnabled('memories')
const [saving, setSaving] = useState<Record<string, boolean>>({})
const [providerValues, setProviderValues] = useState<Record<string, Record<string, string>>>({})
const [providerConnected, setProviderConnected] = useState<Record<string, boolean>>({})
const [providerTesting, setProviderTesting] = useState<Record<string, boolean>>({})
const activePhotoProviders = useMemo(
() => addons.filter(a => a.type === 'photo_provider' && a.enabled) as PhotoProviderAddon[],
[addons],
)
const buildProviderPayload = (provider: PhotoProviderAddon): Record<string, unknown> => {
const values = providerValues[provider.id] || {}
const payload: Record<string, unknown> = {}
for (const field of getProviderFields(provider)) {
const payloadKey = field.payload_key || field.settings_key || field.key
const value = (values[field.key] || '').trim()
if (field.secret && !value) continue
payload[payloadKey] = value
}
return payload
}
const refreshProviderConnection = async (provider: PhotoProviderAddon) => {
const cfg = getProviderConfig(provider)
const statusPath = cfg.status_get
if (!statusPath) return
try {
const res = await apiClient.get(statusPath)
setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data?.connected }))
} catch {
setProviderConnected(prev => ({ ...prev, [provider.id]: false }))
}
}
const activeProviderSignature = useMemo(
() => activePhotoProviders.map(provider => provider.id).join('|'),
[activePhotoProviders],
)
useEffect(() => {
let isCancelled = false
for (const provider of activePhotoProviders) {
const cfg = getProviderConfig(provider)
const fields = getProviderFields(provider)
if (cfg.settings_get) {
apiClient.get(cfg.settings_get).then(res => {
if (isCancelled) return
const nextValues: Record<string, string> = {}
for (const field of fields) {
// Do not prefill secret fields; user can overwrite only when needed.
if (field.secret) continue
const sourceKey = field.settings_key || field.payload_key || field.key
const rawValue = (res.data as Record<string, unknown>)[sourceKey]
nextValues[field.key] = typeof rawValue === 'string' ? rawValue : rawValue != null ? String(rawValue) : ''
}
setProviderValues(prev => ({
...prev,
[provider.id]: { ...(prev[provider.id] || {}), ...nextValues },
}))
if (typeof res.data?.connected === 'boolean') {
setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data.connected }))
}
}).catch(() => { })
}
refreshProviderConnection(provider).catch(() => { })
}
return () => {
isCancelled = true
}
}, [activePhotoProviders, activeProviderSignature])
const handleProviderFieldChange = (providerId: string, key: string, value: string) => {
setProviderValues(prev => ({
...prev,
[providerId]: { ...(prev[providerId] || {}), [key]: value },
}))
}
const isProviderSaveDisabled = (provider: PhotoProviderAddon): boolean => {
const values = providerValues[provider.id] || {}
return getProviderFields(provider).some(field => {
if (!field.required) return false
return !(values[field.key] || '').trim()
})
}
const handleSaveProvider = async (provider: PhotoProviderAddon) => {
const cfg = getProviderConfig(provider)
if (!cfg.settings_put) return
setSaving(s => ({ ...s, [provider.id]: true }))
try {
await apiClient.put(cfg.settings_put, buildProviderPayload(provider))
await refreshProviderConnection(provider)
toast.success(t('memories.saved', { provider_name: provider.name }))
} catch {
toast.error(t('memories.saveError', { provider_name: provider.name }))
} finally {
setSaving(s => ({ ...s, [provider.id]: false }))
}
}
const handleTestProvider = async (provider: PhotoProviderAddon) => {
const cfg = getProviderConfig(provider)
const testPath = cfg.test_post || cfg.test_get || cfg.status_get
if (!testPath) return
setProviderTesting(prev => ({ ...prev, [provider.id]: true }))
try {
const payload = buildProviderPayload(provider)
const res = cfg.test_post ? await apiClient.post(testPath, payload) : await apiClient.get(testPath)
const ok = !!res.data?.connected
setProviderConnected(prev => ({ ...prev, [provider.id]: ok }))
if (ok) {
toast.success(t('memories.connectionSuccess', { provider_name: provider.name }))
} else {
toast.error(`${t('memories.connectionError', { provider_name: provider.name })} ${res.data?.error ? `: ${String(res.data.error)}` : ''}`)
}
} catch {
toast.error(t('memories.connectionError', { provider_name: provider.name }))
} finally {
setProviderTesting(prev => ({ ...prev, [provider.id]: false }))
}
}
const renderPhotoProviderSection = (provider: PhotoProviderAddon): React.ReactElement => {
const fields = getProviderFields(provider)
const cfg = getProviderConfig(provider)
const values = providerValues[provider.id] || {}
const connected = !!providerConnected[provider.id]
const testing = !!providerTesting[provider.id]
const canSave = !!cfg.settings_put
const canTest = !!(cfg.test_post || cfg.test_get || cfg.status_get)
return (
<Section key={provider.id} title={provider.name || provider.id} icon={Camera}>
<div className="space-y-3">
{fields.map(field => (
<div key={`${provider.id}-${field.key}`}>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
<input
type={field.input_type || 'text'}
value={values[field.key] || ''}
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.value)}
placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')}
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={() => handleSaveProvider(provider)}
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
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"
title={!canSave ? 'Save route is not configured for this provider' : isProviderSaveDisabled(provider) ? 'Please fill all required fields' : ''}
>
<Save className="w-4 h-4" /> {t('common.save')}
</button>
<button
onClick={() => handleTestProvider(provider)}
disabled={!canTest || testing}
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
title={!canTest ? 'Test route is not configured for this provider' : ''}
>
{testing
? <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>
{connected && (
<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>
)
}
if (!memoriesEnabled) {
return <></>
}
return <>{activePhotoProviders.map(provider => renderPhotoProviderSection(provider))}</>
}
@@ -0,0 +1,22 @@
import React from 'react'
import type { LucideIcon } from 'lucide-react'
interface SectionProps {
title: string
icon: LucideIcon
children: React.ReactNode
}
export default 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)', 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>
</div>
<div className="p-6 space-y-4">
{children}
</div>
</div>
)
}
@@ -0,0 +1,18 @@
import React from 'react'
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
return (
<button onClick={onToggle}
style={{
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
transition: 'background 0.2s',
}}>
<span style={{
position: 'absolute', top: 2, left: on ? 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>
)
}
@@ -0,0 +1,778 @@
import { useState, useMemo, useEffect } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { tripsApi } from '../../api/client'
import apiClient from '../../api/client'
import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import { formatDate as fmtDate } from '../../utils/formatters'
import {
CheckSquare, Square, Plus, ChevronRight, Flag,
X, Check, Calendar, User, FolderPlus, AlertCircle, ListChecks, Inbox, CheckCheck, Trash2,
} from 'lucide-react'
import type { TodoItem } from '../../types'
const KAT_COLORS = [
'#3b82f6', '#a855f7', '#ec4899', '#22c55e', '#f97316',
'#06b6d4', '#ef4444', '#eab308', '#8b5cf6', '#14b8a6',
]
const PRIO_CONFIG: Record<number, { label: string; color: string }> = {
1: { label: 'P1', color: '#ef4444' },
2: { label: 'P2', color: '#f59e0b' },
3: { label: 'P3', color: '#3b82f6' },
}
function katColor(kat: string, allCategories: string[]) {
const idx = allCategories.indexOf(kat)
if (idx >= 0) return KAT_COLORS[idx % KAT_COLORS.length]
let h = 0
for (let i = 0; i < kat.length; i++) h = ((h << 5) - h + kat.charCodeAt(i)) | 0
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length]
}
type FilterType = 'all' | 'my' | 'overdue' | 'done' | string
interface Member { id: number; username: string; avatar: string | null }
export default function TodoListPanel({ tripId, items }: { tripId: number; items: TodoItem[] }) {
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore()
const canEdit = useCanDo('packing_edit')
const toast = useToast()
const { t, locale } = useTranslation()
const formatDate = (d: string) => fmtDate(d, locale) || d
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
useEffect(() => {
const mq = window.matchMedia('(max-width: 767px)')
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
const [filter, setFilter] = useState<FilterType>('all')
const [selectedId, setSelectedId] = useState<number | null>(null)
const [isAddingNew, setIsAddingNew] = useState(false)
const [sortByPrio, setSortByPrio] = useState(false)
const [addingCategory, setAddingCategory] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [members, setMembers] = useState<Member[]>([])
const [currentUserId, setCurrentUserId] = useState<number | null>(null)
useEffect(() => {
apiClient.get(`/trips/${tripId}/members`).then(r => {
const owner = r.data?.owner
const mems = r.data?.members || []
const all = owner ? [owner, ...mems] : mems
setMembers(all)
setCurrentUserId(r.data?.current_user_id || null)
}).catch(() => {})
}, [tripId])
const categories = useMemo(() => {
const cats = new Set<string>()
items.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [items])
const today = new Date().toISOString().split('T')[0]
const filtered = useMemo(() => {
let result: TodoItem[]
if (filter === 'all') result = items.filter(i => !i.checked)
else if (filter === 'done') result = items.filter(i => !!i.checked)
else if (filter === 'my') result = items.filter(i => !i.checked && i.assigned_user_id === currentUserId)
else if (filter === 'overdue') result = items.filter(i => !i.checked && i.due_date && i.due_date < today)
else result = items.filter(i => i.category === filter)
if (sortByPrio) result = [...result].sort((a, b) => {
const ap = a.priority || 99
const bp = b.priority || 99
return ap - bp
})
return result
}, [items, filter, currentUserId, today, sortByPrio])
const selectedItem = items.find(i => i.id === selectedId) || null
const totalCount = items.length
const doneCount = items.filter(i => !!i.checked).length
const overdueCount = items.filter(i => !i.checked && i.due_date && i.due_date < today).length
const myCount = currentUserId ? items.filter(i => !i.checked && i.assigned_user_id === currentUserId).length : 0
const addCategory = () => {
const name = newCategoryName.trim()
if (!name || categories.includes(name)) { setAddingCategory(false); setNewCategoryName(''); return }
addTodoItem(tripId, { name: t('todo.newItem'), category: name } as any)
.then(() => { setAddingCategory(false); setNewCategoryName(''); setFilter(name) })
.catch(err => toast.error(err instanceof Error ? err.message : 'Error'))
}
// Get category count (non-done items)
const catCount = (cat: string) => items.filter(i => i.category === cat && !i.checked).length
// Sidebar filter item
const SidebarItem = ({ id, icon: Icon, label, count, color }: { id: string; icon: any; label: string; count: number; color?: string }) => (
<button onClick={() => setFilter(id as FilterType)}
title={isMobile ? label : undefined}
style={{
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
gap: isMobile ? 0 : 8, width: '100%', padding: isMobile ? '8px 0' : '7px 12px',
border: 'none', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13,
background: filter === id ? 'var(--bg-hover)' : 'transparent',
color: filter === id ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: filter === id ? 600 : 400, transition: 'all 0.1s',
position: 'relative',
}}
onMouseEnter={e => { if (filter !== id) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (filter !== id) e.currentTarget.style.background = 'transparent' }}>
{color ? (
<span style={{ width: isMobile ? 12 : 10, height: isMobile ? 12 : 10, borderRadius: '50%', background: color, flexShrink: 0 }} />
) : (
<Icon size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} />
)}
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{label}</span>}
{!isMobile && count > 0 && (
<span style={{ fontSize: 11, color: 'var(--text-faint)', background: 'var(--bg-hover)', borderRadius: 10, padding: '1px 7px', minWidth: 20, textAlign: 'center' }}>
{count}
</span>
)}
{isMobile && count > 0 && (
<span style={{ position: 'absolute', top: 2, right: 2, fontSize: 8, fontWeight: 700, color: 'var(--bg-primary)', background: 'var(--text-faint)', borderRadius: '50%', width: 14, height: 14, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{count}
</span>
)}
</button>
)
// Filter title
const filterTitle = (() => {
if (filter === 'all') return t('todo.filter.all')
if (filter === 'done') return t('todo.filter.done')
if (filter === 'my') return t('todo.filter.my')
if (filter === 'overdue') return t('todo.filter.overdue')
return filter
})()
return (
<div style={{ display: 'flex', height: 'calc(100vh - 180px)', minHeight: 400 }}>
{/* ── Left Sidebar ── */}
<div style={{
width: isMobile ? 52 : 220, flexShrink: 0, borderRight: '1px solid var(--border-faint)',
padding: isMobile ? '12px 6px' : '16px 10px', display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto',
transition: 'width 0.2s',
}}>
{/* Progress Card */}
{!isMobile && <div style={{
margin: '0 6px 12px', padding: '14px 14px 12px', borderRadius: 14,
background: 'var(--bg-hover)',
border: '1px solid var(--border-primary)',
boxShadow: '0 1px 2px rgba(0,0,0,0.02)',
}}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, marginBottom: 8 }}>
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1, letterSpacing: '-0.02em' }}>
{totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0}%
</span>
</div>
<div style={{ height: 4, background: 'var(--border-faint)', borderRadius: 2, overflow: 'hidden', marginBottom: 6 }}>
<div style={{ height: '100%', width: totalCount > 0 ? `${Math.round((doneCount / totalCount) * 100)}%` : '0%', background: '#22c55e', borderRadius: 2, transition: 'width 0.3s' }} />
</div>
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
{doneCount} / {totalCount} {t('todo.completed')}
</div>
</div>}
{/* Smart filters */}
{!isMobile && <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 12px 4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t('todo.sidebar.tasks')}
</div>}
<SidebarItem id="all" icon={Inbox} label={t('todo.filter.all')} count={items.filter(i => !i.checked).length} />
<SidebarItem id="my" icon={User} label={t('todo.filter.my')} count={myCount} />
<SidebarItem id="overdue" icon={AlertCircle} label={t('todo.filter.overdue')} count={overdueCount} />
<SidebarItem id="done" icon={CheckCheck} label={t('todo.filter.done')} count={doneCount} />
{/* Sort by priority */}
<button onClick={() => setSortByPrio(v => !v)}
title={isMobile ? t('todo.sortByPrio') : undefined}
style={{
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
gap: isMobile ? 0 : 8, width: '100%', padding: isMobile ? '8px 0' : '7px 12px',
border: 'none', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13,
background: sortByPrio ? '#f59e0b12' : 'transparent',
color: sortByPrio ? '#f59e0b' : 'var(--text-secondary)',
fontWeight: sortByPrio ? 600 : 400, transition: 'all 0.1s',
}}
onMouseEnter={e => { if (!sortByPrio) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!sortByPrio) e.currentTarget.style.background = 'transparent' }}>
<Flag size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} />
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.sortByPrio')}</span>}
</button>
{/* Categories */}
{!isMobile && <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', padding: '16px 12px 4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t('todo.sidebar.categories')}
</div>}
{isMobile && <div style={{ height: 1, background: 'var(--border-faint)', margin: '8px 4px' }} />}
{categories.map(cat => (
<SidebarItem key={cat} id={cat} icon={null} label={cat} count={catCount(cat)} color={katColor(cat, categories)} />
))}
{canEdit && (
addingCategory && !isMobile ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 12px' }}>
<input autoFocus value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') addCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCategoryName('') } }}
placeholder={t('todo.newCategory')}
style={{ flex: 1, fontSize: 12, padding: '4px 6px', border: '1px solid var(--border-primary)', borderRadius: 5, background: 'var(--bg-hover)', color: 'var(--text-primary)', fontFamily: 'inherit', minWidth: 0 }} />
<button onClick={addCategory} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#22c55e', padding: 2 }}><Check size={13} /></button>
</div>
) : (
<button onClick={() => setAddingCategory(true)}
title={isMobile ? t('todo.addCategory') : undefined}
style={{ display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start', gap: isMobile ? 0 : 6, padding: isMobile ? '8px 0' : '7px 12px', fontSize: 12, color: 'var(--text-faint)', background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', width: '100%', textAlign: 'left' }}>
<Plus size={isMobile ? 18 : 13} /> {!isMobile && t('todo.addCategory')}
</button>
)
)}
</div>
{/* ── Middle: Task List ── */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{/* Header */}
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-faint)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 700, color: 'var(--text-primary)', letterSpacing: '-0.02em' }}>
{filterTitle}
</h2>
<span style={{ fontSize: 13, color: 'var(--text-faint)', background: 'var(--bg-hover)', borderRadius: 6, padding: '2px 8px', fontWeight: 600 }}>
{filtered.length}
</span>
</div>
</div>
{/* Add task */}
{canEdit && (
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border-faint)' }}>
<button
onClick={() => { setSelectedId(null); setIsAddingNew(true) }}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
width: '100%', padding: '9px 16px', borderRadius: 8,
background: isAddingNew ? 'var(--text-primary)' : 'var(--bg-hover)',
color: isAddingNew ? 'var(--bg-primary)' : 'var(--text-primary)',
border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 13, fontWeight: 600, transition: 'all 0.15s',
}}
onMouseEnter={e => { if (!isAddingNew) { e.currentTarget.style.background = 'var(--text-primary)'; e.currentTarget.style.color = 'var(--bg-primary)'; e.currentTarget.style.borderColor = 'var(--text-primary)' } }}
onMouseLeave={e => { if (!isAddingNew) { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-primary)'; e.currentTarget.style.borderColor = 'var(--border-primary)' } }}>
<Plus size={14} />
{t('todo.addItem')}
</button>
</div>
)}
{/* Task list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
{filtered.length === 0 ? null : (
filtered.map(item => {
const done = !!item.checked
const assignedUser = members.find(m => m.id === item.assigned_user_id)
const isOverdue = item.due_date && !done && item.due_date < today
const isSelected = selectedId === item.id
const catColor = item.category ? katColor(item.category, categories) : null
return (
<div key={item.id}
onClick={() => { setSelectedId(isSelected ? null : item.id); setIsAddingNew(false) }}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px',
borderBottom: '1px solid var(--border-faint)', cursor: 'pointer',
background: isSelected ? 'var(--bg-hover)' : 'transparent',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(0,0,0,0.02)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}>
{/* Checkbox */}
<button onClick={e => { e.stopPropagation(); canEdit && toggleTodoItem(tripId, item.id, !done) }}
style={{ background: 'none', border: 'none', cursor: canEdit ? 'pointer' : 'default', padding: 0, flexShrink: 0,
color: done ? '#22c55e' : 'var(--border-primary)' }}>
{done ? <CheckSquare size={18} /> : <Square size={18} />}
</button>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 14, color: done ? 'var(--text-faint)' : 'var(--text-primary)',
textDecoration: done ? 'line-through' : 'none', lineHeight: 1.4,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{item.name}
</div>
{/* Description preview */}
{item.description && (
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.4 }}>
{item.description}
</div>
)}
{/* Inline badges */}
{(item.priority || item.due_date || catColor || assignedUser) && (
<div style={{ display: 'flex', gap: 5, marginTop: 5, flexWrap: 'wrap' }}>
{item.priority > 0 && PRIO_CONFIG[item.priority] && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 7px', borderRadius: 5, fontWeight: 600,
color: PRIO_CONFIG[item.priority].color,
background: `${PRIO_CONFIG[item.priority].color}10`,
border: `1px solid ${PRIO_CONFIG[item.priority].color}25`,
}}>
<Flag size={9} />{PRIO_CONFIG[item.priority].label}
</span>
)}
{item.due_date && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
color: isOverdue ? '#ef4444' : 'var(--text-secondary)',
background: isOverdue ? 'rgba(239,68,68,0.08)' : 'var(--bg-hover)',
border: `1px solid ${isOverdue ? 'rgba(239,68,68,0.15)' : 'var(--border-faint)'}`,
}}>
<Calendar size={9} />{formatDate(item.due_date)}
</span>
)}
{catColor && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
color: 'var(--text-secondary)', background: 'var(--bg-hover)',
border: '1px solid var(--border-faint)',
}}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: catColor, flexShrink: 0 }} />
{item.category}
</span>
)}
{assignedUser && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
color: 'var(--text-secondary)', background: 'var(--bg-hover)',
border: '1px solid var(--border-faint)',
}}>
{assignedUser.avatar ? (
<img src={`/uploads/avatars/${assignedUser.avatar}`} style={{ width: 13, height: 13, borderRadius: '50%', objectFit: 'cover' }} alt="" />
) : (
<span style={{ width: 13, height: 13, borderRadius: '50%', background: 'var(--border-primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 7, color: 'var(--text-faint)', fontWeight: 700 }}>
{assignedUser.username.charAt(0).toUpperCase()}
</span>
)}
{assignedUser.username}
</span>
)}
</div>
)}
</div>
{/* Chevron */}
<ChevronRight size={16} color="var(--text-faint)" style={{ flexShrink: 0, opacity: 0.4 }} />
</div>
)
})
)}
</div>
</div>
{/* ── Right: Detail Pane ── */}
{selectedItem && !isAddingNew && !isMobile && (
<DetailPane
item={selectedItem}
tripId={tripId}
categories={categories}
members={members}
onClose={() => setSelectedId(null)}
/>
)}
{selectedItem && !isAddingNew && isMobile && (
<div onClick={e => { if (e.target === e.currentTarget) setSelectedId(null) }}
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
<DetailPane
item={selectedItem}
tripId={tripId}
categories={categories}
members={members}
onClose={() => setSelectedId(null)}
/>
</div>
</div>
)}
{isAddingNew && !selectedItem && !isMobile && (
<NewTaskPane
tripId={tripId}
categories={categories}
members={members}
defaultCategory={typeof filter === 'string' && categories.includes(filter) ? filter : null}
onCreated={(id) => { setIsAddingNew(false); setSelectedId(id) }}
onClose={() => setIsAddingNew(false)}
/>
)}
{isAddingNew && !selectedItem && isMobile && (
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
<NewTaskPane
tripId={tripId}
categories={categories}
members={members}
defaultCategory={typeof filter === 'string' && categories.includes(filter) ? filter : null}
onCreated={(id) => { setIsAddingNew(false); setSelectedId(id) }}
onClose={() => setIsAddingNew(false)}
/>
</div>
</div>
)}
</div>
)
}
// ── Detail Pane (right side) ──────────────────────────────────────────────
function DetailPane({ item, tripId, categories, members, onClose }: {
item: TodoItem; tripId: number; categories: string[]; members: Member[];
onClose: () => void;
}) {
const { updateTodoItem, deleteTodoItem } = useTripStore()
const canEdit = useCanDo('packing_edit')
const toast = useToast()
const { t } = useTranslation()
const [name, setName] = useState(item.name)
const [desc, setDesc] = useState(item.description || '')
const [dueDate, setDueDate] = useState(item.due_date || '')
const [category, setCategory] = useState(item.category || '')
const [assignedUserId, setAssignedUserId] = useState<number | null>(item.assigned_user_id)
const [priority, setPriority] = useState(item.priority || 0)
const [saving, setSaving] = useState(false)
// Sync when selected item changes
useEffect(() => {
setName(item.name)
setDesc(item.description || '')
setDueDate(item.due_date || '')
setCategory(item.category || '')
setAssignedUserId(item.assigned_user_id)
setPriority(item.priority || 0)
}, [item.id, item.name, item.description, item.due_date, item.category, item.assigned_user_id, item.priority])
const hasChanges = name !== item.name || desc !== (item.description || '') ||
dueDate !== (item.due_date || '') || category !== (item.category || '') ||
assignedUserId !== item.assigned_user_id || priority !== (item.priority || 0)
const save = async () => {
if (!name.trim() || !hasChanges) return
setSaving(true)
try {
await updateTodoItem(tripId, item.id, {
name: name.trim(), description: desc || null,
due_date: dueDate || null, category: category || null,
assigned_user_id: assignedUserId, priority,
} as any)
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') }
setSaving(false)
}
const handleDelete = async () => {
try {
await deleteTodoItem(tripId, item.id)
onClose()
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') }
}
const labelStyle: React.CSSProperties = { fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 4, display: 'block' }
const inputStyle: React.CSSProperties = {
width: '100%', fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)',
borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit',
}
return (
<div style={{
width: 320, flexShrink: 0, borderLeft: '1px solid var(--border-faint)',
display: 'flex', flexDirection: 'column', background: 'var(--bg-primary)',
}}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px 12px', borderBottom: '1px solid var(--border-faint)' }}>
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>{t('todo.detail.title')}</span>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4 }}>
<X size={16} />
</button>
</div>
{/* Form */}
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Name */}
<div>
<input value={name} onChange={e => setName(e.target.value)} disabled={!canEdit}
style={{ ...inputStyle, fontSize: 15, fontWeight: 600, border: 'none', padding: '4px 0', background: 'transparent' }}
placeholder={t('todo.namePlaceholder')} />
</div>
{/* Description */}
<div>
<label style={labelStyle}>{t('todo.detail.description')}</label>
<textarea value={desc} onChange={e => setDesc(e.target.value)} disabled={!canEdit} rows={4}
placeholder={t('todo.descriptionPlaceholder')}
style={{ ...inputStyle, resize: 'vertical', minHeight: 80 }} />
</div>
{/* Priority */}
<div>
<label style={labelStyle}>{t('todo.detail.priority')}</label>
<div style={{ display: 'flex', gap: 4 }}>
{[0, 1, 2, 3].map(p => {
const cfg = PRIO_CONFIG[p]
const isActive = priority === p
return (
<button key={p} onClick={() => canEdit && setPriority(p)}
style={{
flex: 1, padding: '6px 0', borderRadius: 6, fontSize: 11, fontWeight: 600, cursor: canEdit ? 'pointer' : 'default',
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
border: `1px solid ${isActive && cfg ? cfg.color + '40' : 'var(--border-primary)'}`,
background: isActive && cfg ? cfg.color + '12' : 'transparent',
color: isActive && cfg ? cfg.color : isActive ? 'var(--text-primary)' : 'var(--text-faint)',
transition: 'all 0.1s',
}}>
{cfg ? <><Flag size={10} />{cfg.label}</> : t('todo.detail.noPriority')}
</button>
)
})}
</div>
</div>
{/* Category */}
<div>
<label style={labelStyle}>{t('todo.detail.category')}</label>
<CustomSelect
value={category}
onChange={v => setCategory(v)}
options={[
{ value: '', label: t('todo.noCategory') },
...categories.map(c => ({
value: c,
label: c,
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
})),
]}
placeholder={t('todo.noCategory')}
size="sm"
disabled={!canEdit}
/>
</div>
{/* Due date */}
<div>
<label style={labelStyle}>{t('todo.detail.dueDate')}</label>
<CustomDatePicker
value={dueDate}
onChange={v => setDueDate(v)}
/>
</div>
{/* Assigned to */}
<div>
<label style={labelStyle}>{t('todo.detail.assignedTo')}</label>
<CustomSelect
value={String(assignedUserId ?? '')}
onChange={v => setAssignedUserId(v ? Number(v) : null)}
options={[
{ value: '', label: t('todo.unassigned'), icon: <User size={14} style={{ color: 'var(--text-faint)' }} /> },
...members.map(m => ({
value: String(m.id),
label: m.username,
icon: m.avatar ? (
<img src={`/uploads/avatars/${m.avatar}`} style={{ width: 18, height: 18, borderRadius: '50%', objectFit: 'cover' as const }} alt="" />
) : (
<span style={{ width: 18, height: 18, borderRadius: '50%', background: 'var(--border-primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, color: 'var(--text-faint)', fontWeight: 600 }}>
{m.username.charAt(0).toUpperCase()}
</span>
),
})),
]}
placeholder={t('todo.unassigned')}
size="sm"
disabled={!canEdit}
/>
</div>
</div>
{/* Footer actions */}
{canEdit && (
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--border-faint)', display: 'flex', gap: 8 }}>
<button onClick={handleDelete}
style={{
flex: 1, padding: '9px 16px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
border: '1px solid var(--border-primary)', background: 'transparent', color: 'var(--text-secondary)',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
}}>
<Trash2 size={13} />
{t('todo.detail.delete')}
</button>
<button onClick={save} disabled={!hasChanges || saving}
style={{
flex: 1, padding: '9px 16px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: hasChanges ? 'pointer' : 'default', fontFamily: 'inherit',
border: 'none', background: hasChanges ? 'var(--text-primary)' : 'var(--border-faint)',
color: hasChanges ? 'var(--bg-primary)' : 'var(--text-faint)',
transition: 'all 0.15s',
}}>
{saving ? '...' : t('todo.detail.save')}
</button>
</div>
)}
</div>
)
}
// ── New Task Pane (right side, for creating) ──────────────────────────────
function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated, onClose }: {
tripId: number; categories: string[]; members: Member[]; defaultCategory: string | null;
onCreated: (id: number) => void; onClose: () => void;
}) {
const { addTodoItem } = useTripStore()
const toast = useToast()
const { t } = useTranslation()
const [name, setName] = useState('')
const [desc, setDesc] = useState('')
const [dueDate, setDueDate] = useState('')
const [category, setCategory] = useState(defaultCategory || '')
const [assignedUserId, setAssignedUserId] = useState<number | null>(null)
const [priority, setPriority] = useState(0)
const [saving, setSaving] = useState(false)
const labelStyle: React.CSSProperties = { fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 4, display: 'block' }
const create = async () => {
if (!name.trim()) return
setSaving(true)
try {
const item = await addTodoItem(tripId, {
name: name.trim(), description: desc || null, priority,
due_date: dueDate || null, category: category || null,
assigned_user_id: assignedUserId,
} as any)
if (item?.id) onCreated(item.id)
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') }
setSaving(false)
}
return (
<div style={{
width: 320, flexShrink: 0, borderLeft: '1px solid var(--border-faint)',
display: 'flex', flexDirection: 'column', background: 'var(--bg-primary)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px 12px', borderBottom: '1px solid var(--border-faint)' }}>
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>{t('todo.newItem')}</span>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4 }}>
<X size={16} />
</button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<input autoFocus value={name} onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && name.trim()) create() }}
style={{ width: '100%', fontSize: 15, fontWeight: 600, border: 'none', padding: '4px 0', background: 'transparent', color: 'var(--text-primary)', outline: 'none', fontFamily: 'inherit' }}
placeholder={t('todo.namePlaceholder')} />
</div>
<div>
<label style={labelStyle}>{t('todo.detail.description')}</label>
<textarea value={desc} onChange={e => setDesc(e.target.value)} rows={4}
placeholder={t('todo.descriptionPlaceholder')}
style={{ width: '100%', fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', resize: 'vertical', minHeight: 80 }} />
</div>
<div>
<label style={labelStyle}>{t('todo.detail.category')}</label>
<CustomSelect
value={category}
onChange={v => setCategory(v)}
options={[
{ value: '', label: t('todo.noCategory') },
...categories.map(c => ({
value: c, label: c,
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
})),
]}
placeholder={t('todo.noCategory')}
size="sm"
/>
</div>
<div>
<label style={labelStyle}>{t('todo.detail.priority')}</label>
<div style={{ display: 'flex', gap: 4 }}>
{[0, 1, 2, 3].map(p => {
const cfg = PRIO_CONFIG[p]
const isActive = priority === p
return (
<button key={p} onClick={() => setPriority(p)}
style={{
flex: 1, padding: '6px 0', borderRadius: 6, fontSize: 11, fontWeight: 600, cursor: 'pointer',
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
border: `1px solid ${isActive && cfg ? cfg.color + '40' : 'var(--border-primary)'}`,
background: isActive && cfg ? cfg.color + '12' : 'transparent',
color: isActive && cfg ? cfg.color : isActive ? 'var(--text-primary)' : 'var(--text-faint)',
transition: 'all 0.1s',
}}>
{cfg ? <><Flag size={10} />{cfg.label}</> : t('todo.detail.noPriority')}
</button>
)
})}
</div>
</div>
<div>
<label style={labelStyle}>{t('todo.detail.dueDate')}</label>
<CustomDatePicker value={dueDate} onChange={v => setDueDate(v)} />
</div>
<div>
<label style={labelStyle}>{t('todo.detail.assignedTo')}</label>
<CustomSelect
value={String(assignedUserId ?? '')}
onChange={v => setAssignedUserId(v ? Number(v) : null)}
options={[
{ value: '', label: t('todo.unassigned'), icon: <User size={14} style={{ color: 'var(--text-faint)' }} /> },
...members.map(m => ({
value: String(m.id), label: m.username,
icon: m.avatar ? (
<img src={`/uploads/avatars/${m.avatar}`} style={{ width: 18, height: 18, borderRadius: '50%', objectFit: 'cover' as const }} alt="" />
) : (
<span style={{ width: 18, height: 18, borderRadius: '50%', background: 'var(--border-primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, color: 'var(--text-faint)', fontWeight: 600 }}>
{m.username.charAt(0).toUpperCase()}
</span>
),
})),
]}
placeholder={t('todo.unassigned')}
size="sm"
/>
</div>
</div>
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--border-faint)' }}>
<button onClick={create} disabled={!name.trim() || saving}
style={{
width: '100%', padding: '9px 16px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: name.trim() ? 'pointer' : 'default', fontFamily: 'inherit',
border: 'none', background: name.trim() ? 'var(--text-primary)' : 'var(--border-faint)',
color: name.trim() ? 'var(--bg-primary)' : 'var(--text-faint)', transition: 'all 0.15s',
}}>
{saving ? '...' : t('todo.detail.create')}
</button>
</div>
</div>
)
}
+105 -17
View File
@@ -1,9 +1,10 @@
import { useState, useEffect, useRef } from 'react'
import Modal from '../shared/Modal'
import { Calendar, Camera, X, Clipboard, UserPlus } from 'lucide-react'
import { Calendar, Camera, X, Clipboard, UserPlus, Bell } from 'lucide-react'
import { tripsApi, authApi } from '../../api/client'
import CustomSelect from '../shared/CustomSelect'
import { useAuthStore } from '../../store/authStore'
import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
@@ -23,13 +24,21 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const toast = useToast()
const { t } = useTranslation()
const currentUser = useAuthStore(s => s.user)
const tripRemindersEnabled = useAuthStore(s => s.tripRemindersEnabled)
const setTripRemindersEnabled = useAuthStore(s => s.setTripRemindersEnabled)
const can = useCanDo()
const canUploadCover = !isEditing || can('trip_cover_upload', trip)
const canEditTrip = !isEditing || can('trip_edit', trip)
const [formData, setFormData] = useState({
title: '',
description: '',
start_date: '',
end_date: '',
reminder_days: 0 as number,
day_count: 7,
})
const [customReminder, setCustomReminder] = useState(false)
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [coverPreview, setCoverPreview] = useState(null)
@@ -41,25 +50,41 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
useEffect(() => {
if (trip) {
const rd = trip.reminder_days ?? 3
setFormData({
title: trip.title || '',
description: trip.description || '',
start_date: trip.start_date || '',
end_date: trip.end_date || '',
reminder_days: rd,
day_count: trip.day_count || 7,
})
setCustomReminder(![0, 1, 3, 9].includes(rd))
setCoverPreview(trip.cover_image || null)
} else {
setFormData({ title: '', description: '', start_date: '', end_date: '' })
setFormData({ title: '', description: '', start_date: '', end_date: '', reminder_days: tripRemindersEnabled ? 3 : 0, day_count: 7 })
setCustomReminder(false)
setCoverPreview(null)
}
setPendingCoverFile(null)
setSelectedMembers([])
setError('')
if (isOpen) {
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
}).catch(() => {})
}
if (!trip) {
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
}
}, [trip, isOpen])
useEffect(() => {
if (!trip && isOpen) {
setFormData(prev => ({ ...prev, reminder_days: tripRemindersEnabled ? 3 : 0 }))
}
}, [tripRemindersEnabled])
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
@@ -74,6 +99,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
description: formData.description.trim() || null,
start_date: formData.start_date || null,
end_date: formData.end_date || null,
reminder_days: formData.reminder_days,
...(!formData.start_date && !formData.end_date ? { day_count: formData.day_count } : {}),
})
// Add selected members for newly created trips
if (selectedMembers.length > 0 && result?.trip?.id) {
@@ -154,6 +181,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
// Paste support for cover image
const handlePaste = (e) => {
if (!canUploadCover) return
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
@@ -172,10 +200,10 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
if (!prev.end_date || prev.end_date < value) {
next.end_date = value
} else if (prev.start_date) {
const oldStart = new Date(prev.start_date + 'T00:00:00')
const oldEnd = new Date(prev.end_date + 'T00:00:00')
const oldStart = new Date(prev.start_date + 'T00:00:00Z')
const oldEnd = new Date(prev.end_date + 'T00:00:00Z')
const duration = Math.round((oldEnd - oldStart) / 86400000)
const newEnd = new Date(value + 'T00:00:00')
const newEnd = new Date(value + 'T00:00:00Z')
newEnd.setDate(newEnd.getDate() + duration)
next.end_date = newEnd.toISOString().split('T')[0]
}
@@ -211,8 +239,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">{error}</div>
)}
{/* Cover image — available for both create and edit */}
<div>
{/* Cover image — gated by trip_cover_upload permission */}
{canUploadCover && <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.coverImage')}</label>
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleCoverChange} />
{coverPreview ? (
@@ -240,20 +268,20 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
</button>
)}
</div>
</div>}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
{t('dashboard.tripTitle')} <span className="text-red-500">*</span>
</label>
<input type="text" value={formData.title} onChange={e => update('title', e.target.value)}
required placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
<input type="text" value={formData.title} onChange={e => canEditTrip && update('title', e.target.value)}
required readOnly={!canEditTrip} placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.tripDescription')}</label>
<textarea value={formData.description} onChange={e => update('description', e.target.value)}
placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
<textarea value={formData.description} onChange={e => canEditTrip && update('description', e.target.value)}
readOnly={!canEditTrip} placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
className={`${inputCls} resize-none`} />
</div>
@@ -272,6 +300,71 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
</div>
</div>
{!formData.start_date && !formData.end_date && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
{t('dashboard.dayCount')}
</label>
<input type="number" min={1} max={365} value={formData.day_count}
onChange={e => update('day_count', Math.max(1, Math.min(365, Number(e.target.value) || 1)))}
className={inputCls} />
<p className="text-xs text-slate-400 mt-1.5">{t('dashboard.dayCountHint')}</p>
</div>
)}
{/* Reminder — only visible to owner (or when creating) */}
{(!isEditing || trip?.user_id === currentUser?.id || currentUser?.role === 'admin') && (
<div className={!tripRemindersEnabled ? 'opacity-50' : ''}>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
<Bell className="inline w-4 h-4 mr-1" />{t('trips.reminder')}
</label>
{!tripRemindersEnabled ? (
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
{t('trips.reminderDisabledHint')}
</p>
) : (
<>
<div className="flex flex-wrap gap-2">
{[
{ value: 0, label: t('trips.reminderNone') },
{ value: 1, label: `1 ${t('trips.reminderDay')}` },
{ value: 3, label: `3 ${t('trips.reminderDays')}` },
{ value: 9, label: `9 ${t('trips.reminderDays')}` },
].map(opt => (
<button key={opt.value} type="button"
onClick={() => { update('reminder_days', opt.value); setCustomReminder(false) }}
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
!customReminder && formData.reminder_days === opt.value
? 'bg-slate-900 text-white border-slate-900'
: 'bg-white text-slate-600 border-slate-200 hover:border-slate-300'
}`}>
{opt.label}
</button>
))}
<button type="button"
onClick={() => { setCustomReminder(true); if ([0, 1, 3, 9].includes(formData.reminder_days)) update('reminder_days', 7) }}
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
customReminder
? 'bg-slate-900 text-white border-slate-900'
: 'bg-white text-slate-600 border-slate-200 hover:border-slate-300'
}`}>
{t('trips.reminderCustom')}
</button>
</div>
{customReminder && (
<div className="flex items-center gap-2 mt-2">
<input type="number" min={1} max={30}
value={formData.reminder_days}
onChange={e => update('reminder_days', Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
className="w-20 px-3 py-1.5 border border-slate-200 rounded-lg text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-300" />
<span className="text-xs text-slate-500">{t('trips.reminderDaysBefore')}</span>
</div>
)}
</>
)}
</div>
)}
{/* Members — only for new trips */}
{!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
<div>
@@ -312,11 +405,6 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
</div>
)}
{!formData.start_date && !formData.end_date && (
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
{t('dashboard.noDateHint')}
</p>
)}
</form>
</Modal>
)
@@ -3,6 +3,8 @@ import Modal from '../shared/Modal'
import { tripsApi, authApi, shareApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useAuthStore } from '../../store/authStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../types'
@@ -32,7 +34,7 @@ function Avatar({ username, avatarUrl, size = 32 }: AvatarProps) {
)
}
function ShareLinkSection({ tripId, t }: { tripId: number; t: any }) {
function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record<string, string | number>) => string }) {
const [shareToken, setShareToken] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false)
@@ -172,6 +174,10 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
const toast = useToast()
const { user } = useAuthStore()
const { t } = useTranslation()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canManageMembers = can('member_manage', trip)
const canManageShare = can('share_manage', trip)
useEffect(() => {
if (isOpen && tripId) {
@@ -247,7 +253,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
return (
<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">
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 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 */}
@@ -260,7 +266,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
</div>
{/* Add member dropdown */}
<div>
{canManageMembers && <div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 8 }}>
{t('members.inviteUser')}
</label>
@@ -293,10 +299,10 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
<UserPlus size={13} /> {adding ? '…' : t('members.invite')}
</button>
</div>
{availableUsers.length === 0 && allUsers.length > 0 && (
{availableUsers.length === 0 && allUsers.length > 0 && canManageMembers && (
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '6px 0 0' }}>{t('members.allHaveAccess')}</p>
)}
</div>
</div>}
{/* Members list */}
<div>
@@ -317,7 +323,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{allMembers.map(member => {
const isSelf = member.id === user?.id
const canRemove = isCurrentOwner ? member.role !== 'owner' : isSelf
const canRemove = isSelf || (canManageMembers && member.role !== 'owner')
return (
<div key={member.id} style={{
display: 'flex', alignItems: 'center', gap: 10,
@@ -358,9 +364,9 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
</div>
{/* Right column: Share Link */}
<div style={{ borderLeft: '1px solid var(--border-faint)', paddingLeft: 24 }}>
{canManageShare && <div style={{ borderLeft: '1px solid var(--border-faint)', paddingLeft: 24 }}>
<ShareLinkSection tripId={tripId} t={t} />
</div>
</div>}
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
</div>
@@ -81,6 +81,7 @@ export default function VacayMonthCard({
return (
<div
key={di}
title={holiday ? (holiday.label ? `${holiday.label}: ${holiday.localName}` : holiday.localName) : undefined}
className="relative flex items-center justify-center cursor-pointer transition-colors"
style={{
height: 28,
+8 -8
View File
@@ -104,18 +104,18 @@ export function getHolidays(year: number, bundesland: string = 'NW'): Record<str
}
export function isWeekend(dateStr: string, weekendDays: number[] = [0, 6]): boolean {
const d = new Date(dateStr + 'T00:00:00')
return weekendDays.includes(d.getDay())
const d = new Date(dateStr + 'T00:00:00Z')
return weekendDays.includes(d.getUTCDay())
}
export function getWeekday(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00')
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getDay()]
const d = new Date(dateStr + 'T00:00:00Z')
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getUTCDay()]
}
export function getWeekdayFull(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00')
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getDay()]
const d = new Date(dateStr + 'T00:00:00Z')
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getUTCDay()]
}
export function daysInMonth(year: number, month: number): number {
@@ -123,8 +123,8 @@ export function daysInMonth(year: number, month: number): number {
}
export function formatDate(dateStr: string, locale?: string): string {
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
const d = new Date(dateStr + 'T00:00:00Z')
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' })
}
export { BUNDESLAENDER }
@@ -11,17 +11,19 @@ interface CustomDatePickerProps {
onChange: (value: string) => void
placeholder?: string
style?: React.CSSProperties
compact?: boolean
borderless?: boolean
}
export function CustomDatePicker({ value, onChange, placeholder, style = {} }: CustomDatePickerProps) {
export function CustomDatePicker({ value, onChange, placeholder, style = {}, compact = false, borderless = false }: CustomDatePickerProps) {
const { locale, t } = useTranslation()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
const parsed = value ? new Date(value + 'T00:00:00') : null
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || new Date().getFullYear())
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() ?? new Date().getMonth())
const parsed = value ? new Date(value + 'T00:00:00Z') : null
const [viewYear, setViewYear] = useState(parsed?.getUTCFullYear() || new Date().getFullYear())
const [viewMonth, setViewMonth] = useState(parsed?.getUTCMonth() ?? new Date().getMonth())
useEffect(() => {
const handler = (e: MouseEvent) => {
@@ -34,7 +36,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
}, [open])
useEffect(() => {
if (open && parsed) { setViewYear(parsed.getFullYear()); setViewMonth(parsed.getMonth()) }
if (open && parsed) { setViewYear(parsed.getUTCFullYear()); setViewMonth(parsed.getUTCMonth()) }
}, [open])
const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) } else setViewMonth(m => m - 1) }
@@ -45,7 +47,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7
const weekdays = Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' }))
const displayValue = parsed ? parsed.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' }) : null
const displayValue = parsed ? parsed.toLocaleDateString(locale, compact ? { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' } : { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : null
const selectDay = (day: number) => {
const y = String(viewYear)
@@ -55,7 +57,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
setOpen(false)
}
const selectedDay = parsed && parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth ? parsed.getDate() : null
const selectedDay = parsed && parsed.getUTCFullYear() === viewYear && parsed.getUTCMonth() === viewMonth ? parsed.getUTCDate() : null
const today = new Date()
const isToday = (d: number) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
@@ -97,16 +99,16 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C
) : (
<button type="button" onClick={() => setOpen(o => !o)} onDoubleClick={() => { setTextInput(value || ''); setIsTyping(true) }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 14px', borderRadius: 10,
border: '1px solid var(--border-primary)',
background: 'var(--bg-input)', color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: compact ? 4 : 8,
padding: compact ? '4px 6px' : '8px 14px', borderRadius: compact ? 4 : 10,
border: borderless ? 'none' : '1px solid var(--border-primary)',
background: borderless ? 'transparent' : 'var(--bg-input)', color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
fontSize: 13, fontFamily: 'inherit', cursor: 'pointer', outline: 'none',
transition: 'border-color 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}>
<Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
{!compact && <Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />}
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayValue || placeholder || t('common.date')}</span>
</button>
)}
@@ -19,6 +19,7 @@ interface CustomSelectProps {
searchable?: boolean
style?: React.CSSProperties
size?: 'sm' | 'md'
disabled?: boolean
}
export default function CustomSelect({
@@ -29,6 +30,7 @@ export default function CustomSelect({
searchable = false,
style = {},
size = 'md',
disabled = false,
}: CustomSelectProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
@@ -83,17 +85,19 @@ export default function CustomSelect({
{/* Trigger */}
<button
type="button"
onClick={() => { setOpen(o => !o); setSearch('') }}
disabled={disabled}
onClick={() => { if (!disabled) { setOpen(o => !o); setSearch('') } }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: sm ? '8px 12px' : '8px 14px', borderRadius: 10,
border: '1px solid var(--border-primary)',
background: 'var(--bg-input)', color: 'var(--text-primary)',
fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
cursor: 'pointer', outline: 'none', textAlign: 'left',
cursor: disabled ? 'default' : 'pointer', outline: 'none', textAlign: 'left',
transition: 'border-color 0.15s', overflow: 'hidden', minWidth: 0,
opacity: disabled ? 0.5 : 1,
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
onMouseEnter={e => { if (!disabled) e.currentTarget.style.borderColor = 'var(--text-faint)' }}
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
>
{selected?.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{selected.icon}</span>}
@@ -69,6 +69,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value
onChange(raw)
if (is12h) return // let handleBlur parse 12h formats
const clean = raw.replace(/[^0-9:]/g, '')
if (/^\d{2}:\d{2}$/.test(clean)) onChange(clean)
else if (/^\d{4}$/.test(clean)) onChange(clean.slice(0, 2) + ':' + clean.slice(2))
@@ -80,7 +81,23 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
const handleBlur = () => {
if (!value) return
const clean = value.replace(/[^0-9:]/g, '')
const raw = value.trim()
// Parse 12h input like "5:30 PM", "5:30pm", "530pm"
if (is12h) {
const match12 = raw.match(/^(\d{1,2}):?(\d{2})?\s*(am|pm)$/i)
if (match12) {
let h = parseInt(match12[1])
const m = match12[2] ? parseInt(match12[2]) : 0
const isPm = match12[3].toLowerCase() === 'pm'
if (h === 12) h = isPm ? 12 : 0
else if (isPm) h += 12
onChange(String(Math.min(23, h)).padStart(2, '0') + ':' + String(Math.min(59, m)).padStart(2, '0'))
return
}
}
const clean = raw.replace(/[^0-9:]/g, '')
if (/^\d{1,2}:\d{2}$/.test(clean)) {
const [hh, mm] = clean.split(':')
const h = Math.min(23, Math.max(0, parseInt(hh)))
+37 -42
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'
import { mapsApi } from '../../api/client'
import React, { useState, useEffect, useRef } from 'react'
import { getCategoryIcon } from './categoryIcons'
import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService'
import type { Place } from '../../types'
interface Category {
@@ -14,57 +14,52 @@ interface PlaceAvatarProps {
category?: Category | null
}
const photoCache = new Map<string, string | null>()
const photoInFlight = new Set<string>()
// Event-based notification instead of polling intervals
const photoListeners = new Map<string, Set<(url: string | null) => void>>()
function notifyListeners(key: string, url: string | null) {
const listeners = photoListeners.get(key)
if (listeners) {
listeners.forEach(fn => fn(url))
photoListeners.delete(key)
}
}
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
const [visible, setVisible] = useState(false)
const ref = useRef<HTMLDivElement>(null)
// Observe visibility — fetch photo only when avatar enters viewport
useEffect(() => {
if (place.image_url) { setVisible(true); return }
const el = ref.current
if (!el) return
// Check if already cached — show immediately without waiting for intersection
const photoId = place.google_place_id || place.osm_id
const cacheKey = photoId || `${place.lat},${place.lng}`
if (cacheKey && getCached(cacheKey)) { setVisible(true); return }
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setVisible(true); io.disconnect() } }, { rootMargin: '200px' })
io.observe(el)
return () => io.disconnect()
}, [place.id])
useEffect(() => {
if (!visible) return
if (place.image_url) { setPhotoSrc(place.image_url); return }
const photoId = place.google_place_id || place.osm_id
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
const cacheKey = photoId || `${place.lat},${place.lng}`
if (photoCache.has(cacheKey)) {
const cached = photoCache.get(cacheKey)
if (cached) setPhotoSrc(cached)
const cached = getCached(cacheKey)
if (cached) {
setPhotoSrc(cached.thumbDataUrl || cached.photoUrl)
if (!cached.thumbDataUrl && cached.photoUrl) {
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
}
return
}
if (photoInFlight.has(cacheKey)) {
// Subscribe to notification instead of polling
if (!photoListeners.has(cacheKey)) photoListeners.set(cacheKey, new Set())
const handler = (url: string | null) => { if (url) setPhotoSrc(url) }
photoListeners.get(cacheKey)!.add(handler)
return () => { photoListeners.get(cacheKey)?.delete(handler) }
if (isLoading(cacheKey)) {
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
}
photoInFlight.add(cacheKey)
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
.then((data: { photoUrl?: string }) => {
const url = data.photoUrl || null
photoCache.set(cacheKey, url)
if (url) setPhotoSrc(url)
notifyListeners(cacheKey, url)
photoInFlight.delete(cacheKey)
})
.catch(() => {
photoCache.set(cacheKey, null)
notifyListeners(cacheKey, null)
photoInFlight.delete(cacheKey)
})
}, [place.id, place.image_url, place.google_place_id, place.osm_id])
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name,
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) }
)
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
}, [visible, place.id, place.image_url, place.google_place_id, place.osm_id])
const bgColor = category?.color || '#6366f1'
const IconComp = getCategoryIcon(category?.icon)
@@ -81,11 +76,11 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
if (photoSrc) {
return (
<div style={containerStyle}>
<div ref={ref} style={containerStyle}>
<img
src={photoSrc}
alt={place.name}
loading="lazy"
decoding="async"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => setPhotoSrc(null)}
/>
@@ -94,7 +89,7 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
}
return (
<div style={containerStyle}>
<div ref={ref} style={containerStyle}>
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
</div>
)
@@ -0,0 +1,20 @@
import { useEffect } from 'react'
import { addListener, removeListener } from '../api/websocket'
import { useInAppNotificationStore } from '../store/inAppNotificationStore.ts'
export function useInAppNotificationListener(): void {
const handleNew = useInAppNotificationStore(s => s.handleNewNotification)
const handleUpdated = useInAppNotificationStore(s => s.handleUpdatedNotification)
useEffect(() => {
const listener = (event: Record<string, unknown>) => {
if (event.type === 'notification:new') {
handleNew(event.notification as any)
} else if (event.type === 'notification:updated') {
handleUpdated(event.notification as any)
}
}
addListener(listener)
return () => removeListener(listener)
}, [handleNew, handleUpdated])
}
+29
View File
@@ -0,0 +1,29 @@
import { useRef, useReducer } from 'react'
export interface UndoEntry {
label: string
undo: () => Promise<void> | void
}
export function usePlannerHistory(maxEntries = 30) {
const historyRef = useRef<UndoEntry[]>([])
const [, forceUpdate] = useReducer((x: number) => x + 1, 0)
const pushUndo = (label: string, undoFn: () => Promise<void> | void) => {
historyRef.current = [{ label, undo: undoFn }, ...historyRef.current].slice(0, maxEntries)
forceUpdate()
}
const undo = async () => {
if (historyRef.current.length === 0) return
const [first, ...rest] = historyRef.current
historyRef.current = rest
forceUpdate()
try { await first.undo() } catch (e) { console.error('Undo failed:', e) }
}
const canUndo = historyRef.current.length > 0
const lastActionLabel = historyRef.current[0]?.label ?? null
return { pushUndo, undo, canUndo, lastActionLabel }
}
+9 -3
View File
@@ -15,11 +15,15 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
const routeAbortRef = useRef<AbortController | null>(null)
// Keep a ref to the latest tripStore so updateRouteForDay never has a stale closure
const tripStoreRef = useRef(tripStore)
tripStoreRef.current = tripStore
const updateRouteForDay = useCallback(async (dayId: number | null) => {
if (routeAbortRef.current) routeAbortRef.current.abort()
if (!dayId) { setRoute(null); setRouteSegments([]); return }
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const currentAssignments = tripStoreRef.current.assignments || {}
const da = (currentAssignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const waypoints = da.map((a) => a.place).filter((p) => p?.lat && p?.lng)
if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
setRoute(waypoints.map((p) => [p.lat!, p.lng!]))
@@ -33,12 +37,14 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
else if (!(err instanceof Error)) setRouteSegments([])
}
}, [tripStore, routeCalcEnabled])
}, [routeCalcEnabled])
// Only recalculate when assignments for the SELECTED day change
const selectedDayAssignments = selectedDayId ? tripStore.assignments?.[String(selectedDayId)] : null
useEffect(() => {
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
updateRouteForDay(selectedDayId)
}, [selectedDayId, tripStore.assignments])
}, [selectedDayId, selectedDayAssignments])
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
}
+8 -4
View File
@@ -8,10 +8,12 @@ import hu from './translations/hu'
import it from './translations/it'
import ru from './translations/ru'
import zh from './translations/zh'
import zhTw from './translations/zhTw'
import nl from './translations/nl'
import ar from './translations/ar'
import br from './translations/br'
import cs from './translations/cs'
import pl from './translations/pl'
type TranslationStrings = Record<string, string | { name: string; category: string }[]>
@@ -24,14 +26,16 @@ export const SUPPORTED_LANGUAGES = [
{ value: 'nl', label: 'Nederlands' },
{ value: 'br', label: 'Português (Brasil)' },
{ value: 'cs', label: 'Česky' },
{ value: 'pl', label: 'Polski' },
{ value: 'ru', label: 'Русский' },
{ value: 'zh', label: '中文' },
{ value: 'zh', label: '简体中文' },
{ value: 'zh-TW', label: '繁體中文' },
{ value: 'it', label: 'Italiano' },
{ value: 'ar', label: 'العربية' },
] as const
const translations: Record<string, TranslationStrings> = { de, en, es, fr, hu, it, ru, zh, nl, ar, br, cs }
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', hu: 'hu-HU', it: 'it-IT', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA', br: 'pt-BR', cs: 'cs-CZ' }
const translations: Record<string, TranslationStrings> = { de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, ar, br, cs, pl }
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', 'zh-TW': 'zh-TW', nl: 'nl-NL', ar: 'ar-SA', br: 'pt-BR', cs: 'cs-CZ', pl: 'pl-PL' }
const RTL_LANGUAGES = new Set(['ar'])
export function getLocaleForLanguage(language: string): string {
@@ -40,7 +44,7 @@ export function getLocaleForLanguage(language: string): string {
export function getIntlLanguage(language: string): string {
if (language === 'br') return 'pt-BR'
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'nl', 'ar', 'cs'].includes(language) ? language : 'en'
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl'].includes(language) ? language : 'en'
}
export function isRtlLanguage(language: string): boolean {
+363 -13
View File
@@ -10,6 +10,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'common.edit': 'تعديل',
'common.add': 'إضافة',
'common.loading': 'جارٍ التحميل...',
'common.import': 'استيراد',
'common.error': 'خطأ',
'common.back': 'رجوع',
'common.all': 'الكل',
@@ -29,6 +30,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'البريد الإلكتروني',
'common.password': 'كلمة المرور',
'common.saving': 'جارٍ الحفظ...',
'common.saved': 'تم الحفظ',
'trips.reminder': 'تذكير',
'trips.reminderNone': 'بدون',
'trips.reminderDay': 'يوم',
'trips.reminderDays': 'أيام',
'trips.reminderCustom': 'مخصص',
'trips.reminderDaysBefore': 'أيام قبل المغادرة',
'trips.reminderDisabledHint': 'تذكيرات الرحلة معطلة. قم بتفعيلها من الإدارة > الإعدادات > الإشعارات.',
'common.update': 'تحديث',
'common.change': 'تغيير',
'common.uploading': 'جارٍ الرفع...',
@@ -76,7 +85,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'dashboard.sharedBy': 'شاركها {name}',
'dashboard.days': 'الأيام',
'dashboard.places': 'الأماكن',
'dashboard.members': 'ال חברים',
'dashboard.archive': 'أرشفة',
'dashboard.copyTrip': 'نسخ',
'dashboard.copySuffix': 'نسخة',
'dashboard.restore': 'استعادة',
'dashboard.archived': 'مؤرشفة',
'dashboard.status.ongoing': 'جارية',
@@ -95,6 +107,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'dashboard.toast.archiveError': 'فشل الأرشفة',
'dashboard.toast.restored': 'تمت استعادة الرحلة',
'dashboard.toast.restoreError': 'فشل الاستعادة',
'dashboard.toast.copied': 'تم نسخ الرحلة!',
'dashboard.toast.copyError': 'فشل نسخ الرحلة',
'dashboard.confirm.delete': 'حذف الرحلة "{title}"؟ سيتم حذف جميع الأماكن والخطط نهائيًا.',
'dashboard.editTrip': 'تعديل الرحلة',
'dashboard.createTrip': 'إنشاء رحلة جديدة',
@@ -104,6 +118,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'dashboard.tripDescriptionPlaceholder': 'عمّ تتحدث هذه الرحلة؟',
'dashboard.startDate': 'تاريخ البداية',
'dashboard.endDate': 'تاريخ النهاية',
'dashboard.dayCount': 'عدد الأيام',
'dashboard.dayCountHint': 'عدد الأيام المراد التخطيط لها عندما لا يتم تحديد تواريخ السفر.',
'dashboard.noDateHint': 'لا يوجد تاريخ محدد. سيتم إنشاء 7 أيام افتراضية ويمكنك تغيير ذلك لاحقًا.',
'dashboard.coverImage': 'صورة الغلاف',
'dashboard.addCoverImage': 'إضافة صورة غلاف',
@@ -118,6 +134,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// Settings
'settings.title': 'الإعدادات',
'settings.subtitle': 'ضبط إعداداتك الشخصية',
'settings.tabs.display': 'العرض',
'settings.tabs.map': 'الخريطة',
'settings.tabs.notifications': 'الإشعارات',
'settings.tabs.integrations': 'التكاملات',
'settings.tabs.account': 'الحساب',
'settings.tabs.about': 'حول',
'settings.map': 'الخريطة',
'settings.mapTemplate': 'قالب الخريطة',
'settings.mapTemplatePlaceholder.select': 'اختر قالبًا...',
@@ -154,9 +176,26 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)',
'settings.notifyPackingTagged': 'قائمة الأمتعة: التعيينات',
'settings.notifyWebhook': 'إشعارات Webhook',
'settings.notificationsDisabled': 'الإشعارات غير مكوّنة. اطلب من المسؤول تفعيل إشعارات البريد الإلكتروني أو Webhook.',
'settings.notificationsActive': 'القناة النشطة',
'settings.notificationsManagedByAdmin': 'يتم تكوين أحداث الإشعارات بواسطة المسؤول.',
'admin.notifications.title': 'الإشعارات',
'admin.notifications.hint': 'اختر قناة إشعارات واحدة. يمكن تفعيل واحدة فقط في كل مرة.',
'admin.notifications.none': 'معطّل',
'admin.notifications.email': 'البريد الإلكتروني (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'أحداث الإشعارات',
'admin.notifications.eventsHint': 'اختر الأحداث التي تُفعّل الإشعارات لجميع المستخدمين.',
'admin.notifications.configureFirst': 'قم بتكوين إعدادات SMTP أو Webhook أدناه أولاً، ثم قم بتفعيل الأحداث.',
'admin.notifications.save': 'حفظ إعدادات الإشعارات',
'admin.notifications.saved': 'تم حفظ إعدادات الإشعارات',
'admin.notifications.testWebhook': 'إرسال webhook تجريبي',
'admin.notifications.testWebhookSuccess': 'تم إرسال webhook التجريبي بنجاح',
'admin.notifications.testWebhookFailed': 'فشل إرسال webhook التجريبي',
'admin.smtp.title': 'البريد والإشعارات',
'admin.smtp.hint': 'تكوين SMTP لإشعارات البريد الإلكتروني. اختياري: عنوان Webhook لـ Discord أو Slack وغيرها.',
'admin.smtp.hint': 'تكوين SMTP لإرسال إشعارات البريد الإلكتروني.',
'admin.smtp.testButton': 'إرسال بريد تجريبي',
'admin.webhook.hint': 'إرسال الإشعارات إلى webhook خارجي (Discord، Slack، إلخ).',
'admin.smtp.testSuccess': 'تم إرسال البريد التجريبي بنجاح',
'admin.smtp.testFailed': 'فشل إرسال البريد التجريبي',
'dayplan.icsTooltip': 'تصدير التقويم (ICS)',
@@ -190,13 +229,48 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'share.permCollab': 'الدردشة',
'settings.on': 'تشغيل',
'settings.off': 'إيقاف',
'settings.mcp.title': 'إعداد MCP',
'settings.mcp.endpoint': 'نقطة نهاية MCP',
'settings.mcp.clientConfig': 'إعداد العميل',
'settings.mcp.clientConfigHint': 'استبدل <your_token> برمز API من القائمة أدناه. قد يحتاج مسار npx إلى ضبط وفق نظامك (مثلاً C:\\PROGRA~1\\nodejs\\npx.cmd على Windows).',
'settings.mcp.copy': 'نسخ',
'settings.mcp.copied': 'تم النسخ!',
'settings.mcp.apiTokens': 'رموز API',
'settings.mcp.createToken': 'إنشاء رمز جديد',
'settings.mcp.noTokens': 'لا توجد رموز بعد. أنشئ رمزاً للاتصال بعملاء MCP.',
'settings.mcp.tokenCreatedAt': 'أُنشئ',
'settings.mcp.tokenUsedAt': 'استُخدم',
'settings.mcp.deleteTokenTitle': 'حذف الرمز',
'settings.mcp.deleteTokenMessage': 'سيتوقف هذا الرمز عن العمل فوراً. أي عميل MCP يستخدمه سيفقد الوصول.',
'settings.mcp.modal.createTitle': 'إنشاء رمز API',
'settings.mcp.modal.tokenName': 'اسم الرمز',
'settings.mcp.modal.tokenNamePlaceholder': 'مثال: Claude Desktop، حاسوب العمل',
'settings.mcp.modal.creating': 'جارٍ الإنشاء…',
'settings.mcp.modal.create': 'إنشاء الرمز',
'settings.mcp.modal.createdTitle': 'تم إنشاء الرمز',
'settings.mcp.modal.createdWarning': 'سيُعرض هذا الرمز مرة واحدة فقط. انسخه واحفظه الآن — لا يمكن استرداده.',
'settings.mcp.modal.done': 'تم',
'settings.mcp.toast.created': 'تم إنشاء الرمز',
'settings.mcp.toast.createError': 'فشل إنشاء الرمز',
'settings.mcp.toast.deleted': 'تم حذف الرمز',
'settings.mcp.toast.deleteError': 'فشل حذف الرمز',
'settings.account': 'الحساب',
'settings.about': 'حول',
'settings.about.reportBug': 'الإبلاغ عن خطأ',
'settings.about.reportBugHint': 'وجدت مشكلة؟ أخبرنا',
'settings.about.featureRequest': 'اقتراح ميزة',
'settings.about.featureRequestHint': 'اقترح ميزة جديدة',
'settings.about.wikiHint': 'التوثيق والأدلة',
'settings.about.description': 'TREK هو مخطط سفر مستضاف ذاتيًا يساعدك على تنظيم رحلاتك من أول فكرة حتى آخر ذكرى. تخطيط يومي، ميزانية، قوائم تعبئة، صور والمزيد — كل شيء في مكان واحد، على خادمك الخاص.',
'settings.about.madeWith': 'صُنع بـ',
'settings.about.madeBy': 'بواسطة موريس ومجتمع مفتوح المصدر متنامٍ.',
'settings.username': 'اسم المستخدم',
'settings.email': 'البريد الإلكتروني',
'settings.role': 'الدور',
'settings.roleAdmin': 'مسؤول',
'settings.oidcLinked': 'مرتبط مع',
'settings.changePassword': 'تغيير كلمة المرور',
'settings.mustChangePassword': 'يجب عليك تغيير كلمة المرور قبل المتابعة. يرجى تعيين كلمة مرور جديدة أدناه.',
'settings.currentPassword': 'كلمة المرور الحالية',
'settings.currentPasswordRequired': 'كلمة المرور الحالية مطلوبة',
'settings.newPassword': 'كلمة المرور الجديدة',
@@ -205,7 +279,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.passwordRequired': 'أدخل كلمة المرور الحالية والجديدة',
'settings.passwordTooShort': 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل',
'settings.passwordMismatch': 'كلمتا المرور غير متطابقتين',
'settings.passwordWeak': 'يجب أن تحتوي كلمة المرور على حرف كبير وحرف صغير ورقم',
'settings.passwordWeak': 'يجب أن تحتوي كلمة المرور على حرف كبير وحرف صغير ورقم ورمز خاص',
'settings.passwordChanged': 'تم تغيير كلمة المرور بنجاح',
'settings.deleteAccount': 'حذف الحساب',
'settings.deleteAccountTitle': 'هل تريد حذف حسابك؟',
@@ -226,6 +300,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.avatarError': 'فشل الرفع',
'settings.mfa.title': 'المصادقة الثنائية (2FA)',
'settings.mfa.description': 'تضيف خطوة ثانية عند تسجيل الدخول. استخدم تطبيق مصادقة (Google Authenticator، Authy، إلخ).',
'settings.mfa.requiredByPolicy': 'المسؤول يتطلب المصادقة الثنائية. اضبط تطبيق المصادقة أدناه قبل المتابعة.',
'settings.mfa.backupTitle': 'رموز النسخ الاحتياطي',
'settings.mfa.backupDescription': 'استخدم هذه الرموز لمرة واحدة إذا فقدت الوصول إلى تطبيق المصادقة.',
'settings.mfa.backupWarning': 'احفظ هذه الرموز الآن. كل رمز يمكن استخدامه مرة واحدة فقط.',
'settings.mfa.backupCopy': 'نسخ الرموز',
'settings.mfa.backupDownload': 'تنزيل TXT',
'settings.mfa.backupPrint': 'طباعة / PDF',
'settings.mfa.backupCopied': 'تم نسخ رموز النسخ الاحتياطي',
'settings.mfa.enabled': 'المصادقة الثنائية مفعّلة على حسابك.',
'settings.mfa.disabled': 'المصادقة الثنائية غير مفعّلة.',
'settings.mfa.setup': 'إعداد المصادقة',
@@ -268,6 +350,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'login.signIn': 'دخول',
'login.createAdmin': 'إنشاء حساب مسؤول',
'login.createAdminHint': 'أعد إعداد أول حساب مسؤول لـ TREK.',
'login.setNewPassword': 'تعيين كلمة مرور جديدة',
'login.setNewPasswordHint': 'يجب عليك تغيير كلمة المرور قبل المتابعة.',
'login.createAccount': 'إنشاء حساب',
'login.createAccountHint': 'سجّل حسابًا جديدًا.',
'login.creating': 'جارٍ الإنشاء…',
@@ -283,6 +367,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'login.demoFailed': 'فشل الدخول إلى العرض التجريبي',
'login.oidcSignIn': 'تسجيل الدخول عبر {name}',
'login.oidcOnly': 'تم تعطيل المصادقة بكلمة المرور. يرجى تسجيل الدخول عبر مزود SSO.',
'login.oidcLoggedOut': 'تم تسجيل خروجك. سجّل الدخول مجدداً عبر مزود SSO.',
'login.demoHint': 'جرّب العرض التجريبي دون الحاجة للتسجيل',
'login.mfaTitle': 'المصادقة الثنائية',
'login.mfaSubtitle': 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
@@ -294,7 +379,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// Register
'register.passwordMismatch': 'كلمتا المرور غير متطابقتين',
'register.passwordTooShort': 'يجب أن تتكون كلمة المرور من 6 أحرف على الأقل',
'register.passwordTooShort': 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل',
'register.failed': 'فشل التسجيل',
'register.getStarted': 'ابدأ الآن',
'register.subtitle': 'أنشئ حسابًا وابدأ التخطيط لرحلات أحلامك.',
@@ -320,11 +405,25 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.users': 'المستخدمون',
'admin.tabs.categories': 'الفئات',
'admin.tabs.backup': 'النسخ الاحتياطي',
'admin.tabs.audit': 'سجل التدقيق',
'admin.tabs.audit': 'تدقيق',
'admin.tabs.settings': 'الإعدادات',
'admin.tabs.config': 'الإعدادات',
'admin.tabs.config': 'التخصيص',
'admin.tabs.templates': 'قوالب التعبئة',
'admin.tabs.addons': 'الإضافات',
'admin.tabs.mcpTokens': 'رموز MCP',
'admin.mcpTokens.title': 'رموز MCP',
'admin.mcpTokens.subtitle': 'إدارة رموز API لجميع المستخدمين',
'admin.mcpTokens.owner': 'المالك',
'admin.mcpTokens.tokenName': 'اسم الرمز',
'admin.mcpTokens.created': 'تاريخ الإنشاء',
'admin.mcpTokens.lastUsed': 'آخر استخدام',
'admin.mcpTokens.never': 'أبداً',
'admin.mcpTokens.empty': 'لم يتم إنشاء أي رموز MCP بعد',
'admin.mcpTokens.deleteTitle': 'حذف الرمز',
'admin.mcpTokens.deleteMessage': 'سيتم إلغاء هذا الرمز فوراً. سيفقد المستخدم وصوله إلى MCP عبر هذا الرمز.',
'admin.mcpTokens.deleteSuccess': 'تم حذف الرمز',
'admin.mcpTokens.deleteError': 'فشل حذف الرمز',
'admin.mcpTokens.loadError': 'فشل تحميل الرموز',
'admin.tabs.github': 'GitHub',
'admin.stats.users': 'المستخدمون',
'admin.stats.trips': 'الرحلات',
@@ -374,6 +473,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.invite.deleteError': 'فشل حذف رابط الدعوة',
'admin.allowRegistration': 'السماح بالتسجيل',
'admin.allowRegistrationHint': 'يمكن للمستخدمين الجدد التسجيل بأنفسهم',
'admin.requireMfa': 'فرض المصادقة الثنائية (2FA)',
'admin.requireMfaHint': 'يجب على المستخدمين الذين لا يملكون 2FA إكمال الإعداد في الإعدادات قبل استخدام التطبيق.',
'admin.apiKeys': 'مفاتيح API',
'admin.apiKeysHint': 'اختياري. يُفعّل بيانات الأماكن الموسعة مثل الصور والطقس.',
'admin.mapsKey': 'مفتاح Google Maps API',
@@ -425,10 +526,12 @@ 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.description': 'ألبومات صور مشتركة لكل رحلة',
'admin.addons.catalog.packing.name': 'التعبئة',
'admin.addons.catalog.packing.description': 'قوائم تحقق لإعداد أمتعتك لكل رحلة',
'admin.addons.catalog.memories.name': 'صور (Immich)',
'admin.addons.catalog.memories.description': 'شارك صور رحلتك عبر Immich',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'بروتوكول سياق النموذج لتكامل مساعد الذكاء الاصطناعي',
'admin.addons.catalog.packing.name': 'القوائم',
'admin.addons.catalog.packing.description': 'قوائم التعبئة والمهام لرحلاتك',
'admin.addons.catalog.budget.name': 'الميزانية',
'admin.addons.catalog.budget.description': 'تتبع النفقات وخطط ميزانية الرحلة',
'admin.addons.catalog.documents.name': 'المستندات',
@@ -445,8 +548,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.disabled': 'معطّل',
'admin.addons.type.trip': 'رحلة',
'admin.addons.type.global': 'عام',
'admin.addons.type.integration': 'تكامل',
'admin.addons.tripHint': 'متاح كعلامة تبويب داخل كل رحلة',
'admin.addons.globalHint': 'متاح كقسم مستقل في التنقل الرئيسي',
'admin.addons.integrationHint': 'خدمات الواجهة الخلفية وتكاملات API بدون صفحة مخصصة',
'admin.addons.toast.updated': 'تم تحديث الإضافة',
'admin.addons.toast.error': 'فشل تحديث الإضافة',
'admin.addons.noAddons': 'لا توجد إضافات متاحة',
@@ -510,7 +615,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'vacay.subtitle': 'خطط وأدر أيام الإجازة',
'vacay.settings': 'الإعدادات',
'vacay.year': 'السنة',
'vacay.addYear': 'إضافة سنة',
'vacay.addYear': 'إضافة السنة التالية',
'vacay.addPrevYear': 'إضافة السنة السابقة',
'vacay.removeYear': 'إزالة السنة',
'vacay.removeYearConfirm': 'إزالة {year}؟',
'vacay.removeYearHint': 'سيتم حذف كل إدخالات الإجازات والعطل الخاصة بهذه السنة نهائيًا.',
@@ -601,10 +707,13 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'atlas.unmark': 'إزالة',
'atlas.confirmMark': 'تعيين هذا البلد كمُزار؟',
'atlas.confirmUnmark': 'إزالة هذا البلد من قائمة المُزارة؟',
'atlas.confirmUnmarkRegion': 'إزالة هذه المنطقة من قائمة المُزارة؟',
'atlas.markVisited': 'تعيين كمُزار',
'atlas.markVisitedHint': 'إضافة هذا البلد إلى قائمة المُزارة',
'atlas.markRegionVisitedHint': 'إضافة هذه المنطقة إلى قائمة المُزارة',
'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات',
'atlas.addPoi': 'إضافة مكان',
'atlas.searchCountry': 'ابحث عن دولة...',
'atlas.bucketNamePlaceholder': 'الاسم (بلد، مدينة، مكان…)',
'atlas.month': 'الشهر',
'atlas.year': 'السنة',
@@ -613,7 +722,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'atlas.statsTab': 'الإحصائيات',
'atlas.bucketTab': 'قائمة الأمنيات',
'atlas.addBucket': 'إضافة إلى قائمة الأمنيات',
'atlas.bucketNamePlaceholder': 'مكان أو وجهة...',
'atlas.bucketNotesPlaceholder': 'ملاحظات (اختياري)',
'atlas.bucketEmpty': 'قائمة أمنياتك فارغة',
'atlas.bucketEmptyHint': 'أضف أماكن تحلم بزيارتها',
@@ -626,7 +734,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'atlas.nextTrip': 'الرحلة القادمة',
'atlas.daysLeft': 'يوم متبقٍ',
'atlas.streak': 'سلسلة',
'atlas.year': 'سنة',
'atlas.years': 'سنوات',
'atlas.yearInRow': 'سنة متتالية',
'atlas.yearsInRow': 'سنوات متتالية',
@@ -653,9 +760,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'حجز',
'trip.tabs.packing': 'قائمة التجهيز',
'trip.tabs.packingShort': 'تجهيز',
'trip.tabs.lists': 'القوائم',
'trip.tabs.listsShort': 'القوائم',
'trip.tabs.budget': 'الميزانية',
'trip.tabs.files': 'الملفات',
'trip.loading': 'جارٍ تحميل الرحلة...',
'trip.loadingPhotos': 'جارٍ تحميل صور الأماكن...',
'trip.mobilePlan': 'الخطة',
'trip.mobilePlaces': 'الأماكن',
'trip.toast.placeUpdated': 'تم تحديث المكان',
@@ -702,9 +812,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// Places Sidebar
'places.addPlace': 'إضافة مكان/نشاط',
'places.importGpx': 'استيراد GPX',
'places.importGpx': 'GPX',
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
'places.gpxError': 'فشل استيراد GPX',
'places.importGoogleList': 'قائمة Google',
'places.googleListHint': 'الصق رابط قائمة Google Maps المشتركة لاستيراد جميع الأماكن.',
'places.googleListImported': 'تم استيراد {count} أماكن من "{list}"',
'places.googleListError': 'فشل استيراد قائمة Google Maps',
'places.viewDetails': 'عرض التفاصيل',
'places.urlResolved': 'تم استيراد المكان من الرابط',
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
'places.all': 'الكل',
@@ -762,6 +877,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'inspector.addRes': 'حجز',
'inspector.editRes': 'تعديل الحجز',
'inspector.participants': 'المشاركون',
'inspector.trackStats': 'بيانات المسار',
// Reservations
'reservations.title': 'الحجوزات',
@@ -841,9 +957,36 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'ربط بخطة اليوم',
'reservations.pickAssignment': 'اختر عنصرًا من خطتك...',
'reservations.noAssignment': 'بلا ربط',
'reservations.price': 'السعر',
'reservations.budgetCategory': 'فئة الميزانية',
'reservations.budgetCategoryPlaceholder': 'مثال: المواصلات، الإقامة',
'reservations.budgetCategoryAuto': 'تلقائي (حسب نوع الحجز)',
'reservations.budgetHint': 'سيتم إنشاء إدخال في الميزانية تلقائيًا عند الحفظ.',
'reservations.departureDate': 'المغادرة',
'reservations.arrivalDate': 'الوصول',
'reservations.departureTime': 'وقت المغادرة',
'reservations.arrivalTime': 'وقت الوصول',
'reservations.pickupDate': 'الاستلام',
'reservations.returnDate': 'الإرجاع',
'reservations.pickupTime': 'وقت الاستلام',
'reservations.returnTime': 'وقت الإرجاع',
'reservations.endDate': 'تاريخ الانتهاء',
'reservations.meta.departureTimezone': 'TZ المغادرة',
'reservations.meta.arrivalTimezone': 'TZ الوصول',
'reservations.span.departure': 'المغادرة',
'reservations.span.arrival': 'الوصول',
'reservations.span.inTransit': 'في الطريق',
'reservations.span.pickup': 'الاستلام',
'reservations.span.return': 'الإرجاع',
'reservations.span.active': 'نشط',
'reservations.span.start': 'البداية',
'reservations.span.end': 'النهاية',
'reservations.span.ongoing': 'جارٍ',
'reservations.validation.endBeforeStart': 'يجب أن يكون تاريخ/وقت الانتهاء بعد تاريخ/وقت البدء',
// Budget
'budget.title': 'الميزانية',
'budget.exportCsv': 'تصدير CSV',
'budget.emptyTitle': 'لم يتم إنشاء ميزانية بعد',
'budget.emptyText': 'أنشئ فئات وإدخالات لتخطيط ميزانية سفرك',
'budget.emptyPlaceholder': 'أدخل اسم الفئة...',
@@ -858,6 +1001,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'budget.table.perDay': 'لكل يوم',
'budget.table.perPersonDay': 'لكل شخص / يوم',
'budget.table.note': 'ملاحظة',
'budget.table.date': 'التاريخ',
'budget.newEntry': 'إدخال جديد',
'budget.defaultEntry': 'إدخال جديد',
'budget.defaultCategory': 'فئة جديدة',
@@ -1251,6 +1395,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'memories.immichUrl': 'عنوان خادم Immich',
'memories.immichApiKey': 'مفتاح API',
'memories.testConnection': 'اختبار الاتصال',
'memories.testFirst': 'اختبر الاتصال أولاً',
'memories.connected': 'متصل',
'memories.disconnected': 'غير متصل',
'memories.connectionSuccess': 'تم الاتصال بـ Immich',
@@ -1260,6 +1405,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'memories.newest': 'الأحدث أولاً',
'memories.allLocations': 'جميع المواقع',
'memories.addPhotos': 'إضافة صور',
'memories.linkAlbum': 'ربط ألبوم',
'memories.selectAlbum': 'اختيار ألبوم Immich',
'memories.noAlbums': 'لم يتم العثور على ألبومات',
'memories.syncAlbum': 'مزامنة الألبوم',
'memories.unlinkAlbum': 'إلغاء الربط',
'memories.photos': 'صور',
'memories.selectPhotos': 'اختيار صور من Immich',
'memories.selectHint': 'انقر على الصور لتحديدها.',
'memories.selected': 'محدد',
@@ -1291,6 +1442,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'collab.chat.today': 'اليوم',
'collab.chat.yesterday': 'أمس',
'collab.chat.deletedMessage': 'حذف رسالة',
'collab.chat.reply': 'رد',
'collab.chat.loadMore': 'تحميل الرسائل الأقدم',
'collab.chat.justNow': 'الآن',
'collab.chat.minutesAgo': 'منذ {n} د',
@@ -1341,6 +1493,204 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'collab.polls.options': 'الخيارات',
'collab.polls.delete': 'حذف',
'collab.polls.closedSection': 'مغلق',
// Permissions
'admin.tabs.permissions': 'الصلاحيات',
'perm.title': 'إعدادات الصلاحيات',
'perm.subtitle': 'التحكم في من يمكنه تنفيذ الإجراءات عبر التطبيق',
'perm.saved': 'تم حفظ إعدادات الصلاحيات',
'perm.resetDefaults': 'إعادة التعيين إلى الافتراضي',
'perm.customized': 'مخصص',
'perm.level.admin': 'المسؤول فقط',
'perm.level.tripOwner': 'مالك الرحلة',
'perm.level.tripMember': 'أعضاء الرحلة',
'perm.level.everybody': 'الجميع',
'perm.cat.trip': 'إدارة الرحلات',
'perm.cat.members': 'إدارة الأعضاء',
'perm.cat.files': 'الملفات',
'perm.cat.content': 'المحتوى والجدول الزمني',
'perm.cat.extras': 'الميزانية والتعبئة والتعاون',
'perm.action.trip_create': 'إنشاء رحلات',
'perm.action.trip_edit': 'تعديل تفاصيل الرحلة',
'perm.action.trip_delete': 'حذف الرحلات',
'perm.action.trip_archive': 'أرشفة / إلغاء أرشفة الرحلات',
'perm.action.trip_cover_upload': 'رفع صورة الغلاف',
'perm.action.member_manage': 'إضافة / إزالة الأعضاء',
'perm.action.file_upload': 'رفع الملفات',
'perm.action.file_edit': 'تعديل بيانات الملف',
'perm.action.file_delete': 'حذف الملفات',
'perm.action.place_edit': 'إضافة / تعديل / حذف الأماكن',
'perm.action.day_edit': 'تعديل الأيام والملاحظات والتعيينات',
'perm.action.reservation_edit': 'إدارة الحجوزات',
'perm.action.budget_edit': 'إدارة الميزانية',
'perm.action.packing_edit': 'إدارة قوائم التعبئة',
'perm.action.collab_edit': 'التعاون (ملاحظات، استطلاعات، دردشة)',
'perm.action.share_manage': 'إدارة روابط المشاركة',
'perm.actionHint.trip_create': 'من يمكنه إنشاء رحلات جديدة',
'perm.actionHint.trip_edit': 'من يمكنه تغيير اسم الرحلة والتواريخ والوصف والعملة',
'perm.actionHint.trip_delete': 'من يمكنه حذف رحلة نهائياً',
'perm.actionHint.trip_archive': 'من يمكنه أرشفة أو إلغاء أرشفة رحلة',
'perm.actionHint.trip_cover_upload': 'من يمكنه رفع أو تغيير صورة الغلاف',
'perm.actionHint.member_manage': 'من يمكنه دعوة أو إزالة أعضاء الرحلة',
'perm.actionHint.file_upload': 'من يمكنه رفع ملفات إلى رحلة',
'perm.actionHint.file_edit': 'من يمكنه تعديل أوصاف الملفات والروابط',
'perm.actionHint.file_delete': 'من يمكنه نقل الملفات إلى سلة المهملات أو حذفها نهائياً',
'perm.actionHint.place_edit': 'من يمكنه إضافة أو تعديل أو حذف الأماكن',
'perm.actionHint.day_edit': 'من يمكنه تعديل الأيام وملاحظات الأيام وتعيينات الأماكن',
'perm.actionHint.reservation_edit': 'من يمكنه إنشاء أو تعديل أو حذف الحجوزات',
'perm.actionHint.budget_edit': 'من يمكنه إنشاء أو تعديل أو حذف عناصر الميزانية',
'perm.actionHint.packing_edit': 'من يمكنه إدارة عناصر التعبئة والحقائب',
'perm.actionHint.collab_edit': 'من يمكنه إنشاء ملاحظات واستطلاعات وإرسال رسائل',
'perm.actionHint.share_manage': 'من يمكنه إنشاء أو حذف روابط المشاركة العامة',
// Undo
'undo.button': 'تراجع',
'undo.tooltip': 'تراجع: {action}',
'undo.assignPlace': 'تم تعيين المكان لليوم',
'undo.removeAssignment': 'تم إزالة المكان من اليوم',
'undo.reorder': 'تمت إعادة ترتيب الأماكن',
'undo.optimize': 'تم تحسين المسار',
'undo.deletePlace': 'تم حذف المكان',
'undo.moveDay': 'تم نقل المكان إلى يوم آخر',
'undo.lock': 'تم تبديل قفل المكان',
'undo.importGpx': 'استيراد GPX',
'undo.importGoogleList': 'استيراد خرائط Google',
// Notifications
'notifications.title': 'الإشعارات',
'notifications.markAllRead': 'تحديد الكل كمقروء',
'notifications.deleteAll': 'حذف الكل',
'notifications.showAll': 'عرض جميع الإشعارات',
'notifications.empty': 'لا توجد إشعارات',
'notifications.emptyDescription': 'لقد اطلعت على كل شيء!',
'notifications.all': 'الكل',
'notifications.unreadOnly': 'غير مقروء',
'notifications.markRead': 'تحديد كمقروء',
'notifications.markUnread': 'تحديد كغير مقروء',
'notifications.delete': 'حذف',
'notifications.system': 'النظام',
'memories.error.loadAlbums': 'فشل تحميل الألبومات',
'memories.error.linkAlbum': 'فشل ربط الألبوم',
'memories.error.unlinkAlbum': 'فشل إلغاء ربط الألبوم',
'memories.error.syncAlbum': 'فشل مزامنة الألبوم',
'memories.error.loadPhotos': 'فشل تحميل الصور',
'memories.error.addPhotos': 'فشل إضافة الصور',
'memories.error.removePhoto': 'فشل حذف الصورة',
'memories.error.toggleSharing': 'فشل تحديث إعدادات المشاركة',
'undo.addPlace': 'تمت إضافة المكان',
'undo.done': 'تم التراجع: {action}',
'notifications.test.title': 'إشعار تجريبي من {actor}',
'notifications.test.text': 'هذا إشعار تجريبي بسيط.',
'notifications.test.booleanTitle': 'يطلب منك {actor} الموافقة',
'notifications.test.booleanText': 'إشعار تجريبي يتطلب إجابة.',
'notifications.test.accept': 'موافقة',
'notifications.test.decline': 'رفض',
'notifications.test.navigateTitle': 'تحقق من شيء ما',
'notifications.test.navigateText': 'إشعار تجريبي للتنقل.',
'notifications.test.goThere': 'اذهب إلى هناك',
'notifications.test.adminTitle': 'إذاعة المسؤول',
'notifications.test.adminText': 'أرسل {actor} إشعاراً تجريبياً لجميع المسؤولين.',
'notifications.test.tripTitle': 'نشر {actor} في رحلتك',
'notifications.test.tripText': 'إشعار تجريبي للرحلة "{trip}".',
// Todo
'todo.subtab.packing': 'قائمة الأمتعة',
'todo.subtab.todo': 'المهام',
'todo.completed': 'مكتمل',
'todo.filter.all': 'الكل',
'todo.filter.open': 'مفتوح',
'todo.filter.done': 'منجز',
'todo.uncategorized': 'بدون تصنيف',
'todo.namePlaceholder': 'اسم المهمة',
'todo.descriptionPlaceholder': 'وصف (اختياري)',
'todo.unassigned': 'غير مُسنَد',
'todo.noCategory': 'بدون فئة',
'todo.hasDescription': 'له وصف',
'todo.addItem': 'إضافة مهمة جديدة...',
'todo.newCategory': 'اسم الفئة',
'todo.addCategory': 'إضافة فئة',
'todo.newItem': 'مهمة جديدة',
'todo.empty': 'لا توجد مهام بعد. أضف مهمة للبدء!',
'todo.filter.my': 'مهامي',
'todo.filter.overdue': 'متأخرة',
'todo.sidebar.tasks': 'المهام',
'todo.sidebar.categories': 'الفئات',
'todo.detail.title': 'مهمة',
'todo.detail.description': 'وصف',
'todo.detail.category': 'فئة',
'todo.detail.dueDate': 'تاريخ الاستحقاق',
'todo.detail.assignedTo': 'مسند إلى',
'todo.detail.delete': 'حذف',
'todo.detail.save': 'حفظ التغييرات',
'todo.detail.create': 'إنشاء مهمة',
'todo.detail.priority': 'الأولوية',
'todo.detail.noPriority': 'لا شيء',
'todo.sortByPrio': 'الأولوية',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'إصدار جديد متاح',
'settings.notificationPreferences.noChannels': 'لم يتم تكوين قنوات إشعارات. اطلب من المسؤول إعداد إشعارات البريد الإلكتروني أو webhook.',
'settings.webhookUrl.label': 'رابط Webhook',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'أدخل رابط Webhook الخاص بـ Discord أو Slack أو المخصص لتلقي الإشعارات.',
'settings.webhookUrl.save': 'حفظ',
'settings.webhookUrl.saved': 'تم حفظ رابط Webhook',
'settings.webhookUrl.test': 'اختبار',
'settings.webhookUrl.testSuccess': 'تم إرسال Webhook الاختباري بنجاح',
'settings.webhookUrl.testFailed': 'فشل إرسال Webhook الاختباري',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint': 'الإشعارات داخل التطبيق نشطة دائمًا ولا يمكن تعطيلها بشكل عام.',
'admin.notifications.adminWebhookPanel.title': 'Webhook المسؤول',
'admin.notifications.adminWebhookPanel.hint': 'يُستخدم هذا الـ Webhook حصريًا لإشعارات المسؤول (مثل تنبيهات الإصدارات). وهو مستقل عن Webhooks المستخدمين ويُرسل تلقائيًا عند تعيين رابط URL.',
'admin.notifications.adminWebhookPanel.saved': 'تم حفظ رابط Webhook المسؤول',
'admin.notifications.adminWebhookPanel.testSuccess': 'تم إرسال Webhook الاختباري بنجاح',
'admin.notifications.adminWebhookPanel.testFailed': 'فشل إرسال Webhook الاختباري',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'يُرسل Webhook المسؤول تلقائيًا عند تعيين رابط URL',
'admin.notifications.adminNotificationsHint': 'حدد القنوات التي تُسلّم إشعارات المسؤول (مثل تنبيهات الإصدارات). يُرسل الـ Webhook تلقائيًا عند تعيين رابط URL لـ Webhook المسؤول.',
'admin.tabs.notifications': 'الإشعارات',
'notifications.versionAvailable.title': 'تحديث متاح',
'notifications.versionAvailable.text': 'TREK {version} متاح الآن.',
'notifications.versionAvailable.button': 'عرض التفاصيل',
'notif.test.title': '[اختبار] إشعار',
'notif.test.simple.text': 'هذا إشعار اختبار بسيط.',
'notif.test.boolean.text': 'هل تقبل هذا الإشعار الاختباري؟',
'notif.test.navigate.text': 'انقر أدناه للانتقال إلى لوحة التحكم.',
// Notifications
'notif.trip_invite.title': 'دعوة للرحلة',
'notif.trip_invite.text': '{actor} دعاك إلى {trip}',
'notif.booking_change.title': 'تم تحديث الحجز',
'notif.booking_change.text': '{actor} حدّث حجزاً في {trip}',
'notif.trip_reminder.title': 'تذكير بالرحلة',
'notif.trip_reminder.text': 'رحلتك {trip} تقترب!',
'notif.vacay_invite.title': 'دعوة دمج الإجازة',
'notif.vacay_invite.text': '{actor} يدعوك لدمج خطط الإجازة',
'notif.photos_shared.title': 'تمت مشاركة الصور',
'notif.photos_shared.text': '{actor} شارك {count} صورة في {trip}',
'notif.collab_message.title': 'رسالة جديدة',
'notif.collab_message.text': '{actor} أرسل رسالة في {trip}',
'notif.packing_tagged.title': 'مهمة التعبئة',
'notif.packing_tagged.text': '{actor} عيّنك في {category} في {trip}',
'notif.version_available.title': 'إصدار جديد متاح',
'notif.version_available.text': 'TREK {version} متاح الآن',
'notif.action.view_trip': 'عرض الرحلة',
'notif.action.view_collab': 'عرض الرسائل',
'notif.action.view_packing': 'عرض التعبئة',
'notif.action.view_photos': 'عرض الصور',
'notif.action.view_vacay': 'عرض Vacay',
'notif.action.view_admin': 'الذهاب للإدارة',
'notif.action.view': 'عرض',
'notif.action.accept': 'قبول',
'notif.action.decline': 'رفض',
'notif.generic.title': 'إشعار',
'notif.generic.text': 'لديك إشعار جديد',
'notif.dev.unknown_event.title': '[DEV] حدث غير معروف',
'notif.dev.unknown_event.text': 'نوع الحدث "{event}" غير مسجل في EVENT_NOTIFICATION_CONFIG',
}
export default ar
+361 -11
View File
@@ -6,6 +6,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'common.edit': 'Editar',
'common.add': 'Adicionar',
'common.loading': 'Carregando...',
'common.import': 'Importar',
'common.error': 'Erro',
'common.back': 'Voltar',
'common.all': 'Todos',
@@ -25,6 +26,14 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'E-mail',
'common.password': 'Senha',
'common.saving': 'Salvando...',
'common.saved': 'Salvo',
'trips.reminder': 'Lembrete',
'trips.reminderNone': 'Nenhum',
'trips.reminderDay': 'dia',
'trips.reminderDays': 'dias',
'trips.reminderCustom': 'Personalizado',
'trips.reminderDaysBefore': 'dias antes da partida',
'trips.reminderDisabledHint': 'Os lembretes de viagem estão desativados. Ative-os em Admin > Configurações > Notificações.',
'common.update': 'Atualizar',
'common.change': 'Alterar',
'common.uploading': 'Enviando…',
@@ -71,7 +80,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'dashboard.sharedBy': 'Compartilhada por {name}',
'dashboard.days': 'Dias',
'dashboard.places': 'Lugares',
'dashboard.members': 'Parceiros de viagem',
'dashboard.archive': 'Arquivar',
'dashboard.copyTrip': 'Copiar',
'dashboard.copySuffix': 'cópia',
'dashboard.restore': 'Restaurar',
'dashboard.archived': 'Arquivada',
'dashboard.status.ongoing': 'Em andamento',
@@ -90,6 +102,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'dashboard.toast.archiveError': 'Não foi possível arquivar',
'dashboard.toast.restored': 'Viagem restaurada',
'dashboard.toast.restoreError': 'Não foi possível restaurar',
'dashboard.toast.copied': 'Viagem copiada!',
'dashboard.toast.copyError': 'Não foi possível copiar a viagem',
'dashboard.confirm.delete': 'Excluir a viagem "{title}"? Todos os lugares e planos serão excluídos permanentemente.',
'dashboard.editTrip': 'Editar viagem',
'dashboard.createTrip': 'Criar nova viagem',
@@ -99,6 +113,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'dashboard.tripDescriptionPlaceholder': 'Sobre o que é esta viagem?',
'dashboard.startDate': 'Data de início',
'dashboard.endDate': 'Data de término',
'dashboard.dayCount': 'Número de dias',
'dashboard.dayCountHint': 'Quantos dias planejar quando nenhuma data de viagem for definida.',
'dashboard.noDateHint': 'Sem datas — serão criados 7 dias padrão. Você pode alterar depois.',
'dashboard.coverImage': 'Imagem de capa',
'dashboard.addCoverImage': 'Adicionar capa (ou arrastar e soltar)',
@@ -113,6 +129,12 @@ const br: Record<string, string | { name: string; category: string }[]> = {
// Settings
'settings.title': 'Configurações',
'settings.subtitle': 'Ajuste suas preferências pessoais',
'settings.tabs.display': 'Exibição',
'settings.tabs.map': 'Mapa',
'settings.tabs.notifications': 'Notificações',
'settings.tabs.integrations': 'Integrações',
'settings.tabs.account': 'Conta',
'settings.tabs.about': 'Sobre',
'settings.map': 'Mapa',
'settings.mapTemplate': 'Modelo de mapa',
'settings.mapTemplatePlaceholder.select': 'Selecione o modelo...',
@@ -149,9 +171,26 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyCollabMessage': 'Mensagens de chat (Colab)',
'settings.notifyPackingTagged': 'Lista de mala: atribuições',
'settings.notifyWebhook': 'Notificações webhook',
'settings.notificationsDisabled': 'As notificações não estão configuradas. Peça a um administrador para ativar notificações por e-mail ou webhook.',
'settings.notificationsActive': 'Canal ativo',
'settings.notificationsManagedByAdmin': 'Os eventos de notificação são configurados pelo administrador.',
'admin.notifications.title': 'Notificações',
'admin.notifications.hint': 'Escolha um canal de notificação. Apenas um pode estar ativo por vez.',
'admin.notifications.none': 'Desativado',
'admin.notifications.email': 'E-mail (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Eventos de notificação',
'admin.notifications.eventsHint': 'Escolha quais eventos acionam notificações para todos os usuários.',
'admin.notifications.configureFirst': 'Configure primeiro as configurações SMTP ou webhook abaixo, depois ative os eventos.',
'admin.notifications.save': 'Salvar configurações de notificação',
'admin.notifications.saved': 'Configurações de notificação salvas',
'admin.notifications.testWebhook': 'Enviar webhook de teste',
'admin.notifications.testWebhookSuccess': 'Webhook de teste enviado com sucesso',
'admin.notifications.testWebhookFailed': 'Falha ao enviar webhook de teste',
'admin.smtp.title': 'E-mail e notificações',
'admin.smtp.hint': 'Configuração SMTP para notificações por e-mail. Opcional: URL webhook para Discord, Slack, etc.',
'admin.smtp.hint': 'Configuração SMTP para envio de notificações por e-mail.',
'admin.smtp.testButton': 'Enviar e-mail de teste',
'admin.webhook.hint': 'Enviar notificações para um webhook externo (Discord, Slack, etc.).',
'admin.smtp.testSuccess': 'E-mail de teste enviado com sucesso',
'admin.smtp.testFailed': 'Falha ao enviar e-mail de teste',
'dayplan.icsTooltip': 'Exportar calendário (ICS)',
@@ -186,6 +225,15 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.on': 'Ligado',
'settings.off': 'Desligado',
'settings.account': 'Conta',
'settings.about': 'Sobre',
'settings.about.reportBug': 'Reportar um bug',
'settings.about.reportBugHint': 'Encontrou um problema? Nos avise',
'settings.about.featureRequest': 'Solicitar recurso',
'settings.about.featureRequestHint': 'Sugira um novo recurso',
'settings.about.wikiHint': 'Documentação e guias',
'settings.about.description': 'TREK é um planejador de viagens auto-hospedado que ajuda você a organizar suas viagens da primeira ideia à última lembrança. Planejamento diário, orçamento, listas de bagagem, fotos e muito mais — tudo em um só lugar, no seu próprio servidor.',
'settings.about.madeWith': 'Feito com',
'settings.about.madeBy': 'por Maurice e uma crescente comunidade open-source.',
'settings.username': 'Nome de usuário',
'settings.email': 'E-mail',
'settings.role': 'Função',
@@ -200,7 +248,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.passwordRequired': 'Informe a senha atual e a nova',
'settings.passwordTooShort': 'A senha deve ter pelo menos 8 caracteres',
'settings.passwordMismatch': 'As senhas não coincidem',
'settings.passwordWeak': 'A senha deve ter maiúscula, minúscula e número',
'settings.passwordWeak': 'A senha deve ter maiúscula, minúscula, número e um caractere especial',
'settings.passwordChanged': 'Senha alterada com sucesso',
'settings.deleteAccount': 'Excluir conta',
'settings.deleteAccountTitle': 'Excluir sua conta?',
@@ -221,6 +269,14 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.avatarError': 'Falha no envio',
'settings.mfa.title': 'Autenticação em duas etapas (2FA)',
'settings.mfa.description': 'Adiciona uma segunda etapa ao entrar com e-mail e senha. Use um app autenticador (Google Authenticator, Authy, etc.).',
'settings.mfa.requiredByPolicy': 'O administrador exige autenticação em dois fatores. Configure um app autenticador abaixo antes de continuar.',
'settings.mfa.backupTitle': 'Códigos de backup',
'settings.mfa.backupDescription': 'Use estes códigos únicos se perder acesso ao app autenticador.',
'settings.mfa.backupWarning': 'Salve estes códigos agora. Cada código pode ser usado apenas uma vez.',
'settings.mfa.backupCopy': 'Copiar códigos',
'settings.mfa.backupDownload': 'Baixar TXT',
'settings.mfa.backupPrint': 'Imprimir / PDF',
'settings.mfa.backupCopied': 'Códigos de backup copiados',
'settings.mfa.enabled': 'O 2FA está ativado na sua conta.',
'settings.mfa.disabled': 'O 2FA não está ativado.',
'settings.mfa.setup': 'Configurar autenticador',
@@ -235,6 +291,32 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.mfa.toastEnabled': 'Autenticação em duas etapas ativada',
'settings.mfa.toastDisabled': 'Autenticação em duas etapas desativada',
'settings.mfa.demoBlocked': 'Indisponível no modo demonstração',
'settings.mcp.title': 'Configuração MCP',
'settings.mcp.endpoint': 'Endpoint MCP',
'settings.mcp.clientConfig': 'Configuração do cliente',
'settings.mcp.clientConfigHint': 'Substitua <your_token> por um token de API da lista abaixo. O caminho para o npx pode precisar ser ajustado para o seu sistema (ex.: C:\\PROGRA~1\\nodejs\\npx.cmd no Windows).',
'settings.mcp.copy': 'Copiar',
'settings.mcp.copied': 'Copiado!',
'settings.mcp.apiTokens': 'Tokens de API',
'settings.mcp.createToken': 'Criar novo token',
'settings.mcp.noTokens': 'Nenhum token ainda. Crie um para conectar clientes MCP.',
'settings.mcp.tokenCreatedAt': 'Criado em',
'settings.mcp.tokenUsedAt': 'Usado em',
'settings.mcp.deleteTokenTitle': 'Excluir token',
'settings.mcp.deleteTokenMessage': 'Este token deixará de funcionar imediatamente. Qualquer cliente MCP que o utilize perderá o acesso.',
'settings.mcp.modal.createTitle': 'Criar token de API',
'settings.mcp.modal.tokenName': 'Nome do token',
'settings.mcp.modal.tokenNamePlaceholder': 'ex.: Claude Desktop, Notebook do trabalho',
'settings.mcp.modal.creating': 'Criando…',
'settings.mcp.modal.create': 'Criar token',
'settings.mcp.modal.createdTitle': 'Token criado',
'settings.mcp.modal.createdWarning': 'Este token será exibido apenas uma vez. Copie e guarde agora — não poderá ser recuperado.',
'settings.mcp.modal.done': 'Concluído',
'settings.mcp.toast.created': 'Token criado',
'settings.mcp.toast.createError': 'Falha ao criar token',
'settings.mcp.toast.deleted': 'Token excluído',
'settings.mcp.toast.deleteError': 'Falha ao excluir token',
'settings.mustChangePassword': 'Você deve alterar sua senha antes de continuar. Defina uma nova senha abaixo.',
// Login
'login.error': 'Falha no login. Verifique suas credenciais.',
@@ -263,6 +345,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'login.signIn': 'Entrar',
'login.createAdmin': 'Criar conta de administrador',
'login.createAdminHint': 'Configure a primeira conta de administrador do TREK.',
'login.setNewPassword': 'Definir nova senha',
'login.setNewPasswordHint': 'Você deve alterar sua senha antes de continuar.',
'login.createAccount': 'Criar conta',
'login.createAccountHint': 'Cadastre uma nova conta.',
'login.creating': 'Criando…',
@@ -278,6 +362,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'login.demoFailed': 'Falha no login de demonstração',
'login.oidcSignIn': 'Entrar com {name}',
'login.oidcOnly': 'Login por senha desativado. Use o provedor SSO.',
'login.oidcLoggedOut': 'Você foi desconectado. Entre novamente usando o provedor SSO.',
'login.demoHint': 'Experimente a demonstração — sem cadastro',
'login.mfaTitle': 'Autenticação em duas etapas',
'login.mfaSubtitle': 'Digite o código de 6 dígitos do seu app autenticador.',
@@ -289,7 +374,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
// Register
'register.passwordMismatch': 'As senhas não coincidem',
'register.passwordTooShort': 'A senha deve ter pelo menos 6 caracteres',
'register.passwordTooShort': 'A senha deve ter pelo menos 8 caracteres',
'register.failed': 'Falha no cadastro',
'register.getStarted': 'Começar',
'register.subtitle': 'Crie uma conta e comece a planejar suas viagens.',
@@ -364,6 +449,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.settings': 'Configurações',
'admin.allowRegistration': 'Permitir cadastro',
'admin.allowRegistrationHint': 'Novos usuários podem se cadastrar sozinhos',
'admin.requireMfa': 'Exigir autenticação em dois fatores (2FA)',
'admin.requireMfaHint': 'Usuários sem 2FA precisam concluir a configuração em Configurações antes de usar o app.',
'admin.apiKeys': 'Chaves de API',
'admin.apiKeysHint': 'Opcional. Habilita dados estendidos de lugares, como fotos e clima.',
'admin.mapsKey': 'Chave da API Google Maps',
@@ -394,7 +481,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
// Packing Templates & Bag Tracking
'admin.bagTracking.title': 'Rastreamento de malas',
'admin.bagTracking.subtitle': 'Ativar peso e atribuição de mala para itens da lista',
'admin.tabs.config': 'Configuração',
'admin.tabs.config': 'Personalização',
'admin.tabs.templates': 'Modelos de mala',
'admin.packingTemplates.title': 'Modelos de mala',
'admin.packingTemplates.subtitle': 'Crie listas de mala reutilizáveis para suas viagens',
@@ -420,8 +507,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.subtitle': 'Ative ou desative recursos para personalizar sua experiência no TREK.',
'admin.addons.catalog.memories.name': 'Memórias',
'admin.addons.catalog.memories.description': 'Álbuns de fotos compartilhados em cada viagem',
'admin.addons.catalog.packing.name': 'Mala',
'admin.addons.catalog.packing.description': 'Listas para preparar a bagagem de cada viagem',
'admin.addons.catalog.packing.name': 'Listas',
'admin.addons.catalog.packing.description': 'Listas de bagagem e tarefas a fazer para suas viagens',
'admin.addons.catalog.budget.name': 'Orçamento',
'admin.addons.catalog.budget.description': 'Acompanhe despesas e planeje o orçamento da viagem',
'admin.addons.catalog.documents.name': 'Documentos',
@@ -432,17 +519,21 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.catalog.atlas.description': 'Mapa mundial com países visitados e estatísticas',
'admin.addons.catalog.collab.name': 'Colab',
'admin.addons.catalog.collab.description': 'Notas, enquetes e chat em tempo real para planejar a viagem',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'Model Context Protocol para integração com assistentes de IA',
'admin.addons.subtitleBefore': 'Ative ou desative recursos para personalizar sua ',
'admin.addons.subtitleAfter': ' experiência.',
'admin.addons.enabled': 'Ativado',
'admin.addons.disabled': 'Desativado',
'admin.addons.type.trip': 'Viagem',
'admin.addons.type.global': 'Global',
'admin.addons.type.integration': 'Integração',
'admin.addons.tripHint': 'Disponível como aba em cada viagem',
'admin.addons.globalHint': 'Disponível como seção própria na navegação principal',
'admin.addons.toast.updated': 'Complemento atualizado',
'admin.addons.toast.error': 'Falha ao atualizar complemento',
'admin.addons.noAddons': 'Nenhum complemento disponível',
'admin.addons.integrationHint': 'Serviços de backend e integrações de API sem página dedicada',
// Weather info
'admin.weather.title': 'Dados meteorológicos',
'admin.weather.badge': 'Desde 24 de março de 2026',
@@ -455,7 +546,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.weather.requestsDesc': 'Grátis, sem chave de API',
'admin.weather.locationHint': 'O clima usa o primeiro lugar com coordenadas de cada dia. Se nenhum lugar estiver atribuído ao dia, qualquer lugar da lista serve como referência.',
'admin.tabs.audit': 'Log de auditoria',
'admin.tabs.audit': 'Audit',
'admin.audit.subtitle': 'Eventos sensíveis de segurança e administração (backups, usuários, 2FA, configurações).',
'admin.audit.empty': 'Nenhum registro de auditoria.',
@@ -505,7 +596,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'vacay.subtitle': 'Planeje e gerencie dias de férias',
'vacay.settings': 'Configurações',
'vacay.year': 'Ano',
'vacay.addYear': 'Adicionar ano',
'vacay.addYear': 'Adicionar próximo ano',
'vacay.addPrevYear': 'Adicionar ano anterior',
'vacay.removeYear': 'Remover ano',
'vacay.removeYearConfirm': 'Remover {year}?',
'vacay.removeYearHint': 'Todas as entradas de férias e feriados da empresa deste ano serão excluídas permanentemente.',
@@ -597,10 +689,13 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'atlas.unmark': 'Remover',
'atlas.confirmMark': 'Marcar este país como visitado?',
'atlas.confirmUnmark': 'Remover este país da lista de visitados?',
'atlas.confirmUnmarkRegion': 'Remover esta região da lista de visitados?',
'atlas.markVisited': 'Marcar como visitado',
'atlas.markVisitedHint': 'Adicionar este país à lista de visitados',
'atlas.markRegionVisitedHint': 'Adicionar esta região à lista de visitados',
'atlas.addToBucket': 'Adicionar à lista de desejos',
'atlas.addPoi': 'Adicionar lugar',
'atlas.searchCountry': 'Buscar um país...',
'atlas.bucketNamePlaceholder': 'Nome (país, cidade, lugar…)',
'atlas.month': 'Mês',
'atlas.year': 'Ano',
@@ -609,7 +704,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'atlas.statsTab': 'Estatísticas',
'atlas.bucketTab': 'Lista de desejos',
'atlas.addBucket': 'Adicionar à lista de desejos',
'atlas.bucketNamePlaceholder': 'Lugar ou destino...',
'atlas.bucketNotesPlaceholder': 'Notas (opcional)',
'atlas.bucketEmpty': 'Sua lista de desejos está vazia',
'atlas.bucketEmptyHint': 'Adicione lugares que sonha em visitar',
@@ -622,7 +716,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'atlas.nextTrip': 'Próxima viagem',
'atlas.daysLeft': 'dias restantes',
'atlas.streak': 'Sequência',
'atlas.year': 'ano',
'atlas.years': 'anos',
'atlas.yearInRow': 'ano seguido',
'atlas.yearsInRow': 'anos seguidos',
@@ -649,6 +742,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'Reservas',
'trip.tabs.packing': 'Lista de mala',
'trip.tabs.packingShort': 'Mala',
'trip.tabs.lists': 'Listas',
'trip.tabs.listsShort': 'Listas',
'trip.tabs.budget': 'Orçamento',
'trip.tabs.files': 'Arquivos',
'trip.loading': 'Carregando viagem...',
@@ -664,6 +759,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'trip.toast.reservationAdded': 'Reserva adicionada',
'trip.toast.deleted': 'Excluído',
'trip.confirm.deletePlace': 'Tem certeza de que deseja excluir este lugar?',
'trip.loadingPhotos': 'Carregando fotos dos lugares...',
// Day Plan Sidebar
'dayplan.emptyDay': 'Nenhum lugar planejado para este dia',
@@ -698,9 +794,14 @@ const br: Record<string, string | { name: string; category: string }[]> = {
// Places Sidebar
'places.addPlace': 'Adicionar lugar/atividade',
'places.importGpx': 'Importar GPX',
'places.importGpx': 'GPX',
'places.gpxImported': '{count} lugares importados do GPX',
'places.gpxError': 'Falha ao importar GPX',
'places.importGoogleList': 'Lista Google',
'places.googleListHint': 'Cole um link compartilhado de uma lista do Google Maps para importar todos os lugares.',
'places.googleListImported': '{count} lugares importados de "{list}"',
'places.googleListError': 'Falha ao importar lista do Google Maps',
'places.viewDetails': 'Ver detalhes',
'places.urlResolved': 'Lugar importado da URL',
'places.assignToDay': 'Adicionar a qual dia?',
'places.all': 'Todos',
@@ -757,6 +858,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'inspector.addRes': 'Reserva',
'inspector.editRes': 'Editar reserva',
'inspector.participants': 'Participantes',
'inspector.trackStats': 'Dados da trilha',
// Reservations
'reservations.title': 'Reservas',
@@ -836,9 +938,36 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'Vincular à atribuição do dia',
'reservations.pickAssignment': 'Selecione uma atribuição do seu plano...',
'reservations.noAssignment': 'Sem vínculo (avulsa)',
'reservations.price': 'Preço',
'reservations.budgetCategory': 'Categoria de orçamento',
'reservations.budgetCategoryPlaceholder': 'ex. Transporte, Acomodação',
'reservations.budgetCategoryAuto': 'Automático (pelo tipo de reserva)',
'reservations.budgetHint': 'Uma entrada de orçamento será criada automaticamente ao salvar.',
'reservations.departureDate': 'Partida',
'reservations.arrivalDate': 'Chegada',
'reservations.departureTime': 'Hora partida',
'reservations.arrivalTime': 'Hora chegada',
'reservations.pickupDate': 'Retirada',
'reservations.returnDate': 'Devolução',
'reservations.pickupTime': 'Hora retirada',
'reservations.returnTime': 'Hora devolução',
'reservations.endDate': 'Data final',
'reservations.meta.departureTimezone': 'TZ partida',
'reservations.meta.arrivalTimezone': 'TZ chegada',
'reservations.span.departure': 'Partida',
'reservations.span.arrival': 'Chegada',
'reservations.span.inTransit': 'Em trânsito',
'reservations.span.pickup': 'Retirada',
'reservations.span.return': 'Devolução',
'reservations.span.active': 'Ativo',
'reservations.span.start': 'Início',
'reservations.span.end': 'Fim',
'reservations.span.ongoing': 'Em andamento',
'reservations.validation.endBeforeStart': 'A data/hora final deve ser posterior à data/hora inicial',
// Budget
'budget.title': 'Orçamento',
'budget.exportCsv': 'Exportar CSV',
'budget.emptyTitle': 'Nenhum orçamento criado ainda',
'budget.emptyText': 'Crie categorias e lançamentos para planejar o orçamento da viagem',
'budget.emptyPlaceholder': 'Nome da categoria...',
@@ -853,6 +982,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'budget.table.perDay': 'Por dia',
'budget.table.perPersonDay': 'P. p. / dia',
'budget.table.note': 'Obs.',
'budget.table.date': 'Data',
'budget.newEntry': 'Novo lançamento',
'budget.defaultEntry': 'Novo lançamento',
'budget.defaultCategory': 'Nova categoria',
@@ -1247,6 +1377,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'collab.chat.today': 'Hoje',
'collab.chat.yesterday': 'Ontem',
'collab.chat.deletedMessage': 'apagou uma mensagem',
'collab.chat.reply': 'Responder',
'collab.chat.loadMore': 'Carregar mensagens antigas',
'collab.chat.justNow': 'agora mesmo',
'collab.chat.minutesAgo': 'há {n} min',
@@ -1315,12 +1446,19 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'memories.immichUrl': 'URL do servidor Immich',
'memories.immichApiKey': 'Chave da API',
'memories.testConnection': 'Testar conexão',
'memories.testFirst': 'Teste a conexão primeiro',
'memories.connected': 'Conectado',
'memories.disconnected': 'Não conectado',
'memories.connectionSuccess': 'Conectado ao Immich',
'memories.connectionError': 'Não foi possível conectar ao Immich',
'memories.saved': 'Configurações do Immich salvas',
'memories.addPhotos': 'Adicionar fotos',
'memories.linkAlbum': 'Vincular álbum',
'memories.selectAlbum': 'Selecionar álbum do Immich',
'memories.noAlbums': 'Nenhum álbum encontrado',
'memories.syncAlbum': 'Sincronizar álbum',
'memories.unlinkAlbum': 'Desvincular',
'memories.photos': 'fotos',
'memories.selectPhotos': 'Selecionar fotos do Immich',
'memories.selectHint': 'Toque nas fotos para selecioná-las.',
'memories.selected': 'selecionadas',
@@ -1336,6 +1474,218 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'memories.confirmShareTitle': 'Compartilhar com membros da viagem?',
'memories.confirmShareHint': '{count} fotos serão visíveis para todos os membros desta viagem. Você pode tornar fotos individuais privadas depois.',
'memories.confirmShareButton': 'Compartilhar fotos',
// Permissions
'admin.tabs.permissions': 'Permissões',
'admin.tabs.mcpTokens': 'Tokens MCP',
'admin.mcpTokens.title': 'Tokens MCP',
'admin.mcpTokens.subtitle': 'Gerenciar tokens de API de todos os usuários',
'admin.mcpTokens.owner': 'Proprietário',
'admin.mcpTokens.tokenName': 'Nome do Token',
'admin.mcpTokens.created': 'Criado',
'admin.mcpTokens.lastUsed': 'Último uso',
'admin.mcpTokens.never': 'Nunca',
'admin.mcpTokens.empty': 'Nenhum token MCP foi criado ainda',
'admin.mcpTokens.deleteTitle': 'Excluir Token',
'admin.mcpTokens.deleteMessage': 'Isso revogará o token imediatamente. O usuário perderá o acesso MCP por este token.',
'admin.mcpTokens.deleteSuccess': 'Token excluído',
'admin.mcpTokens.deleteError': 'Falha ao excluir token',
'admin.mcpTokens.loadError': 'Falha ao carregar tokens',
'perm.title': 'Configurações de Permissões',
'perm.subtitle': 'Controle quem pode realizar ações no aplicativo',
'perm.saved': 'Configurações de permissões salvas',
'perm.resetDefaults': 'Restaurar padrões',
'perm.customized': 'personalizado',
'perm.level.admin': 'Apenas administrador',
'perm.level.tripOwner': 'Dono da viagem',
'perm.level.tripMember': 'Membros da viagem',
'perm.level.everybody': 'Todos',
'perm.cat.trip': 'Gerenciamento de Viagens',
'perm.cat.members': 'Gerenciamento de Membros',
'perm.cat.files': 'Arquivos',
'perm.cat.content': 'Conteúdo e Cronograma',
'perm.cat.extras': 'Orçamento, Bagagem e Colaboração',
'perm.action.trip_create': 'Criar viagens',
'perm.action.trip_edit': 'Editar detalhes da viagem',
'perm.action.trip_delete': 'Excluir viagens',
'perm.action.trip_archive': 'Arquivar / desarquivar viagens',
'perm.action.trip_cover_upload': 'Enviar imagem de capa',
'perm.action.member_manage': 'Adicionar / remover membros',
'perm.action.file_upload': 'Enviar arquivos',
'perm.action.file_edit': 'Editar metadados do arquivo',
'perm.action.file_delete': 'Excluir arquivos',
'perm.action.place_edit': 'Adicionar / editar / excluir lugares',
'perm.action.day_edit': 'Editar dias, notas e atribuições',
'perm.action.reservation_edit': 'Gerenciar reservas',
'perm.action.budget_edit': 'Gerenciar orçamento',
'perm.action.packing_edit': 'Gerenciar listas de bagagem',
'perm.action.collab_edit': 'Colaboração (notas, enquetes, chat)',
'perm.action.share_manage': 'Gerenciar links de compartilhamento',
'perm.actionHint.trip_create': 'Quem pode criar novas viagens',
'perm.actionHint.trip_edit': 'Quem pode alterar nome, datas, descrição e moeda da viagem',
'perm.actionHint.trip_delete': 'Quem pode excluir permanentemente uma viagem',
'perm.actionHint.trip_archive': 'Quem pode arquivar ou desarquivar uma viagem',
'perm.actionHint.trip_cover_upload': 'Quem pode enviar ou alterar a imagem de capa',
'perm.actionHint.member_manage': 'Quem pode convidar ou remover membros da viagem',
'perm.actionHint.file_upload': 'Quem pode enviar arquivos para uma viagem',
'perm.actionHint.file_edit': 'Quem pode editar descrições e links dos arquivos',
'perm.actionHint.file_delete': 'Quem pode mover arquivos para a lixeira ou excluí-los permanentemente',
'perm.actionHint.place_edit': 'Quem pode adicionar, editar ou excluir lugares',
'perm.actionHint.day_edit': 'Quem pode editar dias, notas dos dias e atribuições de lugares',
'perm.actionHint.reservation_edit': 'Quem pode criar, editar ou excluir reservas',
'perm.actionHint.budget_edit': 'Quem pode criar, editar ou excluir itens do orçamento',
'perm.actionHint.packing_edit': 'Quem pode gerenciar itens de bagagem e malas',
'perm.actionHint.collab_edit': 'Quem pode criar notas, enquetes e enviar mensagens',
'perm.actionHint.share_manage': 'Quem pode criar ou excluir links de compartilhamento públicos',
// Undo
'undo.button': 'Desfazer',
'undo.tooltip': 'Desfazer: {action}',
'undo.assignPlace': 'Local atribuído ao dia',
'undo.removeAssignment': 'Local removido do dia',
'undo.reorder': 'Locais reordenados',
'undo.optimize': 'Rota otimizada',
'undo.deletePlace': 'Local excluído',
'undo.moveDay': 'Local movido para outro dia',
'undo.lock': 'Bloqueio do local alternado',
'undo.importGpx': 'Importação de GPX',
'undo.importGoogleList': 'Importação do Google Maps',
// Notifications
'notifications.title': 'Notificações',
'notifications.markAllRead': 'Marcar tudo como lido',
'notifications.deleteAll': 'Excluir tudo',
'notifications.showAll': 'Ver todas as notificações',
'notifications.empty': 'Sem notificações',
'notifications.emptyDescription': 'Você está em dia!',
'notifications.all': 'Todas',
'notifications.unreadOnly': 'Não lidas',
'notifications.markRead': 'Marcar como lido',
'notifications.markUnread': 'Marcar como não lido',
'notifications.delete': 'Excluir',
'notifications.system': 'Sistema',
'memories.error.loadAlbums': 'Falha ao carregar álbuns',
'memories.error.linkAlbum': 'Falha ao vincular álbum',
'memories.error.unlinkAlbum': 'Falha ao desvincular álbum',
'memories.error.syncAlbum': 'Falha ao sincronizar álbum',
'memories.error.loadPhotos': 'Falha ao carregar fotos',
'memories.error.addPhotos': 'Falha ao adicionar fotos',
'memories.error.removePhoto': 'Falha ao remover foto',
'memories.error.toggleSharing': 'Falha ao atualizar compartilhamento',
'undo.addPlace': 'Local adicionado',
'undo.done': 'Desfeito: {action}',
'notifications.test.title': 'Notificação de teste de {actor}',
'notifications.test.text': 'Esta é uma notificação de teste simples.',
'notifications.test.booleanTitle': '{actor} solicita sua aprovação',
'notifications.test.booleanText': 'Notificação de teste booleana.',
'notifications.test.accept': 'Aprovar',
'notifications.test.decline': 'Recusar',
'notifications.test.navigateTitle': 'Confira algo',
'notifications.test.navigateText': 'Notificação de teste de navegação.',
'notifications.test.goThere': 'Ir lá',
'notifications.test.adminTitle': 'Transmissão do admin',
'notifications.test.adminText': '{actor} enviou uma notificação de teste para todos os admins.',
'notifications.test.tripTitle': '{actor} postou na sua viagem',
'notifications.test.tripText': 'Notificação de teste para a viagem "{trip}".',
// Todo
'todo.subtab.packing': 'Lista de bagagem',
'todo.subtab.todo': 'A fazer',
'todo.completed': 'concluído(s)',
'todo.filter.all': 'Todos',
'todo.filter.open': 'Aberto',
'todo.filter.done': 'Concluído',
'todo.uncategorized': 'Sem categoria',
'todo.namePlaceholder': 'Nome da tarefa',
'todo.descriptionPlaceholder': 'Descrição (opcional)',
'todo.unassigned': 'Não atribuído',
'todo.noCategory': 'Sem categoria',
'todo.hasDescription': 'Com descrição',
'todo.addItem': 'Adicionar nova tarefa...',
'todo.newCategory': 'Nome da categoria',
'todo.addCategory': 'Adicionar categoria',
'todo.newItem': 'Nova tarefa',
'todo.empty': 'Nenhuma tarefa ainda. Adicione uma tarefa para começar!',
'todo.filter.my': 'Minhas tarefas',
'todo.filter.overdue': 'Atrasada',
'todo.sidebar.tasks': 'Tarefas',
'todo.sidebar.categories': 'Categorias',
'todo.detail.title': 'Tarefa',
'todo.detail.description': 'Descrição',
'todo.detail.category': 'Categoria',
'todo.detail.dueDate': 'Data de vencimento',
'todo.detail.assignedTo': 'Atribuído a',
'todo.detail.delete': 'Excluir',
'todo.detail.save': 'Salvar alterações',
'todo.detail.create': 'Criar tarefa',
'todo.detail.priority': 'Prioridade',
'todo.detail.noPriority': 'Nenhuma',
'todo.sortByPrio': 'Prioridade',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'Nova versão disponível',
'settings.notificationPreferences.noChannels': 'Nenhum canal de notificação configurado. Peça a um administrador para configurar notificações por e-mail ou webhook.',
'settings.webhookUrl.label': 'URL do webhook',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Insira a URL do seu webhook do Discord, Slack ou personalizado para receber notificações.',
'settings.webhookUrl.save': 'Salvar',
'settings.webhookUrl.saved': 'URL do webhook salva',
'settings.webhookUrl.test': 'Testar',
'settings.webhookUrl.testSuccess': 'Webhook de teste enviado com sucesso',
'settings.webhookUrl.testFailed': 'Falha no webhook de teste',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint': 'As notificações no aplicativo estão sempre ativas e não podem ser desativadas globalmente.',
'admin.notifications.adminWebhookPanel.title': 'Webhook de admin',
'admin.notifications.adminWebhookPanel.hint': 'Este webhook é usado exclusivamente para notificações de admin (ex. alertas de versão). É independente dos webhooks de usuários e dispara automaticamente quando uma URL está configurada.',
'admin.notifications.adminWebhookPanel.saved': 'URL do webhook de admin salva',
'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de teste enviado com sucesso',
'admin.notifications.adminWebhookPanel.testFailed': 'Falha no webhook de teste',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'O webhook de admin dispara automaticamente quando uma URL está configurada',
'admin.notifications.adminNotificationsHint': 'Configure quais canais entregam notificações de admin (ex. alertas de versão). O webhook dispara automaticamente se uma URL de webhook de admin estiver definida.',
'admin.tabs.notifications': 'Notificações',
'notifications.versionAvailable.title': 'Atualização disponível',
'notifications.versionAvailable.text': 'TREK {version} já está disponível.',
'notifications.versionAvailable.button': 'Ver detalhes',
'notif.test.title': '[Teste] Notificação',
'notif.test.simple.text': 'Esta é uma notificação de teste simples.',
'notif.test.boolean.text': 'Você aceita esta notificação de teste?',
'notif.test.navigate.text': 'Clique abaixo para ir ao painel.',
// Notifications
'notif.trip_invite.title': 'Convite para viagem',
'notif.trip_invite.text': '{actor} convidou você para {trip}',
'notif.booking_change.title': 'Reserva atualizada',
'notif.booking_change.text': '{actor} atualizou uma reserva em {trip}',
'notif.trip_reminder.title': 'Lembrete de viagem',
'notif.trip_reminder.text': 'Sua viagem {trip} está chegando!',
'notif.vacay_invite.title': 'Convite Vacay Fusion',
'notif.vacay_invite.text': '{actor} convidou você para fundir planos de férias',
'notif.photos_shared.title': 'Fotos compartilhadas',
'notif.photos_shared.text': '{actor} compartilhou {count} foto(s) em {trip}',
'notif.collab_message.title': 'Nova mensagem',
'notif.collab_message.text': '{actor} enviou uma mensagem em {trip}',
'notif.packing_tagged.title': 'Atribuição de bagagem',
'notif.packing_tagged.text': '{actor} atribuiu você a {category} em {trip}',
'notif.version_available.title': 'Nova versão disponível',
'notif.version_available.text': 'TREK {version} está disponível',
'notif.action.view_trip': 'Ver viagem',
'notif.action.view_collab': 'Ver mensagens',
'notif.action.view_packing': 'Ver bagagem',
'notif.action.view_photos': 'Ver fotos',
'notif.action.view_vacay': 'Ver Vacay',
'notif.action.view_admin': 'Ir para admin',
'notif.action.view': 'Ver',
'notif.action.accept': 'Aceitar',
'notif.action.decline': 'Recusar',
'notif.generic.title': 'Notificação',
'notif.generic.text': 'Você tem uma nova notificação',
'notif.dev.unknown_event.title': '[DEV] Evento desconhecido',
'notif.dev.unknown_event.text': 'O tipo de evento "{event}" não está registrado em EVENT_NOTIFICATION_CONFIG',
}
export default br
File diff suppressed because it is too large Load Diff
+368 -11
View File
@@ -6,6 +6,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'common.edit': 'Bearbeiten',
'common.add': 'Hinzufügen',
'common.loading': 'Laden...',
'common.import': 'Importieren',
'common.error': 'Fehler',
'common.back': 'Zurück',
'common.all': 'Alle',
@@ -25,6 +26,14 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'E-Mail',
'common.password': 'Passwort',
'common.saving': 'Speichern...',
'common.saved': 'Gespeichert',
'trips.reminder': 'Erinnerung',
'trips.reminderNone': 'Keine',
'trips.reminderDay': 'Tag',
'trips.reminderDays': 'Tage',
'trips.reminderCustom': 'Benutzerdefiniert',
'trips.reminderDaysBefore': 'Tage vor Abreise',
'trips.reminderDisabledHint': 'Reiseerinnerungen sind deaktiviert. Aktivieren Sie sie unter Admin > Einstellungen > Benachrichtigungen.',
'common.update': 'Aktualisieren',
'common.change': 'Ändern',
'common.uploading': 'Hochladen…',
@@ -71,7 +80,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'dashboard.sharedBy': 'Geteilt von {name}',
'dashboard.days': 'Tage',
'dashboard.places': 'Orte',
'dashboard.members': 'Reise-Buddies',
'dashboard.archive': 'Archivieren',
'dashboard.copyTrip': 'Kopieren',
'dashboard.copySuffix': 'Kopie',
'dashboard.restore': 'Wiederherstellen',
'dashboard.archived': 'Archiviert',
'dashboard.status.ongoing': 'Laufend',
@@ -90,6 +102,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'dashboard.toast.archiveError': 'Fehler beim Archivieren',
'dashboard.toast.restored': 'Reise wiederhergestellt',
'dashboard.toast.restoreError': 'Fehler beim Wiederherstellen',
'dashboard.toast.copied': 'Reise kopiert!',
'dashboard.toast.copyError': 'Fehler beim Kopieren der Reise',
'dashboard.confirm.delete': 'Reise "{title}" löschen? Alle Orte und Pläne werden unwiderruflich gelöscht.',
'dashboard.editTrip': 'Reise bearbeiten',
'dashboard.createTrip': 'Neue Reise erstellen',
@@ -99,6 +113,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'dashboard.tripDescriptionPlaceholder': 'Worum geht es bei dieser Reise?',
'dashboard.startDate': 'Startdatum',
'dashboard.endDate': 'Enddatum',
'dashboard.dayCount': 'Anzahl Tage',
'dashboard.dayCountHint': 'Wie viele Tage geplant werden sollen, wenn kein Reisezeitraum gesetzt ist.',
'dashboard.noDateHint': 'Kein Datum gesetzt — es werden 7 Standardtage erstellt. Du kannst das jederzeit ändern.',
'dashboard.coverImage': 'Titelbild',
'dashboard.addCoverImage': 'Titelbild hinzufügen (oder per Drag & Drop)',
@@ -113,6 +129,12 @@ const de: Record<string, string | { name: string; category: string }[]> = {
// Settings
'settings.title': 'Einstellungen',
'settings.subtitle': 'Konfigurieren Sie Ihre persönlichen Einstellungen',
'settings.tabs.display': 'Anzeige',
'settings.tabs.map': 'Karte',
'settings.tabs.notifications': 'Benachrichtigungen',
'settings.tabs.integrations': 'Integrationen',
'settings.tabs.account': 'Konto',
'settings.tabs.about': 'Über',
'settings.map': 'Karte',
'settings.mapTemplate': 'Karten-Vorlage',
'settings.mapTemplatePlaceholder.select': 'Vorlage auswählen...',
@@ -149,9 +171,26 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)',
'settings.notifyPackingTagged': 'Packliste: Zuweisungen',
'settings.notifyWebhook': 'Webhook-Benachrichtigungen',
'settings.notificationsDisabled': 'Benachrichtigungen sind nicht konfiguriert. Bitten Sie einen Administrator, E-Mail- oder Webhook-Benachrichtungen zu aktivieren.',
'settings.notificationsActive': 'Aktiver Kanal',
'settings.notificationsManagedByAdmin': 'Benachrichtigungsereignisse werden vom Administrator konfiguriert.',
'admin.notifications.title': 'Benachrichtigungen',
'admin.notifications.hint': 'Wählen Sie einen Benachrichtigungskanal. Es kann nur einer gleichzeitig aktiv sein.',
'admin.notifications.none': 'Deaktiviert',
'admin.notifications.email': 'E-Mail (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Benachrichtigungsereignisse',
'admin.notifications.eventsHint': 'Wähle, welche Ereignisse Benachrichtigungen für alle Benutzer auslösen.',
'admin.notifications.configureFirst': 'Konfiguriere zuerst die SMTP- oder Webhook-Einstellungen unten, dann aktiviere die Events.',
'admin.notifications.save': 'Benachrichtigungseinstellungen speichern',
'admin.notifications.saved': 'Benachrichtigungseinstellungen gespeichert',
'admin.notifications.testWebhook': 'Test-Webhook senden',
'admin.notifications.testWebhookSuccess': 'Test-Webhook erfolgreich gesendet',
'admin.notifications.testWebhookFailed': 'Test-Webhook fehlgeschlagen',
'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.hint': 'SMTP-Konfiguration zum Versenden von E-Mail-Benachrichtigungen.',
'admin.smtp.testButton': 'Test-E-Mail senden',
'admin.webhook.hint': 'Benachrichtigungen an einen externen Webhook senden (Discord, Slack usw.).',
'admin.smtp.testSuccess': 'Test-E-Mail erfolgreich gesendet',
'admin.smtp.testFailed': 'Test-E-Mail fehlgeschlagen',
'dayplan.icsTooltip': 'Kalender exportieren (ICS)',
@@ -185,13 +224,48 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'share.permCollab': 'Chat',
'settings.on': 'An',
'settings.off': 'Aus',
'settings.mcp.title': 'MCP-Konfiguration',
'settings.mcp.endpoint': 'MCP-Endpunkt',
'settings.mcp.clientConfig': 'Client-Konfiguration',
'settings.mcp.clientConfigHint': 'Ersetze <your_token> durch ein API-Token aus der Liste unten. Der Pfad zu npx muss ggf. für dein System angepasst werden (z. B. C:\\PROGRA~1\\nodejs\\npx.cmd unter Windows).',
'settings.mcp.copy': 'Kopieren',
'settings.mcp.copied': 'Kopiert!',
'settings.mcp.apiTokens': 'API-Tokens',
'settings.mcp.createToken': 'Neuen Token erstellen',
'settings.mcp.noTokens': 'Noch keine Tokens. Erstelle einen, um MCP-Clients zu verbinden.',
'settings.mcp.tokenCreatedAt': 'Erstellt',
'settings.mcp.tokenUsedAt': 'Verwendet',
'settings.mcp.deleteTokenTitle': 'Token löschen',
'settings.mcp.deleteTokenMessage': 'Dieser Token wird sofort ungültig. Jeder MCP-Client, der ihn verwendet, verliert den Zugang.',
'settings.mcp.modal.createTitle': 'API-Token erstellen',
'settings.mcp.modal.tokenName': 'Token-Name',
'settings.mcp.modal.tokenNamePlaceholder': 'z. B. Claude Desktop, Arbeits-Laptop',
'settings.mcp.modal.creating': 'Wird erstellt…',
'settings.mcp.modal.create': 'Token erstellen',
'settings.mcp.modal.createdTitle': 'Token erstellt',
'settings.mcp.modal.createdWarning': 'Dieser Token wird nur einmal angezeigt. Kopiere und speichere ihn jetzt — er kann nicht wiederhergestellt werden.',
'settings.mcp.modal.done': 'Fertig',
'settings.mcp.toast.created': 'Token erstellt',
'settings.mcp.toast.createError': 'Token konnte nicht erstellt werden',
'settings.mcp.toast.deleted': 'Token gelöscht',
'settings.mcp.toast.deleteError': 'Token konnte nicht gelöscht werden',
'settings.account': 'Konto',
'settings.about': 'Über',
'settings.about.reportBug': 'Bug melden',
'settings.about.reportBugHint': 'Problem gefunden? Melde es uns',
'settings.about.featureRequest': 'Feature vorschlagen',
'settings.about.featureRequestHint': 'Schlage ein neues Feature vor',
'settings.about.wikiHint': 'Dokumentation & Anleitungen',
'settings.about.description': 'TREK ist ein selbst gehosteter Reiseplaner, der dir hilft, deine Trips von der ersten Idee bis zur letzten Erinnerung zu organisieren. Tagesplanung, Budget, Packlisten, Fotos und vieles mehr — alles an einem Ort, auf deinem eigenen Server.',
'settings.about.madeWith': 'Entwickelt mit',
'settings.about.madeBy': 'von Maurice und einer wachsenden Open-Source-Community.',
'settings.username': 'Benutzername',
'settings.email': 'E-Mail',
'settings.role': 'Rolle',
'settings.roleAdmin': 'Administrator',
'settings.oidcLinked': 'Verknüpft mit',
'settings.changePassword': 'Passwort ändern',
'settings.mustChangePassword': 'Sie müssen Ihr Passwort ändern, bevor Sie fortfahren können. Bitte legen Sie unten ein neues Passwort fest.',
'settings.currentPassword': 'Aktuelles Passwort',
'settings.currentPasswordRequired': 'Aktuelles Passwort wird benötigt',
'settings.newPassword': 'Neues Passwort',
@@ -200,7 +274,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.passwordRequired': 'Bitte aktuelles und neues Passwort eingeben',
'settings.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein',
'settings.passwordMismatch': 'Passwörter stimmen nicht überein',
'settings.passwordWeak': 'Passwort muss Groß-, Kleinbuchstaben und eine Zahl enthalten',
'settings.passwordWeak': 'Passwort muss Groß-, Kleinbuchstaben, eine Zahl und ein Sonderzeichen enthalten',
'settings.passwordChanged': 'Passwort erfolgreich geändert',
'settings.deleteAccount': 'Löschen',
'settings.deleteAccountTitle': 'Account wirklich löschen?',
@@ -221,6 +295,14 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.avatarError': 'Fehler beim Hochladen',
'settings.mfa.title': 'Zwei-Faktor-Authentifizierung (2FA)',
'settings.mfa.description': 'Zusätzlicher Schritt bei der Anmeldung mit E-Mail und Passwort. Nutze eine Authenticator-App (Google Authenticator, Authy, …).',
'settings.mfa.requiredByPolicy': 'Dein Administrator verlangt Zwei-Faktor-Authentifizierung. Richte unten eine Authenticator-App ein, bevor du fortfährst.',
'settings.mfa.backupTitle': 'Backup-Codes',
'settings.mfa.backupDescription': 'Verwende diese Einmal-Codes, wenn du keinen Zugriff mehr auf deine Authenticator-App hast.',
'settings.mfa.backupWarning': 'Jetzt speichern. Jeder Code kann nur einmal verwendet werden.',
'settings.mfa.backupCopy': 'Codes kopieren',
'settings.mfa.backupDownload': 'TXT herunterladen',
'settings.mfa.backupPrint': 'Drucken / PDF',
'settings.mfa.backupCopied': 'Backup-Codes kopiert',
'settings.mfa.enabled': '2FA ist für dein Konto aktiv.',
'settings.mfa.disabled': '2FA ist nicht aktiviert.',
'settings.mfa.setup': 'Authenticator einrichten',
@@ -263,6 +345,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'login.signIn': 'Anmelden',
'login.createAdmin': 'Admin-Konto erstellen',
'login.createAdminHint': 'Erstelle das erste Admin-Konto für TREK.',
'login.setNewPassword': 'Neues Passwort festlegen',
'login.setNewPasswordHint': 'Sie müssen Ihr Passwort ändern, bevor Sie fortfahren können.',
'login.createAccount': 'Konto erstellen',
'login.createAccountHint': 'Neues Konto registrieren.',
'login.creating': 'Erstelle…',
@@ -278,6 +362,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'login.demoFailed': 'Demo-Login fehlgeschlagen',
'login.oidcSignIn': 'Anmelden mit {name}',
'login.oidcOnly': 'Passwort-Authentifizierung ist deaktiviert. Bitte melde dich über deinen SSO-Anbieter an.',
'login.oidcLoggedOut': 'Du wurdest abgemeldet. Melde dich erneut über deinen SSO-Anbieter an.',
'login.demoHint': 'Demo ausprobieren — ohne Registrierung',
'login.mfaTitle': 'Zwei-Faktor-Authentifizierung',
'login.mfaSubtitle': 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.',
@@ -289,7 +374,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
// Register
'register.passwordMismatch': 'Passwörter stimmen nicht überein',
'register.passwordTooShort': 'Passwort muss mindestens 6 Zeichen lang sein',
'register.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein',
'register.failed': 'Registrierung fehlgeschlagen',
'register.getStarted': 'Jetzt starten',
'register.subtitle': 'Erstellen Sie ein Konto und beginnen Sie, Ihre Traumreisen zu planen.',
@@ -315,7 +400,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.tabs.audit': 'Audit',
'admin.stats.users': 'Benutzer',
'admin.stats.trips': 'Reisen',
'admin.stats.places': 'Orte',
@@ -365,6 +450,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.settings': 'Einstellungen',
'admin.allowRegistration': 'Registrierung erlauben',
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
'admin.requireMfa': 'Zwei-Faktor-Authentifizierung (2FA) für alle verlangen',
'admin.requireMfaHint': 'Benutzer ohne 2FA müssen die Einrichtung unter Einstellungen abschließen, bevor sie die App nutzen können.',
'admin.apiKeys': 'API-Schlüssel',
'admin.apiKeysHint': 'Optional. Aktiviert erweiterte Ortsdaten wie Fotos und Wetter.',
'admin.mapsKey': 'Google Maps API Key',
@@ -395,7 +482,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
// Packing Templates & Bag Tracking
'admin.bagTracking.title': 'Gepäck-Tracking',
'admin.bagTracking.subtitle': 'Gewicht und Gepäckstück-Zuordnung für Packlisteneinträge aktivieren',
'admin.tabs.config': 'Konfiguration',
'admin.tabs.config': 'Personalisierung',
'admin.tabs.templates': 'Packvorlagen',
'admin.packingTemplates.title': 'Packvorlagen',
'admin.packingTemplates.subtitle': 'Wiederverwendbare Packlisten für deine Reisen erstellen',
@@ -419,8 +506,8 @@ 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.packing.name': 'Packliste',
'admin.addons.catalog.packing.description': 'Checklisten zum Kofferpacken für jede Reise',
'admin.addons.catalog.packing.name': 'Listen',
'admin.addons.catalog.packing.description': 'Packlisten und To-Do-Aufgaben für deine Reisen',
'admin.addons.catalog.budget.name': 'Budget',
'admin.addons.catalog.budget.description': 'Ausgaben verfolgen und Reisebudget planen',
'admin.addons.catalog.documents.name': 'Dokumente',
@@ -433,14 +520,18 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'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.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'Model Context Protocol für die KI-Assistenten-Integration',
'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ',
'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.',
'admin.addons.enabled': 'Aktiviert',
'admin.addons.disabled': 'Deaktiviert',
'admin.addons.type.trip': 'Trip',
'admin.addons.type.global': 'Global',
'admin.addons.type.integration': 'Integration',
'admin.addons.tripHint': 'Verfügbar als Tab innerhalb jedes Trips',
'admin.addons.globalHint': 'Verfügbar als eigenständiger Bereich in der Navigation',
'admin.addons.integrationHint': 'Backend-Dienste und API-Integrationen ohne eigene Seite',
'admin.addons.toast.updated': 'Addon aktualisiert',
'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden',
'admin.addons.noAddons': 'Keine Addons verfügbar',
@@ -456,6 +547,22 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.weather.requestsDesc': 'Kostenlos, kein API-Schlüssel erforderlich',
'admin.weather.locationHint': 'Das Wetter wird anhand des ersten Ortes mit Koordinaten im jeweiligen Tag berechnet. Ist kein Ort am Tag eingeplant, wird ein beliebiger Ort aus der Ortsliste als Referenz verwendet.',
// MCP Tokens
'admin.tabs.mcpTokens': 'MCP-Tokens',
'admin.mcpTokens.title': 'MCP-Tokens',
'admin.mcpTokens.subtitle': 'API-Tokens aller Benutzer verwalten',
'admin.mcpTokens.owner': 'Besitzer',
'admin.mcpTokens.tokenName': 'Token-Name',
'admin.mcpTokens.created': 'Erstellt',
'admin.mcpTokens.lastUsed': 'Zuletzt verwendet',
'admin.mcpTokens.never': 'Nie',
'admin.mcpTokens.empty': 'Es wurden noch keine MCP-Tokens erstellt',
'admin.mcpTokens.deleteTitle': 'Token löschen',
'admin.mcpTokens.deleteMessage': 'Dieser Token wird sofort widerrufen. Der Benutzer verliert den MCP-Zugang über diesen Token.',
'admin.mcpTokens.deleteSuccess': 'Token gelöscht',
'admin.mcpTokens.deleteError': 'Token konnte nicht gelöscht werden',
'admin.mcpTokens.loadError': 'Tokens konnten nicht geladen werden',
// GitHub
'admin.tabs.github': 'GitHub',
@@ -505,7 +612,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'vacay.subtitle': 'Urlaubstage planen und verwalten',
'vacay.settings': 'Einstellungen',
'vacay.year': 'Jahr',
'vacay.addYear': 'Jahr hinzufügen',
'vacay.addYear': 'Nächstes Jahr hinzufügen',
'vacay.addPrevYear': 'Vorheriges Jahr hinzufügen',
'vacay.removeYear': 'Jahr entfernen',
'vacay.removeYearConfirm': '{year} entfernen?',
'vacay.removeYearHint': 'Alle Urlaubseinträge und Betriebsferien für dieses Jahr werden unwiderruflich gelöscht.',
@@ -597,10 +705,13 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'atlas.unmark': 'Entfernen',
'atlas.confirmMark': 'Dieses Land als besucht markieren?',
'atlas.confirmUnmark': 'Dieses Land von der Liste entfernen?',
'atlas.confirmUnmarkRegion': 'Diese Region von der Liste entfernen?',
'atlas.markVisited': 'Als besucht markieren',
'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen',
'atlas.markRegionVisitedHint': 'Diese Region zur besuchten Liste hinzufügen',
'atlas.addToBucket': 'Zur Bucket List',
'atlas.addPoi': 'Ort hinzufügen',
'atlas.searchCountry': 'Land suchen...',
'atlas.bucketNamePlaceholder': 'Name (Land, Stadt, Ort...)',
'atlas.month': 'Monat',
'atlas.year': 'Jahr',
@@ -609,7 +720,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'atlas.statsTab': 'Statistik',
'atlas.bucketTab': 'Bucket List',
'atlas.addBucket': 'Zur Bucket List hinzufügen',
'atlas.bucketNamePlaceholder': 'Ort oder Reiseziel...',
'atlas.bucketNotesPlaceholder': 'Notizen (optional)',
'atlas.bucketEmpty': 'Deine Bucket List ist leer',
'atlas.bucketEmptyHint': 'Füge Orte hinzu, die du besuchen möchtest',
@@ -622,7 +732,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'atlas.nextTrip': 'Nächster Trip',
'atlas.daysLeft': 'Tage',
'atlas.streak': 'Streak',
'atlas.year': 'Jahr',
'atlas.years': 'Jahre',
'atlas.yearInRow': 'Jahr in Folge',
'atlas.yearsInRow': 'Jahre in Folge',
@@ -649,9 +758,12 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'Buchung',
'trip.tabs.packing': 'Liste',
'trip.tabs.packingShort': 'Liste',
'trip.tabs.lists': 'Listen',
'trip.tabs.listsShort': 'Listen',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Dateien',
'trip.loading': 'Reise wird geladen...',
'trip.loadingPhotos': 'Fotos der Orte werden geladen...',
'trip.mobilePlan': 'Planung',
'trip.mobilePlaces': 'Orte',
'trip.toast.placeUpdated': 'Ort aktualisiert',
@@ -698,10 +810,15 @@ const de: Record<string, string | { name: string; category: string }[]> = {
// Places Sidebar
'places.addPlace': 'Ort/Aktivität hinzufügen',
'places.importGpx': 'GPX importieren',
'places.importGpx': 'GPX',
'places.gpxImported': '{count} Orte aus GPX importiert',
'places.urlResolved': 'Ort aus URL importiert',
'places.gpxError': 'GPX-Import fehlgeschlagen',
'places.importGoogleList': 'Google Liste',
'places.googleListHint': 'Geteilten Google Maps Listen-Link einfügen, um alle Orte zu importieren.',
'places.googleListImported': '{count} Orte aus "{list}" importiert',
'places.googleListError': 'Google Maps Liste konnte nicht importiert werden',
'places.viewDetails': 'Details anzeigen',
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
'places.all': 'Alle',
'places.unplanned': 'Ungeplant',
@@ -757,6 +874,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'inspector.addRes': 'Reservierung',
'inspector.editRes': 'Reservierung bearbeiten',
'inspector.participants': 'Teilnehmer',
'inspector.trackStats': 'Streckendaten',
// Reservations
'reservations.title': 'Buchungen',
@@ -836,9 +954,36 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'Mit Tagesplanung verknüpfen',
'reservations.pickAssignment': 'Zuordnung aus dem Plan wählen...',
'reservations.noAssignment': 'Keine Verknüpfung',
'reservations.price': 'Preis',
'reservations.budgetCategory': 'Budgetkategorie',
'reservations.budgetCategoryPlaceholder': 'z.B. Transport, Unterkunft',
'reservations.budgetCategoryAuto': 'Auto (aus Buchungstyp)',
'reservations.budgetHint': 'Beim Speichern wird automatisch ein Budgeteintrag erstellt.',
'reservations.departureDate': 'Abflug',
'reservations.arrivalDate': 'Ankunft',
'reservations.departureTime': 'Abflugzeit',
'reservations.arrivalTime': 'Ankunftszeit',
'reservations.pickupDate': 'Abholung',
'reservations.returnDate': 'Rückgabe',
'reservations.pickupTime': 'Abholzeit',
'reservations.returnTime': 'Rückgabezeit',
'reservations.endDate': 'Enddatum',
'reservations.meta.departureTimezone': 'Abfl. TZ',
'reservations.meta.arrivalTimezone': 'Ank. TZ',
'reservations.span.departure': 'Abflug',
'reservations.span.arrival': 'Ankunft',
'reservations.span.inTransit': 'Unterwegs',
'reservations.span.pickup': 'Abholung',
'reservations.span.return': 'Rückgabe',
'reservations.span.active': 'Aktiv',
'reservations.span.start': 'Start',
'reservations.span.end': 'Ende',
'reservations.span.ongoing': 'Laufend',
'reservations.validation.endBeforeStart': 'Enddatum/-zeit muss nach dem Startdatum/-zeit liegen',
// Budget
'budget.title': 'Budget',
'budget.exportCsv': 'CSV exportieren',
'budget.emptyTitle': 'Noch kein Budget erstellt',
'budget.emptyText': 'Erstelle Kategorien und Einträge, um dein Reisebudget zu planen',
'budget.emptyPlaceholder': 'Kategoriename eingeben...',
@@ -853,6 +998,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'budget.table.perDay': 'Pro Tag',
'budget.table.perPersonDay': 'P. p / Tag',
'budget.table.note': 'Notiz',
'budget.table.date': 'Datum',
'budget.newEntry': 'Neuer Eintrag',
'budget.defaultEntry': 'Neuer Eintrag',
'budget.defaultCategory': 'Neue Kategorie',
@@ -860,6 +1006,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'budget.totalBudget': 'Gesamtbudget',
'budget.byCategory': 'Nach Kategorie',
'budget.editTooltip': 'Klicken zum Bearbeiten',
'budget.linkedToReservation': 'Verknüpft mit einer Buchung — Name dort bearbeiten',
'budget.confirm.deleteCategory': 'Möchtest du die Kategorie "{name}" mit {count} Einträgen wirklich löschen?',
'budget.deleteCategory': 'Kategorie löschen',
'budget.perPerson': 'Pro Person',
@@ -960,6 +1107,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'packing.template': 'Vorlage',
'packing.templateApplied': '{count} Einträge aus Vorlage hinzugefügt',
'packing.templateError': 'Vorlage konnte nicht angewendet werden',
'packing.saveAsTemplate': 'Als Vorlage speichern',
'packing.templateName': 'Vorlagenname',
'packing.templateSaved': 'Packliste als Vorlage gespeichert',
'packing.assignUser': 'Person zuweisen',
'packing.bags': 'Gepäck',
'packing.noBag': 'Nicht zugeordnet',
'packing.totalWeight': 'Gesamtgewicht',
@@ -1246,12 +1397,19 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'memories.immichUrl': 'Immich Server URL',
'memories.immichApiKey': 'API-Schlüssel',
'memories.testConnection': 'Verbindung testen',
'memories.testFirst': 'Verbindung zuerst 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.linkAlbum': 'Album verknüpfen',
'memories.selectAlbum': 'Immich-Album auswählen',
'memories.noAlbums': 'Keine Alben gefunden',
'memories.syncAlbum': 'Album synchronisieren',
'memories.unlinkAlbum': 'Album trennen',
'memories.photos': 'Fotos',
'memories.selectPhotos': 'Fotos aus Immich auswählen',
'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.',
'memories.selected': 'ausgewählt',
@@ -1286,6 +1444,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'collab.chat.today': 'Heute',
'collab.chat.yesterday': 'Gestern',
'collab.chat.deletedMessage': 'hat eine Nachricht gelöscht',
'collab.chat.reply': 'Antworten',
'collab.chat.loadMore': 'Ältere Nachrichten laden',
'collab.chat.justNow': 'gerade eben',
'collab.chat.minutesAgo': 'vor {n} Min.',
@@ -1336,6 +1495,204 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'collab.polls.options': 'Optionen',
'collab.polls.delete': 'Löschen',
'collab.polls.closedSection': 'Geschlossen',
// Permissions
'admin.tabs.permissions': 'Berechtigungen',
'perm.title': 'Berechtigungseinstellungen',
'perm.subtitle': 'Steuern Sie, wer Aktionen in der Anwendung ausführen kann',
'perm.saved': 'Berechtigungseinstellungen gespeichert',
'perm.resetDefaults': 'Auf Standard zurücksetzen',
'perm.customized': 'angepasst',
'perm.level.admin': 'Nur Administrator',
'perm.level.tripOwner': 'Reise-Eigentümer',
'perm.level.tripMember': 'Reise-Mitglieder',
'perm.level.everybody': 'Alle',
'perm.cat.trip': 'Reiseverwaltung',
'perm.cat.members': 'Mitgliederverwaltung',
'perm.cat.files': 'Dateien',
'perm.cat.content': 'Inhalte & Zeitplan',
'perm.cat.extras': 'Budget, Packlisten & Zusammenarbeit',
'perm.action.trip_create': 'Reisen erstellen',
'perm.action.trip_edit': 'Reisedetails bearbeiten',
'perm.action.trip_delete': 'Reisen löschen',
'perm.action.trip_archive': 'Reisen archivieren / dearchivieren',
'perm.action.trip_cover_upload': 'Titelbild hochladen',
'perm.action.member_manage': 'Mitglieder hinzufügen / entfernen',
'perm.action.file_upload': 'Dateien hochladen',
'perm.action.file_edit': 'Datei-Metadaten bearbeiten',
'perm.action.file_delete': 'Dateien löschen',
'perm.action.place_edit': 'Orte hinzufügen / bearbeiten / löschen',
'perm.action.day_edit': 'Tage, Notizen & Zuweisungen bearbeiten',
'perm.action.reservation_edit': 'Reservierungen verwalten',
'perm.action.budget_edit': 'Budget verwalten',
'perm.action.packing_edit': 'Packlisten verwalten',
'perm.action.collab_edit': 'Zusammenarbeit (Notizen, Umfragen, Chat)',
'perm.action.share_manage': 'Freigabelinks verwalten',
'perm.actionHint.trip_create': 'Wer kann neue Reisen erstellen',
'perm.actionHint.trip_edit': 'Wer kann Reisename, Daten, Beschreibung und Währung ändern',
'perm.actionHint.trip_delete': 'Wer kann eine Reise dauerhaft löschen',
'perm.actionHint.trip_archive': 'Wer kann eine Reise archivieren oder dearchivieren',
'perm.actionHint.trip_cover_upload': 'Wer kann das Titelbild hochladen oder ändern',
'perm.actionHint.member_manage': 'Wer kann Reise-Mitglieder einladen oder entfernen',
'perm.actionHint.file_upload': 'Wer kann Dateien zu einer Reise hochladen',
'perm.actionHint.file_edit': 'Wer kann Dateibeschreibungen und Links bearbeiten',
'perm.actionHint.file_delete': 'Wer kann Dateien in den Papierkorb verschieben oder dauerhaft löschen',
'perm.actionHint.place_edit': 'Wer kann Orte hinzufügen, bearbeiten oder löschen',
'perm.actionHint.day_edit': 'Wer kann Tage, Tagesnotizen und Ort-Zuweisungen bearbeiten',
'perm.actionHint.reservation_edit': 'Wer kann Reservierungen erstellen, bearbeiten oder löschen',
'perm.actionHint.budget_edit': 'Wer kann Budgetposten erstellen, bearbeiten oder löschen',
'perm.actionHint.packing_edit': 'Wer kann Packstücke und Taschen verwalten',
'perm.actionHint.collab_edit': 'Wer kann Notizen, Umfragen erstellen und Nachrichten senden',
'perm.actionHint.share_manage': 'Wer kann öffentliche Freigabelinks erstellen oder löschen',
// Undo
'undo.button': 'Rückgängig',
'undo.tooltip': 'Rückgängig: {action}',
'undo.assignPlace': 'Ort einem Tag zugewiesen',
'undo.removeAssignment': 'Ort von Tag entfernt',
'undo.reorder': 'Orte neu sortiert',
'undo.optimize': 'Route optimiert',
'undo.deletePlace': 'Ort gelöscht',
'undo.moveDay': 'Ort zu anderem Tag verschoben',
'undo.lock': 'Ortssperre umgeschaltet',
'undo.importGpx': 'GPX-Import',
'undo.importGoogleList': 'Google Maps-Import',
// Notifications
'notifications.title': 'Benachrichtigungen',
'notifications.markAllRead': 'Alle als gelesen markieren',
'notifications.deleteAll': 'Alle löschen',
'notifications.showAll': 'Alle Benachrichtigungen anzeigen',
'notifications.empty': 'Keine Benachrichtigungen',
'notifications.emptyDescription': 'Sie sind auf dem neuesten Stand!',
'notifications.all': 'Alle',
'notifications.unreadOnly': 'Ungelesen',
'notifications.markRead': 'Als gelesen markieren',
'notifications.markUnread': 'Als ungelesen markieren',
'notifications.delete': 'Löschen',
'notifications.system': 'System',
'memories.error.loadAlbums': 'Alben konnten nicht geladen werden',
'memories.error.linkAlbum': 'Album konnte nicht verknüpft werden',
'memories.error.unlinkAlbum': 'Album konnte nicht getrennt werden',
'memories.error.syncAlbum': 'Album konnte nicht synchronisiert werden',
'memories.error.loadPhotos': 'Fotos konnten nicht geladen werden',
'memories.error.addPhotos': 'Fotos konnten nicht hinzugefügt werden',
'memories.error.removePhoto': 'Foto konnte nicht entfernt werden',
'memories.error.toggleSharing': 'Freigabe konnte nicht aktualisiert werden',
'undo.addPlace': 'Ort hinzugefügt',
'undo.done': 'Rückgängig gemacht: {action}',
'notifications.test.title': 'Testbenachrichtigung von {actor}',
'notifications.test.text': 'Dies ist eine einfache Testbenachrichtigung.',
'notifications.test.booleanTitle': '{actor} bittet um Ihre Zustimmung',
'notifications.test.booleanText': 'Dies ist eine boolesche Testbenachrichtigung.',
'notifications.test.accept': 'Genehmigen',
'notifications.test.decline': 'Ablehnen',
'notifications.test.navigateTitle': 'Etwas ansehen',
'notifications.test.navigateText': 'Dies ist eine Navigations-Testbenachrichtigung.',
'notifications.test.goThere': 'Dorthin',
'notifications.test.adminTitle': 'Admin-Broadcast',
'notifications.test.adminText': '{actor} hat eine Testbenachrichtigung an alle Admins gesendet.',
'notifications.test.tripTitle': '{actor} hat in Ihrer Reise gepostet',
'notifications.test.tripText': 'Testbenachrichtigung für Reise "{trip}".',
// Todo
'todo.subtab.packing': 'Packliste',
'todo.subtab.todo': 'To-Do',
'todo.completed': 'erledigt',
'todo.filter.all': 'Alle',
'todo.filter.open': 'Offen',
'todo.filter.done': 'Erledigt',
'todo.uncategorized': 'Ohne Kategorie',
'todo.namePlaceholder': 'Aufgabenname',
'todo.descriptionPlaceholder': 'Beschreibung (optional)',
'todo.unassigned': 'Nicht zugewiesen',
'todo.noCategory': 'Keine Kategorie',
'todo.hasDescription': 'Hat Beschreibung',
'todo.addItem': 'Neue Aufgabe hinzufügen...',
'todo.newCategory': 'Kategoriename',
'todo.addCategory': 'Kategorie hinzufügen',
'todo.newItem': 'Neue Aufgabe',
'todo.empty': 'Noch keine Aufgaben. Erstelle eine Aufgabe um loszulegen!',
'todo.filter.my': 'Meine Aufgaben',
'todo.filter.overdue': 'Überfällig',
'todo.sidebar.tasks': 'Aufgaben',
'todo.sidebar.categories': 'Kategorien',
'todo.detail.title': 'Aufgabe',
'todo.detail.description': 'Beschreibung',
'todo.detail.category': 'Kategorie',
'todo.detail.dueDate': 'Fällig am',
'todo.detail.assignedTo': 'Zuständig',
'todo.detail.delete': 'Löschen',
'todo.detail.save': 'Speichern',
'todo.sortByPrio': 'Priorität',
'todo.detail.priority': 'Priorität',
'todo.detail.noPriority': 'Keine',
'todo.detail.create': 'Aufgabe erstellen',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'Neue Version verfügbar',
'settings.notificationPreferences.noChannels': 'Keine Benachrichtigungskanäle konfiguriert. Bitte einen Administrator, E-Mail- oder Webhook-Benachrichtigungen einzurichten.',
'settings.webhookUrl.label': 'Webhook URL',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Gib deine Discord-, Slack- oder benutzerdefinierte Webhook-URL ein, um Benachrichtigungen zu erhalten.',
'settings.webhookUrl.save': 'Speichern',
'settings.webhookUrl.saved': 'Webhook-URL gespeichert',
'settings.webhookUrl.test': 'Testen',
'settings.webhookUrl.testSuccess': 'Test-Webhook erfolgreich gesendet',
'settings.webhookUrl.testFailed': 'Test-Webhook fehlgeschlagen',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint': 'In-App-Benachrichtigungen sind immer aktiv und können nicht global deaktiviert werden.',
'admin.notifications.adminWebhookPanel.title': 'Admin-Webhook',
'admin.notifications.adminWebhookPanel.hint': 'Dieser Webhook wird ausschließlich für Admin-Benachrichtigungen verwendet (z. B. Versions-Updates). Er ist unabhängig von den Benutzer-Webhooks und sendet automatisch, wenn eine URL konfiguriert ist.',
'admin.notifications.adminWebhookPanel.saved': 'Admin-Webhook-URL gespeichert',
'admin.notifications.adminWebhookPanel.testSuccess': 'Test-Webhook erfolgreich gesendet',
'admin.notifications.adminWebhookPanel.testFailed': 'Test-Webhook fehlgeschlagen',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin-Webhook sendet automatisch, wenn eine URL konfiguriert ist',
'admin.notifications.adminNotificationsHint': 'Konfiguriere, welche Kanäle Admin-Benachrichtigungen liefern (z. B. Versions-Updates). Der Webhook sendet automatisch, wenn eine Admin-Webhook-URL gesetzt ist.',
'admin.tabs.notifications': 'Benachrichtigungen',
'notifications.versionAvailable.title': 'Update verfügbar',
'notifications.versionAvailable.text': 'TREK {version} ist jetzt verfügbar.',
'notifications.versionAvailable.button': 'Details anzeigen',
'notif.test.title': '[Test] Benachrichtigung',
'notif.test.simple.text': 'Dies ist eine einfache Testbenachrichtigung.',
'notif.test.boolean.text': 'Akzeptierst du diese Testbenachrichtigung?',
'notif.test.navigate.text': 'Klicke unten, um zum Dashboard zu navigieren.',
// Notifications
'notif.trip_invite.title': 'Reiseeinladung',
'notif.trip_invite.text': '{actor} hat dich zu {trip} eingeladen',
'notif.booking_change.title': 'Buchung aktualisiert',
'notif.booking_change.text': '{actor} hat eine Buchung in {trip} aktualisiert',
'notif.trip_reminder.title': 'Reiseerinnerung',
'notif.trip_reminder.text': 'Deine Reise {trip} steht bald an!',
'notif.vacay_invite.title': 'Vacay Fusion-Einladung',
'notif.vacay_invite.text': '{actor} hat dich zum Fusionieren von Urlaubsplänen eingeladen',
'notif.photos_shared.title': 'Fotos geteilt',
'notif.photos_shared.text': '{actor} hat {count} Foto(s) in {trip} geteilt',
'notif.collab_message.title': 'Neue Nachricht',
'notif.collab_message.text': '{actor} hat eine Nachricht in {trip} gesendet',
'notif.packing_tagged.title': 'Packlistenzuweisung',
'notif.packing_tagged.text': '{actor} hat dich zu {category} in {trip} zugewiesen',
'notif.version_available.title': 'Neue Version verfügbar',
'notif.version_available.text': 'TREK {version} ist jetzt verfügbar',
'notif.action.view_trip': 'Reise ansehen',
'notif.action.view_collab': 'Nachrichten ansehen',
'notif.action.view_packing': 'Packliste ansehen',
'notif.action.view_photos': 'Fotos ansehen',
'notif.action.view_vacay': 'Vacay ansehen',
'notif.action.view_admin': 'Zum Admin',
'notif.action.view': 'Ansehen',
'notif.action.accept': 'Annehmen',
'notif.action.decline': 'Ablehnen',
'notif.generic.title': 'Benachrichtigung',
'notif.generic.text': 'Du hast eine neue Benachrichtigung',
'notif.dev.unknown_event.title': '[DEV] Unbekanntes Ereignis',
'notif.dev.unknown_event.text': 'Ereignistyp "{event}" ist nicht in EVENT_NOTIFICATION_CONFIG registriert',
}
export default de

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