Compare commits

...

256 Commits

Author SHA1 Message Date
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
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
256 changed files with 46313 additions and 9942 deletions
+7 -3
View File
@@ -6,8 +6,8 @@ data
uploads
.git
.github
.env
.env.*
**/.env
**/.env.*
*.log
*.md
!client/**/*.md
@@ -21,8 +21,12 @@ unraid-template.xml
*.db
*.db-shm
*.db-wal
coverage
**/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.
@@ -0,0 +1,67 @@
name: Close untitled issues
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
check-title:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Close if title is empty or generic
uses: actions/github-script@v7
with:
script: |
const title = context.payload.issue.title.trim();
const badTitles = [
"[bug]",
"bug report",
"bug",
"issue",
];
const featureRequestTitles = [
"feature request",
"[feature]",
"[feature request]",
"[enhancement]"
]
const titleLower = title.toLowerCase();
if (badTitles.includes(titleLower)) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: "This issue was closed because no title was provided. Please re-open with a descriptive title that summarizes the problem."
});
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"
});
} 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: "Feature requests should be made in the [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests) — not as issues. This issue has been closed."
});
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"
});
}
+69 -7
View File
@@ -3,11 +3,72 @@ 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 both package.json files
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
# 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
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 +82,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 +117,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,12 +140,13 @@ 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
+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
+3
View File
@@ -56,3 +56,6 @@ coverage
.cache
*.tsbuildinfo
*.tgz
.scannerwork
test-data
-281
View File
@@ -1,281 +0,0 @@
# TREK Security & Code Quality Audit
**Date:** 2026-03-30
**Auditor:** Automated comprehensive audit
**Scope:** Full codebase — server, client, infrastructure, dependencies
---
## Table of Contents
1. [Security](#1-security)
2. [Code Quality](#2-code-quality)
3. [Best Practices](#3-best-practices)
4. [Dependency Hygiene](#4-dependency-hygiene)
5. [Documentation & DX](#5-documentation--dx)
6. [Testing](#6-testing)
7. [Remediation Summary](#7-remediation-summary)
---
## 1. Security
### 1.1 General
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|---|----------|------|---------|-------------|-----------------|--------|
| S-1 | **CRITICAL** | `server/src/middleware/auth.ts` | 17 | JWT `verify()` does not pin algorithm — accepts whatever algorithm is in the token header, potentially including `none`. | Pass `{ algorithms: ['HS256'] }` to all `jwt.verify()` calls. | FIXED |
| S-2 | **HIGH** | `server/src/websocket.ts` | 56 | Same JWT verify without algorithm pinning in WebSocket auth. | Pin algorithm to HS256. | FIXED |
| S-3 | **HIGH** | `server/src/middleware/mfaPolicy.ts` | 54 | Same JWT verify without algorithm pinning. | Pin algorithm to HS256. | FIXED |
| S-4 | **HIGH** | `server/src/routes/oidc.ts` | 84-88 | OIDC `generateToken()` includes excessive claims (username, email, role) in JWT payload. If the JWT is leaked, this exposes PII. | Only include `{ id: user.id }` in token, consistent with auth.ts. | FIXED |
| S-5 | **HIGH** | `client/src/api/websocket.ts` | 27 | Auth token passed in WebSocket URL query string (`?token=`). Tokens in URLs appear in server logs, proxy logs, and browser history. | Document as known limitation; WebSocket protocol doesn't easily support headers from browsers. Add `LOW` priority note to switch to message-based auth in the future. | DOCUMENTED |
| S-6 | **HIGH** | `client/vite.config.js` | 47-56 | Service worker caches ALL `/api/.*` responses with `NetworkFirst`, including auth tokens, user data, budget, reservations. Data persists after logout. | Exclude sensitive API paths from caching: `/api/auth/.*`, `/api/admin/.*`, `/api/backup/.*`. | FIXED |
| S-7 | **HIGH** | `client/vite.config.js` | 57-65 | User-uploaded files (possibly passport scans, booking confirmations) cached with `CacheFirst` for 30 days, persisting after logout. | Reduce cache lifetime; add note about clearing on logout. | FIXED |
| S-8 | **MEDIUM** | `server/src/index.ts` | 60 | CSP allows `'unsafe-inline'` for scripts, weakening XSS protection. | Remove `'unsafe-inline'` from `scriptSrc` if Vite build doesn't require it. If needed for development, only allow in non-production. | FIXED |
| S-9 | **MEDIUM** | `server/src/index.ts` | 64 | CSP `connectSrc` allows `http:` and `https:` broadly, permitting connections to any origin. | Restrict to known API domains (nominatim, overpass, Google APIs) or use `'self'` with specific external origins. | FIXED |
| S-10 | **MEDIUM** | `server/src/index.ts` | 62 | CSP `imgSrc` allows `http:` broadly. | Restrict to `https:` and `'self'` plus known image domains. | FIXED |
| S-11 | **MEDIUM** | `server/src/websocket.ts` | 84-90 | No message size limit on WebSocket messages. A malicious client could send very large messages to exhaust server memory. | Set `maxPayload` on WebSocketServer configuration. | FIXED |
| S-12 | **MEDIUM** | `server/src/websocket.ts` | 84 | No rate limiting on WebSocket messages. A client can flood the server with join/leave messages. | Add per-connection message rate limiting. | FIXED |
| S-13 | **MEDIUM** | `server/src/websocket.ts` | 29 | No origin validation on WebSocket connections. | Add origin checking against allowed origins. | FIXED |
| S-14 | **MEDIUM** | `server/src/routes/auth.ts` | 157-163 | JWT tokens have 24h expiry with no refresh token mechanism. Long-lived tokens increase window of exposure if leaked. | Document as accepted risk for self-hosted app. Consider refresh tokens in future. | DOCUMENTED |
| S-15 | **MEDIUM** | `server/src/routes/auth.ts` | 367-368 | Password change does not invalidate existing JWT tokens. Old tokens remain valid for up to 24h. | Implement token version/generation tracking, or reduce token expiry and add refresh tokens. | REQUIRES MANUAL REVIEW |
| S-16 | **MEDIUM** | `server/src/services/mfaCrypto.ts` | 2, 5 | MFA encryption key is derived from JWT_SECRET. If JWT_SECRET is compromised, all MFA secrets are also compromised. Single point of failure. | Use a separate MFA_ENCRYPTION_KEY env var, or derive using a different salt/purpose. Current implementation with `:mfa:v1` salt is acceptable but tightly coupled. | DOCUMENTED |
| S-17 | **MEDIUM** | `server/src/routes/maps.ts` | 429 | Google API key exposed in URL query string (`&key=${apiKey}`). Could appear in logs. | Use header-based auth (X-Goog-Api-Key) consistently. Already used elsewhere in the file. | FIXED |
| S-18 | **MEDIUM** | `MCP.md` | 232-235 | Contains publicly accessible database download link with hardcoded credentials (`admin@admin.com` / `admin123`). | Remove credentials from documentation. | FIXED |
| S-19 | **LOW** | `server/src/index.ts` | 229 | Error handler logs full error object including stack trace to console. In containerized deployments, this could leak to centralized logging. | Sanitize error logging in production. | FIXED |
| S-20 | **LOW** | `server/src/routes/backup.ts` | 301-304 | Error detail leaked in non-production environments (`detail: process.env.NODE_ENV !== 'production' ? msg : undefined`). | Acceptable for dev, but ensure it's consistently not leaked in production. Already correct. | OK |
### 1.2 Auth (JWT + OIDC + TOTP)
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|---|----------|------|---------|-------------|-----------------|--------|
| A-1 | **CRITICAL** | All jwt.verify calls | Multiple | JWT algorithm not pinned. `jsonwebtoken` library defaults to accepting the algorithm specified in the token header, which could include `none`. | Add `{ algorithms: ['HS256'] }` to every `jwt.verify()` call. | FIXED |
| A-2 | **MEDIUM** | `server/src/routes/auth.ts` | 315-318 | MFA login token uses same JWT_SECRET and same `jwt.sign()`. Purpose field `mfa_login` prevents misuse but should use a shorter expiry. Currently 5m which is acceptable. | OK — 5 minute expiry is reasonable. | OK |
| A-3 | **MEDIUM** | `server/src/routes/oidc.ts` | 113-143 | OIDC redirect URI is dynamically constructed from request headers (`x-forwarded-proto`, `x-forwarded-host`). An attacker who can control these headers could redirect the callback to a malicious domain. | Validate the constructed redirect URI against an allowlist, or use a configured base URL from env vars. | FIXED |
| A-4 | **LOW** | `server/src/routes/auth.ts` | 21 | TOTP `window: 1` allows codes from adjacent time periods (±30s). This is standard and acceptable. | OK | OK |
### 1.3 SQLite (better-sqlite3)
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|---|----------|------|---------|-------------|-----------------|--------|
| D-1 | **HIGH** | `server/src/routes/files.ts` | 90-91 | Dynamic SQL with `IN (${placeholders})` — however, placeholders are correctly generated from array length and values are parameterized. **Not an injection risk.** | OK — pattern is safe. | OK |
| D-2 | **MEDIUM** | `server/src/routes/auth.ts` | 455 | Dynamic SQL `UPDATE users SET ${updates.join(', ')} WHERE id = ?` — column names come from controlled server-side code, not user input. Parameters are properly bound. | OK — column names are from a controlled set. | OK |
| D-3 | **LOW** | `server/src/db/database.ts` | 26-28 | WAL mode and busy_timeout configured. Good. | OK | OK |
### 1.4 WebSocket (ws)
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|---|----------|------|---------|-------------|-----------------|--------|
| W-1 | **MEDIUM** | `server/src/websocket.ts` | 29 | No `maxPayload` set on WebSocketServer. Default is 100MB which is excessive. | Set `maxPayload: 64 * 1024` (64KB). | FIXED |
| W-2 | **MEDIUM** | `server/src/websocket.ts` | 84-110 | Only `join` and `leave` message types are handled; unknown types are silently ignored. This is acceptable but there is no schema validation on the message structure. | Add basic type/schema validation using Zod. | FIXED |
| W-3 | **LOW** | `server/src/websocket.ts` | 88 | `JSON.parse` errors are silently caught with empty catch. | Log malformed messages at debug level. | FIXED |
### 1.5 Express
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|---|----------|------|---------|-------------|-----------------|--------|
| E-1 | **LOW** | `server/src/index.ts` | 82 | Body parser limit set to 100KB. Good. | OK | OK |
| E-2 | **LOW** | `server/src/index.ts` | 14-16 | Trust proxy configured conditionally. Good. | OK | OK |
| E-3 | **LOW** | `server/src/index.ts` | 121-136 | Path traversal protection on uploads endpoint. Uses `path.basename` and `path.resolve` check. Good. | OK | OK |
### 1.6 PWA / Workbox
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|---|----------|------|---------|-------------|-----------------|--------|
| P-1 | **HIGH** | `client/vite.config.js` | 47-56 | API response caching includes sensitive endpoints. | Exclude auth, admin, backup, and settings endpoints from caching. | FIXED |
| P-2 | **MEDIUM** | `client/vite.config.js` | 23, 31, 42, 54, 63 | `cacheableResponse: { statuses: [0, 200] }` — status 0 represents opaque responses which may cache error responses silently. | Remove status 0 from API and upload caches (keep for CDN/map tiles where CORS may return opaque responses). | FIXED |
| P-3 | **MEDIUM** | `client/src/store/authStore.ts` | 126-135 | Logout does not clear service worker caches. Sensitive data persists after logout. | Clear CacheStorage for `api-data` and `user-uploads` caches on logout. | FIXED |
---
## 2. Code Quality
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|---|----------|------|---------|-------------|-----------------|--------|
| Q-1 | **MEDIUM** | `client/src/store/authStore.ts` | 153-161 | `loadUser` silently catches all errors and logs user out. A transient network failure logs the user out. | Only logout on 401 responses, not on network errors. | FIXED |
| Q-2 | **MEDIUM** | `client/src/hooks/useRouteCalculation.ts` | 36 | `useCallback` depends on entire `tripStore` object, defeating memoization. | Select only needed properties from the store. | DOCUMENTED |
| Q-3 | **MEDIUM** | `client/src/hooks/useTripWebSocket.ts` | 14 | `collabFileSync` captures stale `tripStore` reference from initial render. | Use `useTripStore.getState()` instead. | DOCUMENTED |
| Q-4 | **MEDIUM** | `client/src/store/authStore.ts` | 38 vs 105 | `register` function accepts 4 params but TypeScript interface only declares 3. | Update interface to include optional `invite_token`. | FIXED |
| Q-5 | **LOW** | `client/src/store/slices/filesSlice.ts` | — | Empty catch block on file link operation (`catch {}`). | Log error. | DOCUMENTED |
| Q-6 | **LOW** | `client/src/App.tsx` | 101, 108 | Empty catch blocks silently swallow errors. | Add minimal error logging. | DOCUMENTED |
| Q-7 | **LOW** | `client/src/App.tsx` | 155 | `RegisterPage` imported but never used — `/register` route renders `LoginPage`. | Remove unused import. | FIXED |
| Q-8 | **LOW** | `client/tsconfig.json` | 14 | `strict: false` disables TypeScript strict mode. | Enable strict mode and fix resulting type errors. | REQUIRES MANUAL REVIEW |
| Q-9 | **LOW** | `client/src/main.tsx` | 7 | Non-null assertion on `getElementById('root')!`. | Add null check. | DOCUMENTED |
| Q-10 | **LOW** | `server/src/routes/files.ts` | 278 | Empty catch block on file link insert (`catch {}`). | Log duplicate link errors. | FIXED |
| Q-11 | **LOW** | `server/src/db/database.ts` | 20-21 | Silent catch on WAL checkpoint in `initDb`. | Log warning on failure. | DOCUMENTED |
---
## 3. Best Practices
### 3.1 Node / Express
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|---|----------|------|---------|-------------|-----------------|--------|
| B-1 | **LOW** | `server/src/index.ts` | 251-271 | Graceful shutdown implemented with SIGTERM/SIGINT handlers. Good — closes DB, HTTP server, with 10s timeout. | OK | OK |
| B-2 | **LOW** | `server/src/index.ts` | 87-112 | Debug logging redacts sensitive fields. Good. | OK | OK |
### 3.2 React / Vite
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|---|----------|------|---------|-------------|-----------------|--------|
| V-1 | **MEDIUM** | `client/vite.config.js` | — | No explicit `build.sourcemap: false` for production. Source maps may be generated. | Add `build: { sourcemap: false }` to Vite config. | FIXED |
| V-2 | **LOW** | `client/index.html` | 24 | Leaflet CSS loaded from unpkg CDN without Subresource Integrity (SRI) hash. | Add `integrity` and `crossorigin` attributes. | FIXED |
### 3.3 Docker
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|---|----------|------|---------|-------------|-----------------|--------|
| K-1 | **MEDIUM** | `Dockerfile` | 2, 10 | Base images use floating tags (`node:22-alpine`), not pinned to digest. | Pin to specific digest for reproducible builds. | DOCUMENTED |
| K-2 | **MEDIUM** | `Dockerfile` | — | No `HEALTHCHECK` instruction. Only docker-compose has health check. | Add `HEALTHCHECK` to Dockerfile for standalone deployments. | FIXED |
| K-3 | **LOW** | `.dockerignore` | — | Missing exclusions for `chart/`, `docs/`, `.github/`, `docker-compose.yml`, `*.sqlite*`. | Add missing exclusions. | FIXED |
### 3.4 docker-compose.yml
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|---|----------|------|---------|-------------|-----------------|--------|
| C-1 | **HIGH** | `docker-compose.yml` | 25 | `JWT_SECRET` defaults to empty string if not set. App auto-generates one, but it changes on restart, invalidating all sessions. | Log a prominent warning on startup if JWT_SECRET is auto-generated. | FIXED |
| C-2 | **MEDIUM** | `docker-compose.yml` | — | No resource limits defined for the `app` service. | Add `deploy.resources.limits` section. | DOCUMENTED |
### 3.5 Git Hygiene
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|---|----------|------|---------|-------------|-----------------|--------|
| G-1 | **HIGH** | `.gitignore` | 12-14 | Missing `*.sqlite`, `*.sqlite-wal`, `*.sqlite-shm` patterns. Only `*.db` variants covered. | Add sqlite patterns. | FIXED |
| G-2 | **LOW** | — | — | No `.env` or `.sqlite` files found in git history. | OK | OK |
### 3.6 Helm Chart
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|---|----------|------|---------|-------------|-----------------|--------|
| H-1 | **MEDIUM** | `chart/templates/secret.yaml` | 22 | `randAlphaNum 32` generates a new JWT secret on every `helm upgrade`, invalidating all sessions. | Use `lookup` to preserve existing secret across upgrades. | FIXED |
| H-2 | **MEDIUM** | `chart/values.yaml` | 3 | Default image tag is `latest`. | Use a specific version tag. | DOCUMENTED |
| H-3 | **MEDIUM** | `chart/templates/deployment.yaml` | — | No `securityContext` on pod or container. Runs as root by default. | Add `runAsNonRoot: true`, `runAsUser: 1000`. | FIXED |
| H-4 | **MEDIUM** | `chart/templates/pvc.yaml` | — | PVC always created regardless of `.Values.persistence.enabled`. | Add conditional check. | FIXED |
| H-5 | **LOW** | `chart/values.yaml` | 41 | `resources: {}` — no default resource requests or limits. | Add sensible defaults. | FIXED |
---
## 4. Dependency Hygiene
### 4.1 npm audit
| Package | Severity | Description | Status |
|---------|----------|-------------|--------|
| `serialize-javascript` (via vite-plugin-pwa → workbox-build → @rollup/plugin-terser) | **HIGH** | RCE via RegExp.flags / CPU exhaustion DoS | Fix requires `vite-plugin-pwa` major version upgrade. | DOCUMENTED |
| `picomatch` (via @rollup/pluginutils, tinyglobby) | **MODERATE** | ReDoS via extglob quantifiers | `npm audit fix` available. | FIXED |
**Server:** 0 vulnerabilities.
### 4.2 Outdated Dependencies (Notable)
| Package | Current | Latest | Risk | Status |
|---------|---------|--------|------|--------|
| `express` | ^4.18.3 | 5.2.1 | Major version — breaking changes | DOCUMENTED |
| `uuid` | ^9.0.0 | 13.0.0 | Major version | DOCUMENTED |
| `dotenv` | ^16.4.1 | 17.3.1 | Major version | DOCUMENTED |
| `lucide-react` | ^0.344.0 | 1.7.0 | Major version | DOCUMENTED |
| `react` | ^18.2.0 | 19.2.4 | Major version | DOCUMENTED |
| `zustand` | ^4.5.2 | 5.0.12 | Major version | DOCUMENTED |
> Major version upgrades require manual evaluation and testing. Not applied in this remediation pass.
---
## 5. Documentation & DX
| # | Severity | File | Description | Recommended Fix | Status |
|---|----------|------|-------------|-----------------|--------|
| X-1 | **MEDIUM** | `server/.env.example` | Missing many env vars documented in README: `OIDC_*`, `FORCE_HTTPS`, `TRUST_PROXY`, `DEMO_MODE`, `TZ`, `ALLOWED_ORIGINS`, `DEBUG`. | Add all configurable env vars. | FIXED |
| X-2 | **MEDIUM** | `server/.env.example` | JWT_SECRET placeholder is `your-super-secret-jwt-key-change-in-production` — easily overlooked. | Use `CHANGEME_GENERATE_WITH_openssl_rand_hex_32`. | FIXED |
| X-3 | **LOW** | `server/.env.example` | `PORT=3001` differs from Docker default of `3000`. | Align to `3000`. | FIXED |
---
## 6. Testing
| # | Severity | Description | Status |
|---|----------|-------------|--------|
| T-1 | **HIGH** | No test files found anywhere in the repository. Zero test coverage for auth flows, WebSocket handling, SQLite queries, API routes, or React components. | REQUIRES MANUAL REVIEW |
| T-2 | **HIGH** | No test framework configured (no jest, vitest, or similar in dependencies). | REQUIRES MANUAL REVIEW |
| T-3 | **MEDIUM** | No CI step runs tests before building Docker image. | DOCUMENTED |
---
## 7. Remediation Summary
### Applied Fixes
- **Immich SSRF prevention** — Added URL validation on save (block private IPs, metadata endpoints, non-HTTP protocols)
- **Immich API key isolation** — Removed `userId` query parameter from asset proxy endpoints; all Immich requests now use authenticated user's own credentials only
- **Immich asset ID validation** — Added alphanumeric pattern validation to prevent path traversal in proxied URLs
- **JWT algorithm pinning** — Added `{ algorithms: ['HS256'] }` to all `jwt.verify()` calls (auth middleware, MFA policy, WebSocket, OIDC, auth routes)
- **OIDC token payload** — Reduced to `{ id }` only, matching auth.ts pattern
- **OIDC redirect URI validation** — Validates against `APP_URL` env var when set
- **WebSocket hardening** — Added `maxPayload: 64KB`, message rate limiting (30 msg/10s), origin validation, improved message validation
- **CSP tightening** — Removed `'unsafe-inline'` from scripts in production, restricted `connectSrc` and `imgSrc` to known domains
- **PWA cache security** — Excluded sensitive API paths from caching, removed opaque response caching for API/uploads, clear caches on logout
- **Service worker cache cleanup on logout**
- **Google API key** — Moved from URL query string to header in maps photo endpoint
- **MCP.md credentials** — Removed hardcoded demo credentials
- **.gitignore** — Added `*.sqlite*` patterns
- **.dockerignore** — Added missing exclusions
- **Dockerfile** — Added HEALTHCHECK instruction
- **Helm chart** — Fixed secret rotation, added securityContext, conditional PVC, resource defaults
- **Vite config** — Disabled source maps in production
- **CDN integrity** — Added SRI hash for Leaflet CSS
- **.env.example** — Complete with all env vars
- **Various code quality fixes** — Removed dead imports, fixed empty catch blocks, fixed auth store interface
### Requires Manual Review
- Password change should invalidate existing tokens (S-15)
- TypeScript strict mode should be enabled (Q-8)
- Test suite needs to be created from scratch (T-1, T-2)
- Major dependency upgrades (express 5, React 19, zustand 5, etc.)
- `serialize-javascript` vulnerability fix requires vite-plugin-pwa major upgrade
### 1.7 Immich Integration
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|---|----------|------|---------|-------------|-----------------|--------|
| I-1 | **CRITICAL** | `server/src/routes/immich.ts` | 38-39, 85, 199, 250, 274 | SSRF via user-controlled `immich_url`. Users can set any URL which is then used in `fetch()` calls, allowing requests to internal metadata endpoints (169.254.169.254), localhost services, etc. | Validate URL on save: require HTTP(S) protocol, block private/internal IPs. | FIXED |
| I-2 | **CRITICAL** | `server/src/routes/immich.ts` | 194-196, 244-246, 269-270 | Asset info/thumbnail/original endpoints accept `userId` query param, allowing any authenticated user to proxy requests through another user's Immich API key. This exposes other users' Immich credentials and photo libraries. | Restrict all Immich proxy endpoints to the authenticated user's own credentials only. | FIXED |
| I-3 | **MEDIUM** | `server/src/routes/immich.ts` | 199, 250, 274 | `assetId` URL parameter used directly in `fetch()` URL construction. Path traversal characters could redirect requests to unintended Immich API endpoints. | Validate assetId matches `[a-zA-Z0-9_-]+` pattern. | FIXED |
### 1.8 Admin Routes
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|---|----------|------|---------|-------------|-----------------|--------|
| AD-1 | **MEDIUM** | `server/src/routes/admin.ts` | 302-310 | Self-update endpoint runs `git pull` then `npm run build`. While admin-only and `npm install` uses `--ignore-scripts`, `npm run build` executes whatever is in the pulled package.json. A compromised upstream could execute arbitrary code. | Document as accepted risk for self-hosted self-update feature. Users should pin to specific versions. | DOCUMENTED |
### 1.9 Client-Side XSS
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|---|----------|------|---------|-------------|-----------------|--------|
| X-1 | **CRITICAL** | `client/src/components/Admin/GitHubPanel.tsx` | 66, 106 | `dangerouslySetInnerHTML` with `inlineFormat()` renders GitHub release notes as HTML without escaping. Malicious HTML in release notes could execute scripts. | Escape HTML entities before applying markdown-style formatting. Validate link URLs. | FIXED |
| X-2 | **LOW** | `client/src/components/Map/MapView.tsx` | divIcon | Uses `escAttr()` for HTML sanitization in divIcon strings. Properly mitigated. | OK | OK |
### 1.10 Route Calculator Bug
| # | Severity | File | Line(s) | Description | Recommended Fix | Status |
|---|----------|------|---------|-------------|-----------------|--------|
| RC-1 | **HIGH** | `client/src/components/Map/RouteCalculator.ts` | 16 | OSRM URL hardcodes `'driving'` profile, ignoring the `profile` parameter. Walking/cycling routes always return driving results. | Use the `profile` parameter in URL construction. | FIXED |
### Additional Findings (from exhaustive scan)
- **MEDIUM** — `server/src/index.ts:121-136`: Upload files (`/uploads/:type/:filename`) served without authentication. UUIDs are unguessable but this is security-through-obscurity. **REQUIRES MANUAL REVIEW** — adding auth would break shared trip image URLs.
- **MEDIUM** — `server/src/routes/oidc.ts:194`: OIDC token exchange error was logging full token response (potentially including access tokens). **FIXED** — now logs only HTTP status.
- **MEDIUM** — `server/src/services/notifications.ts:194-196`: Email body is not HTML-escaped. User-generated content (trip names, usernames) interpolated directly into HTML email template. Potential stored XSS in email clients. **DOCUMENTED** — needs HTML entity escaping.
- **LOW** — `server/src/demo/demo-seed.ts:7-9`: Hardcoded demo credentials (`demo12345`, `admin12345`). Intentional for demo mode but dangerous if DEMO_MODE accidentally left on in production. Already has startup warning.
- **LOW** — `server/src/routes/auth.ts:742`: MFA setup returns plaintext TOTP secret to client. This is standard TOTP enrollment flow — users need the secret for manual entry. Must be served over HTTPS.
- **LOW** — `server/src/routes/auth.ts:473`: Admin settings GET returns API keys in full (not masked). Only accessible to admins.
- **LOW** — `server/src/routes/auth.ts:564`: SMTP password stored as plaintext in `app_settings` table. Masked in API response but unencrypted at rest.
### Accepted Risks (Documented)
- WebSocket token in URL query string (browser limitation)
- 24h JWT expiry without refresh tokens (acceptable for self-hosted)
- MFA encryption key derived from JWT_SECRET (noted coupling)
- localStorage for token storage (standard SPA pattern)
- Upload files served without auth (UUID-based obscurity, needed for shared trips)
+57
View File
@@ -0,0 +1,57 @@
# 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
## 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 Setup
```bash
git clone https://github.com/mauriceboe/TREK.git
cd TREK
# Server
cd server && npm install && npm run dev
# Client (separate terminal)
cd client && npm install && npm run dev
```
Server: `http://localhost:3001` | Client: `http://localhost:5173`
On first run, check the server logs for the auto-generated admin credentials.
## More Details
See the [Contributing wiki page](https://github.com/mauriceboe/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
+30 -2
View File
@@ -9,6 +9,7 @@
</p>
<p align="center">
<a href="https://discord.gg/J27gr9GH"><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>
@@ -139,10 +140,27 @@ services:
- NODE_ENV=production
- PORT=3000
- 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).
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
- 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
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich is on your local network (RFC-1918 IPs)
- 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)
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
@@ -265,16 +283,26 @@ trek.yourdomain.com {
| `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 | `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` | `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` | 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` |
## Optional API Keys
+2
View File
@@ -32,3 +32,5 @@ See `values.yaml` for more options.
- `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.
+48
View File
@@ -7,9 +7,57 @@ metadata:
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 }}
+21
View File
@@ -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:
@@ -42,6 +45,24 @@ spec:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
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
+3
View File
@@ -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 }}
+18
View File
@@ -8,6 +8,15 @@ metadata:
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) }}
@@ -26,4 +35,13 @@ stringData:
{{- 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 }}
+41
View File
@@ -15,11 +15,44 @@ service:
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.
# Secret environment variables stored in a Kubernetes Secret.
@@ -32,6 +65,13 @@ secretEnv:
# 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
@@ -57,6 +97,7 @@ resources:
ingress:
enabled: false
className: ""
annotations: {}
hosts:
- host: chart-example.local
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "trek-client",
"version": "2.7.2",
"version": "2.9.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-client",
"version": "2.7.2",
"version": "2.9.5",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
@@ -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"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "2.7.2",
"version": "2.9.5",
"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

+20 -4
View File
@@ -11,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 { authApi } from './api/client'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
interface ProtectedRouteProps {
children: ReactNode
@@ -41,7 +43,8 @@ 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 (
@@ -75,13 +78,16 @@ function RootRedirect() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
useEffect(() => {
loadUser()
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
if (!location.pathname.startsWith('/shared/')) {
loadUser()
}
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)
@@ -112,6 +118,8 @@ export default function App() {
const { settings } = useSettingsStore()
useInAppNotificationListener()
useEffect(() => {
if (isAuthenticated) {
loadSettings()
@@ -211,6 +219,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/notifications"
element={
<ProtectedRoute>
<InAppNotificationsPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</TranslationProvider>
+43 -1
View File
@@ -1,4 +1,4 @@
export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): Promise<string> {
export async function getAuthUrl(url: string, purpose: 'download'): Promise<string> {
if (!url) return url
try {
const resp = await fetch('/api/auth/resource-token', {
@@ -14,3 +14,45 @@ export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): P
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)
}
})
}
+43 -6
View File
@@ -25,9 +25,10 @@ apiClient.interceptors.request.use(
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
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 (
@@ -83,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 = {
@@ -129,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),
@@ -184,6 +198,10 @@ export const adminApi = {
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 = {
@@ -230,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 = {
@@ -313,9 +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: () => apiClient.post('/notifications/test-webhook').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
+110 -23
View File
@@ -15,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 {
@@ -34,7 +44,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const toast = useToast()
const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
const [addons, setAddons] = useState([])
const [addons, setAddons] = useState<Addon[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
@@ -53,7 +63,7 @@ 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))
@@ -68,9 +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 (
@@ -108,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 }}>
@@ -171,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 } {
@@ -187,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 */}
@@ -200,7 +285,7 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{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
@@ -210,28 +295,30 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
{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,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>
)
}
+78 -3
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() {
@@ -119,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"
@@ -156,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 */}
+3 -3
View File
@@ -489,7 +489,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
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:00'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' }) }
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)]
@@ -633,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')} readOnly={!canEdit} />
<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 }}>
@@ -701,7 +701,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
})}
</div>
<div className="w-full md:w-[180px]" 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}
+73 -14
View File
@@ -3,8 +3,9 @@ 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'
@@ -96,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>
@@ -122,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>
@@ -144,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' },
@@ -460,14 +484,14 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
{t('collab.notes.attachFiles')}
</div>
<input id="note-file-input" ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { setPendingFiles(prev => [...prev, ...Array.from((e.target 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} />
@@ -484,10 +508,10 @@ 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>}
@@ -845,7 +869,7 @@ function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onVi
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)' }}
@@ -974,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)
@@ -1330,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>,
@@ -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 {
+105 -30
View File
@@ -1,7 +1,7 @@
import ReactDOM from 'react-dom'
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'
@@ -37,49 +37,121 @@ 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(() => {
getAuthUrl(file.url, 'download').then(setImgSrc)
}, [file.url])
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={imgSrc}
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 }}>
<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: 0 }}
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: 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('')
@@ -169,7 +241,7 @@ 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)
@@ -324,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)
}
@@ -453,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(
@@ -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>
)
}
+20 -6
View File
@@ -7,6 +7,7 @@ import { useAddonStore } from '../../store/addonStore'
import { useTranslation } from '../../i18n'
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 }
@@ -132,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>
</>
@@ -154,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">
@@ -228,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>
)}
+23 -12
View File
@@ -72,13 +72,17 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
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:hidden;background:${bgColor};
width:${size}px;height:${size}px;
cursor:pointer;position:relative;
">
<img src="${place.image_url}" width="${size}" height="${size}" style="display:block;border-radius:50%;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>`,
iconSize: [size, size],
@@ -157,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)
@@ -172,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
}
@@ -373,17 +383,18 @@ export const MapView = memo(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])
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
// photoUrls: only base64 thumbs for smooth map zoom
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
@@ -505,7 +516,7 @@ export const MapView = memo(function MapView({
/>
<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} />
+440 -165
View File
@@ -1,30 +1,47 @@
import { useState, useEffect, useCallback } from 'react'
import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen } 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 { getAuthUrl } from '../../api/authUrl'
import { fetchImageAsBlob, clearImageQueue } from '../../api/authUrl'
import { useToast } from '../shared/Toast'
function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
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(() => {
getAuthUrl(baseUrl, 'immich').then(setSrc)
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
@@ -40,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)
@@ -50,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())
@@ -65,52 +86,105 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
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; immich_album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([])
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(`/integrations/immich/trips/${tripId}/album-links`)
const res = await apiClient.get(buildUnifiedUrl('album-links'))
setAlbumLinks(res.data.links || [])
} catch { setAlbumLinks([]) }
}
const openAlbumPicker = async () => {
setShowAlbumPicker(true)
const loadAlbums = async (provider: string = selectedProvider) => {
if (!provider) return
setAlbumsLoading(true)
try {
const res = await apiClient.get('/integrations/immich/albums')
const res = await apiClient.get(buildProviderUrl(provider, 'albums'))
setAlbums(res.data.albums || [])
} catch { setAlbums([]) }
finally { setAlbumsLoading(false) }
} 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(`/integrations/immich/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName })
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(`/integrations/immich/trips/${tripId}/album-links`)
const newLink = (linksRes.data.links || []).find((l: any) => l.immich_album_id === albumId)
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 {}
} catch { toast.error(t('memories.error.linkAlbum')) }
}
const unlinkAlbum = async (linkId: number) => {
try {
await apiClient.delete(`/integrations/immich/trips/${tripId}/album-links/${linkId}`)
loadAlbumLinks()
} catch {}
}
const syncAlbum = async (linkId: number) => {
setSyncing(linkId)
try {
await apiClient.post(`/integrations/immich/trips/${tripId}/album-links/${linkId}/sync`)
await apiClient.delete(buildUnifiedUrl('album-links', linkId.toString()))
await loadAlbumLinks()
await loadPhotos()
} catch {}
} 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) }
}
@@ -120,6 +194,14 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
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 ──────────────────────────────────────────────────────────────────
@@ -136,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([])
@@ -146,9 +228,37 @@ 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()
@@ -168,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)
}
@@ -200,39 +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 thumbnailBaseUrl = (assetId: string, userId: number) =>
`/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}`
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)
@@ -272,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>
)
@@ -283,22 +435,53 @@ 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.immich_album_id))
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)' }}>
{t('memories.selectAlbum')}
{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 ? (
@@ -350,7 +533,11 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
// ── 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 (
<>
@@ -359,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>
@@ -377,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 && (
@@ -420,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'
@@ -441,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',
@@ -453,7 +651,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
outlineOffset: -3,
}}>
<ImmichImg baseUrl={thumbnailBaseUrl(asset.id, currentUser!.id)} loading="lazy"
<ProviderImg baseUrl={buildProviderAssetUrlFromAsset(asset, 'thumbnail', currentUser!.id)} provider={asset.provider} loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
{isSelected && (
<div style={{
@@ -561,7 +759,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
<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)} disabled={syncing === link.id} title={t('memories.syncAlbum')}
<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>
@@ -607,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,
@@ -627,18 +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('')
getAuthUrl(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`, 'immich').then(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))
}}>
<ImmichImg baseUrl={thumbnailBaseUrl(photo.immich_asset_id, photo.user_id)} 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 */}
@@ -669,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',
@@ -678,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)',
@@ -739,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={lightboxOriginalSrc}
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;
@@ -67,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 {
@@ -154,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 }}>
@@ -738,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)
@@ -775,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]
@@ -885,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={{
@@ -1008,25 +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>
{canEdit && (
<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 />
)
})}
@@ -1095,25 +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>
{canEdit && (
<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} />
)
})}
@@ -1187,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' })
}
@@ -154,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)
@@ -445,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"
/>
@@ -457,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"
/>
+307 -97
View File
@@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
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'
@@ -80,6 +80,10 @@ interface DayPlanSidebarProps {
onAddReservation: () => void
onNavigateToFiles?: () => void
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
canUndo?: boolean
lastActionLabel?: string | null
onUndo?: () => void
}
const DayPlanSidebar = React.memo(function DayPlanSidebar({
@@ -93,6 +97,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onAddReservation,
onNavigateToFiles,
onExpandedDaysChange,
pushUndo,
canUndo = false,
lastActionLabel = null,
onUndo,
}: DayPlanSidebarProps) {
const toast = useToast()
const { t, language, locale } = useTranslation()
@@ -119,12 +127,16 @@ const DayPlanSidebar = React.memo(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
@@ -200,13 +212,67 @@ const DayPlanSidebar = React.memo(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 []
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
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
})
}
@@ -268,47 +334,48 @@ const DayPlanSidebar = React.memo(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)
// Build base list: ALL places (timed and untimed) + notes sorted by order_index/sort_order
// Places keep their order_index ordering — only transports are inserted based on time.
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)
// Only transports are inserted among base items based on time/position
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 base items using persisted position or time-to-position mapping.
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 available, fallback to global position
const dayObj = days.find(d => d.id === dayId)
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
const effectivePos = perDayPos ?? timed.data.day_plan_position
if (effectivePos != null) {
result.push({ type: timed.type, sortKey: effectivePos, data: timed.data })
continue
}
// Find insertion position: after the last base item with time <= this item's time
// Find insertion position: after the last base item with time <= this transport's time
let insertAfterKey = -Infinity
for (const item of result) {
if (item.type === 'place') {
@@ -339,7 +406,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
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])
}, [days, assignments, dayNotes, reservations, transportPosVersion])
const openAddNote = (dayId, e) => {
e?.stopPropagation()
@@ -395,6 +462,9 @@ const DayPlanSidebar = React.memo(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[] = []
@@ -433,9 +503,22 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
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') }
}
@@ -599,12 +682,14 @@ const DayPlanSidebar = React.memo(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 () => {
@@ -612,6 +697,8 @@ const DayPlanSidebar = React.memo(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 = []
@@ -638,6 +725,10 @@ const DayPlanSidebar = React.memo(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 = () => {
@@ -656,7 +747,16 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
if (placeId) {
onAssignToDay?.(parseInt(placeId), dayId)
} else if (assignmentId && fromDayId !== dayId) {
tripActions.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) {
tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
}
@@ -705,62 +805,124 @@ const DayPlanSidebar = React.memo(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`, {
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') }
}}
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>
@@ -779,7 +941,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const placeItems = merged.filter(i => i.type === 'place')
return (
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)', contentVisibility: 'auto', containIntrinsicSize: '0 64px' }}>
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
<div
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
@@ -796,6 +958,7 @@ const DayPlanSidebar = React.memo(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' }}
@@ -867,6 +1030,17 @@ const DayPlanSidebar = React.memo(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' }}>
@@ -909,18 +1083,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
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) {
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) {
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
@@ -961,8 +1137,9 @@ const DayPlanSidebar = React.memo(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
@@ -1190,6 +1367,11 @@ const DayPlanSidebar = React.memo(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 || {})
@@ -1206,25 +1388,37 @@ const DayPlanSidebar = React.memo(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) {
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) {
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
}}
@@ -1239,6 +1433,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
background: isTransportHovered ? `${color}12` : `${color}08`,
cursor: 'pointer', userSelect: 'none',
transition: 'background 0.1s',
opacity: spanPhase === 'middle' ? 0.65 : 1,
}}
>
<div style={{
@@ -1249,14 +1444,27 @@ const DayPlanSidebar = React.memo(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>
@@ -1267,6 +1475,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
)}
</div>
</div>
{showDropLineAfter && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
</React.Fragment>
)
}
@@ -1453,8 +1662,9 @@ const DayPlanSidebar = React.memo(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}
@@ -1468,7 +1678,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
<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>
@@ -1569,7 +1779,7 @@ const DayPlanSidebar = React.memo(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' })}`}
@@ -373,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') && (
@@ -29,11 +29,12 @@ interface PlacesSidebarProps {
days: Day[]
isMobile: boolean
onCategoryFilterChange?: (categoryId: string) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
}
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()
@@ -52,6 +53,15 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
const result = await placesApi.importGpx(tripId, file)
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'))
}
@@ -70,6 +80,15 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
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 {
@@ -405,7 +424,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
<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:00').toLocaleDateString()}</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>
@@ -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'
@@ -71,11 +72,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 +106,21 @@ 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) || ''
}
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 +130,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 +140,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 +160,21 @@ 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'
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 +183,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 +193,30 @@ 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.reservation_time, reservation_end_time: 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 +312,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 +341,81 @@ 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 date = d || 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 +503,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 +513,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>
</>
)}
@@ -556,12 +649,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 +698,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' })
}
@@ -84,8 +84,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)
@@ -136,7 +136,12 @@ 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?.includes('T') && r.reservation_end_time.split('T')[0] !== r.reservation_time.split('T')[0] && (
<> {fmtDate(r.reservation_end_time)}</>
)}
</div>
</div>
)}
{r.reservation_time?.includes('T') && (
@@ -179,8 +184,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)' }}>
+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')
} 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>
)
}
+19 -4
View File
@@ -36,6 +36,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
start_date: '',
end_date: '',
reminder_days: 0 as number,
day_count: 7,
})
const [customReminder, setCustomReminder] = useState(false)
const [error, setError] = useState('')
@@ -56,11 +57,12 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
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: '', reminder_days: tripRemindersEnabled ? 3 : 0 })
setFormData({ title: '', description: '', start_date: '', end_date: '', reminder_days: tripRemindersEnabled ? 3 : 0, day_count: 7 })
setCustomReminder(false)
setCoverPreview(null)
}
@@ -98,6 +100,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
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) {
@@ -197,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]
}
@@ -297,6 +300,18 @@ 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' : ''}>
@@ -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 }
@@ -21,9 +21,9 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
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) => {
@@ -36,7 +36,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
}, [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) }
@@ -47,7 +47,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
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, compact ? { day: '2-digit', month: '2-digit', year: '2-digit' } : { 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)
@@ -57,7 +57,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
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
@@ -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 }
}
+4 -1
View File
@@ -15,11 +15,14 @@ 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 currentAssignments = tripStore.assignments || {}
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 }
+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 {
+207 -5
View File
@@ -85,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': 'جارية',
@@ -104,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': 'إنشاء رحلة جديدة',
@@ -113,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': 'إضافة صورة غلاف',
@@ -127,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': 'اختر قالبًا...',
@@ -242,6 +255,15 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'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': 'الدور',
@@ -382,9 +404,9 @@ 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',
@@ -507,8 +529,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'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.packing.name': 'القوائم',
'admin.addons.catalog.packing.description': 'قوائم التعبئة والمهام لرحلاتك',
'admin.addons.catalog.budget.name': 'الميزانية',
'admin.addons.catalog.budget.description': 'تتبع النفقات وخطط ميزانية الرحلة',
'admin.addons.catalog.documents.name': 'المستندات',
@@ -592,7 +614,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': 'سيتم حذف كل إدخالات الإجازات والعطل الخاصة بهذه السنة نهائيًا.',
@@ -683,8 +706,10 @@ 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': 'ابحث عن دولة...',
@@ -734,6 +759,8 @@ 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': 'جارٍ تحميل الرحلة...',
@@ -929,6 +956,32 @@ 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': 'الميزانية',
@@ -1488,6 +1541,155 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'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
+207 -5
View File
@@ -80,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',
@@ -99,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',
@@ -108,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)',
@@ -122,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...',
@@ -212,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',
@@ -458,7 +480,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',
@@ -484,8 +506,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',
@@ -523,7 +545,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.',
@@ -573,7 +595,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.',
@@ -665,8 +688,10 @@ 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...',
@@ -716,6 +741,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...',
@@ -910,6 +937,32 @@ 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',
@@ -1483,6 +1536,155 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'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
+210 -8
View File
@@ -81,7 +81,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'dashboard.sharedBy': 'Sdílí {name}',
'dashboard.days': 'Dní',
'dashboard.places': 'Míst',
'dashboard.members': 'Cestovní parťáci',
'dashboard.archive': 'Archivovat',
'dashboard.copyTrip': 'Kopírovat',
'dashboard.copySuffix': 'kopie',
'dashboard.restore': 'Obnovit',
'dashboard.archived': 'Archivováno',
'dashboard.status.ongoing': 'Probíhající',
@@ -100,7 +103,9 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'dashboard.toast.archiveError': 'Nepodařilo se archivovat cestu',
'dashboard.toast.restored': 'Cesta byla obnovena',
'dashboard.toast.restoreError': 'Nepodařilo se obnovit cestu',
'dashboard.confirm.delete': 'Smazat cestu „{title}“? Všechna místa a plány budou trvale smazány.',
'dashboard.toast.copied': 'Cesta byla zkopírována!',
'dashboard.toast.copyError': 'Nepodařilo se zkopírovat cestu',
'dashboard.confirm.delete': 'Smazat cestu „{title}”? Všechna místa a plány budou trvale smazány.',
'dashboard.editTrip': 'Upravit cestu',
'dashboard.createTrip': 'Vytvořit novou cestu',
'dashboard.tripTitle': 'Název',
@@ -109,6 +114,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'dashboard.tripDescriptionPlaceholder': 'O čem je tato cesta?',
'dashboard.startDate': 'Datum začátku',
'dashboard.endDate': 'Datum konce',
'dashboard.dayCount': 'Počet dnů',
'dashboard.dayCountHint': 'Kolik dnů naplánovat, když nejsou nastavena data cesty.',
'dashboard.noDateHint': 'Datum nezadáno výchozí délka nastavena na 7 dní. Toto lze kdykoli změnit.',
'dashboard.coverImage': 'Úvodní obrázek',
'dashboard.addCoverImage': 'Vybrat úvodní obrázek (nebo přetáhnout sem)',
@@ -123,6 +130,12 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
// Nastavení (Settings)
'settings.title': 'Nastavení',
'settings.subtitle': 'Upravte své osobní nastavení',
'settings.tabs.display': 'Zobrazení',
'settings.tabs.map': 'Mapa',
'settings.tabs.notifications': 'Oznámení',
'settings.tabs.integrations': 'Integrace',
'settings.tabs.account': 'Účet',
'settings.tabs.about': 'O aplikaci',
'settings.map': 'Mapy',
'settings.mapTemplate': 'Šablona mapy',
'settings.mapTemplatePlaceholder.select': 'Vyberte šablonu...',
@@ -190,6 +203,15 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.toast.deleted': 'Token smazán',
'settings.mcp.toast.deleteError': 'Nepodařilo se smazat token',
'settings.account': 'Účet',
'settings.about': 'O aplikaci',
'settings.about.reportBug': 'Nahlásit chybu',
'settings.about.reportBugHint': 'Našli jste problém? Dejte nám vědět',
'settings.about.featureRequest': 'Navrhnout funkci',
'settings.about.featureRequestHint': 'Navrhněte novou funkci',
'settings.about.wikiHint': 'Dokumentace a návody',
'settings.about.description': 'TREK je samohostovaný plánovač cest, který vám pomůže organizovat výlety od prvního nápadu po poslední vzpomínku. Denní plánování, rozpočet, balicí seznamy, fotky a mnoho dalšího — vše na jednom místě, na vašem vlastním serveru.',
'settings.about.madeWith': 'Vytvořeno s',
'settings.about.madeBy': 'Mauricem a rostoucí open-source komunitou.',
'settings.username': 'Uživatelské jméno',
'settings.email': 'E-mail',
'settings.role': 'Role',
@@ -458,7 +480,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
// Šablony balení (Packing Templates)
'admin.bagTracking.title': 'Sledování zavazadel',
'admin.bagTracking.subtitle': 'Povolit váhu a přiřazení k zavazadlům u položek balení',
'admin.tabs.config': 'Konfigurace',
'admin.tabs.config': 'Personalizace',
'admin.tabs.templates': 'Šablony seznamů',
'admin.packingTemplates.title': 'Šablony pro balení',
'admin.packingTemplates.subtitle': 'Vytvářejte opakovaně použitelné seznamy pro své cesty',
@@ -484,8 +506,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.subtitle': 'Zapněte nebo vypněte funkce a přizpůsobte si TREK.',
'admin.addons.catalog.memories.name': 'Fotky (Immich)',
'admin.addons.catalog.memories.description': 'Sdílejte cestovní fotky přes vaši instanci Immich',
'admin.addons.catalog.packing.name': 'Balení',
'admin.addons.catalog.packing.description': 'Seznamy věcí pro přípravu na cestu',
'admin.addons.catalog.packing.name': 'Seznamy',
'admin.addons.catalog.packing.description': 'Balicí seznamy a úkoly pro vaše výlety',
'admin.addons.catalog.budget.name': 'Rozpočet',
'admin.addons.catalog.budget.description': 'Sledování výdajů a plánování rozpočtu cesty',
'admin.addons.catalog.documents.name': 'Dokumenty',
@@ -507,14 +529,12 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.toast.updated': 'Doplněk byl aktualizován',
'admin.addons.toast.error': 'Aktualizace doplňku se nezdařila',
'admin.addons.noAddons': 'Žádné doplňky nejsou k dispozici',
'admin.addons.catalog.memories.name': 'Fotky (Immich)',
'admin.addons.catalog.memories.description': 'Sdílejte cestovní fotky přes vaši instanci Immich',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'Model Context Protocol pro integraci AI asistentů',
'admin.addons.subtitleBefore': 'Zapněte nebo vypněte funkce a přizpůsobte si ',
'admin.addons.subtitleAfter': '.',
'admin.tabs.audit': 'Auditní protokol',
'admin.tabs.audit': 'Audit',
'admin.audit.subtitle': 'Bezpečnostní a administrátorské události (zálohy, uživatelé, 2FA, nastavení).',
'admin.audit.empty': 'Zatím žádné záznamy auditu.',
@@ -593,7 +613,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'vacay.subtitle': 'Plánování a správa dovolené',
'vacay.settings': 'Nastavení',
'vacay.year': 'Rok',
'vacay.addYear': 'Přidat rok',
'vacay.addYear': 'Přidat následující rok',
'vacay.addPrevYear': 'Přidat předchozí rok',
'vacay.removeYear': 'Odebrat rok',
'vacay.removeYearConfirm': 'Odebrat rok {year}?',
'vacay.removeYearHint': 'Všechny záznamy o dovolené a firemní svátky pro tento rok budou trvale smazány.',
@@ -684,8 +705,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'atlas.unmark': 'Odebrat',
'atlas.confirmMark': 'Označit tuto zemi jako navštívenou?',
'atlas.confirmUnmark': 'Odebrat tuto zemi ze seznamu navštívených?',
'atlas.confirmUnmarkRegion': 'Odebrat tento region ze seznamu navštívených?',
'atlas.markVisited': 'Označit jako navštívené',
'atlas.markVisitedHint': 'Přidat tuto zemi do seznamu navštívených',
'atlas.markRegionVisitedHint': 'Přidat tento region do seznamu navštívených',
'atlas.addToBucket': 'Přidat do seznamu přání (Bucket list)',
'atlas.addPoi': 'Přidat místo',
'atlas.bucketNamePlaceholder': 'Název (země, město, místo...)',
@@ -734,6 +757,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'Rez.',
'trip.tabs.packing': 'Seznam věcí',
'trip.tabs.packingShort': 'Balení',
'trip.tabs.lists': 'Seznamy',
'trip.tabs.listsShort': 'Seznamy',
'trip.tabs.budget': 'Rozpočet',
'trip.tabs.files': 'Soubory',
'trip.loading': 'Načítání cesty...',
@@ -929,6 +954,32 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'Propojit s přiřazením dne',
'reservations.pickAssignment': 'Vyberte přiřazení z vašeho plánu...',
'reservations.noAssignment': 'Bez propojení (samostatné)',
'reservations.price': 'Cena',
'reservations.budgetCategory': 'Kategorie rozpočtu',
'reservations.budgetCategoryPlaceholder': 'např. Doprava, Ubytování',
'reservations.budgetCategoryAuto': 'Auto (podle typu rezervace)',
'reservations.budgetHint': 'Při ukládání bude automaticky vytvořena položka rozpočtu.',
'reservations.departureDate': 'Odlet',
'reservations.arrivalDate': 'Přílet',
'reservations.departureTime': 'Čas odletu',
'reservations.arrivalTime': 'Čas příletu',
'reservations.pickupDate': 'Vyzvednutí',
'reservations.returnDate': 'Vrácení',
'reservations.pickupTime': 'Čas vyzvednutí',
'reservations.returnTime': 'Čas vrácení',
'reservations.endDate': 'Datum konce',
'reservations.meta.departureTimezone': 'TZ odletu',
'reservations.meta.arrivalTimezone': 'TZ příletu',
'reservations.span.departure': 'Odlet',
'reservations.span.arrival': 'Přílet',
'reservations.span.inTransit': 'Na cestě',
'reservations.span.pickup': 'Vyzvednutí',
'reservations.span.return': 'Vrácení',
'reservations.span.active': 'Aktivní',
'reservations.span.start': 'Začátek',
'reservations.span.end': 'Konec',
'reservations.span.ongoing': 'Probíhá',
'reservations.validation.endBeforeStart': 'Datum/čas konce musí být po datu/čase začátku',
// Rozpočet (Budget)
'budget.title': 'Rozpočet',
@@ -1488,6 +1539,157 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'perm.actionHint.packing_edit': 'Kdo může spravovat položky balení a tašky',
'perm.actionHint.collab_edit': 'Kdo může vytvářet poznámky, hlasování a posílat zprávy',
'perm.actionHint.share_manage': 'Kdo může vytvářet nebo mazat veřejné odkazy ke sdílení',
// Undo
'undo.button': 'Zpět',
'undo.tooltip': 'Zpět: {action}',
'undo.assignPlace': 'Místo přiřazeno ke dni',
'undo.removeAssignment': 'Místo odebráno ze dne',
'undo.reorder': 'Místa přeseřazena',
'undo.optimize': 'Trasa optimalizována',
'undo.deletePlace': 'Místo smazáno',
'undo.moveDay': 'Místo přesunuto na jiný den',
'undo.lock': 'Zámek místa přepnut',
'undo.importGpx': 'Import GPX',
'undo.importGoogleList': 'Import z Google Maps',
// Notifications
'notifications.title': 'Oznámení',
'notifications.markAllRead': 'Označit vše jako přečtené',
'notifications.deleteAll': 'Smazat vše',
'notifications.showAll': 'Zobrazit všechna oznámení',
'notifications.empty': 'Žádná oznámení',
'notifications.emptyDescription': 'Vše máte přečteno!',
'notifications.all': 'Vše',
'notifications.unreadOnly': 'Nepřečtené',
'notifications.markRead': 'Označit jako přečtené',
'notifications.markUnread': 'Označit jako nepřečtené',
'notifications.delete': 'Smazat',
'notifications.system': 'Systém',
'settings.mustChangePassword': 'Před pokračováním musíte změnit heslo.',
'atlas.searchCountry': 'Hledat zemi...',
'memories.error.loadAlbums': 'Načtení alb se nezdařilo',
'memories.error.linkAlbum': 'Propojení alba se nezdařilo',
'memories.error.unlinkAlbum': 'Odpojení alba se nezdařilo',
'memories.error.syncAlbum': 'Synchronizace alba se nezdařila',
'memories.error.loadPhotos': 'Načtení fotek se nezdařilo',
'memories.error.addPhotos': 'Přidání fotek se nezdařilo',
'memories.error.removePhoto': 'Odebrání fotky se nezdařilo',
'memories.error.toggleSharing': 'Aktualizace sdílení se nezdařila',
'undo.addPlace': 'Místo přidáno',
'undo.done': 'Vráceno zpět: {action}',
'notifications.test.title': 'Testovací oznámení od {actor}',
'notifications.test.text': 'Toto je jednoduché testovací oznámení.',
'notifications.test.booleanTitle': '{actor} žádá o vaše schválení',
'notifications.test.booleanText': 'Testovací oznámení s volbou.',
'notifications.test.accept': 'Schválit',
'notifications.test.decline': 'Odmítnout',
'notifications.test.navigateTitle': 'Podívejte se na toto',
'notifications.test.navigateText': 'Testovací navigační oznámení.',
'notifications.test.goThere': 'Přejít tam',
'notifications.test.adminTitle': 'Hromadná zpráva pro správce',
'notifications.test.adminText': '{actor} odeslal testovací oznámení všem správcům.',
'notifications.test.tripTitle': '{actor} přispěl do vašeho výletu',
'notifications.test.tripText': 'Testovací oznámení pro výlet "{trip}".',
// Todo
'todo.subtab.packing': 'Balicí seznam',
'todo.subtab.todo': 'Úkoly',
'todo.completed': 'dokončeno',
'todo.filter.all': 'Vše',
'todo.filter.open': 'Otevřené',
'todo.filter.done': 'Hotové',
'todo.uncategorized': 'Bez kategorie',
'todo.namePlaceholder': 'Název úkolu',
'todo.descriptionPlaceholder': 'Popis (volitelné)',
'todo.unassigned': 'Nepřiřazeno',
'todo.noCategory': 'Bez kategorie',
'todo.hasDescription': 'Má popis',
'todo.addItem': 'Přidat nový úkol...',
'todo.newCategory': 'Název kategorie',
'todo.addCategory': 'Přidat kategorii',
'todo.newItem': 'Nový úkol',
'todo.empty': 'Zatím žádné úkoly. Přidejte úkol a začněte!',
'todo.filter.my': 'Moje úkoly',
'todo.filter.overdue': 'Po termínu',
'todo.sidebar.tasks': 'Úkoly',
'todo.sidebar.categories': 'Kategorie',
'todo.detail.title': 'Úkol',
'todo.detail.description': 'Popis',
'todo.detail.category': 'Kategorie',
'todo.detail.dueDate': 'Termín splnění',
'todo.detail.assignedTo': 'Přiřazeno',
'todo.detail.delete': 'Smazat',
'todo.detail.save': 'Uložit změny',
'todo.detail.create': 'Vytvořit úkol',
'todo.detail.priority': 'Priorita',
'todo.detail.noPriority': 'Žádná',
'todo.sortByPrio': 'Priorita',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'Nová verze k dispozici',
'settings.notificationPreferences.noChannels': 'Nejsou nakonfigurovány žádné kanály oznámení. Požádejte správce o nastavení e-mailových nebo webhook oznámení.',
'settings.webhookUrl.label': 'URL webhooku',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Zadejte URL vašeho Discord, Slack nebo vlastního webhooku pro příjem oznámení.',
'settings.webhookUrl.save': 'Uložit',
'settings.webhookUrl.saved': 'URL webhooku uložena',
'settings.webhookUrl.test': 'Otestovat',
'settings.webhookUrl.testSuccess': 'Testovací webhook byl úspěšně odeslán',
'settings.webhookUrl.testFailed': 'Testovací webhook selhal',
'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 oznámení jsou vždy aktivní a nelze je globálně vypnout.',
'admin.notifications.adminWebhookPanel.title': 'Admin webhook',
'admin.notifications.adminWebhookPanel.hint': 'Tento webhook se používá výhradně pro admin oznámení (např. upozornění na verze). Je nezávislý na uživatelských webhooků a odesílá automaticky, pokud je nastavena URL.',
'admin.notifications.adminWebhookPanel.saved': 'URL admin webhooku uložena',
'admin.notifications.adminWebhookPanel.testSuccess': 'Testovací webhook byl úspěšně odeslán',
'admin.notifications.adminWebhookPanel.testFailed': 'Testovací webhook selhal',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook odesílá automaticky, pokud je nastavena URL',
'admin.notifications.adminNotificationsHint': 'Nastavte, které kanály doručují admin oznámení (např. upozornění na verze). Webhook odesílá automaticky, pokud je nastavena URL admin webhooku.',
'admin.tabs.notifications': 'Oznámení',
'notifications.versionAvailable.title': 'Dostupná aktualizace',
'notifications.versionAvailable.text': 'TREK {version} je nyní k dispozici.',
'notifications.versionAvailable.button': 'Zobrazit podrobnosti',
'notif.test.title': '[Test] Oznámení',
'notif.test.simple.text': 'Toto je jednoduché testovací oznámení.',
'notif.test.boolean.text': 'Přijmete toto testovací oznámení?',
'notif.test.navigate.text': 'Klikněte níže pro přechod na přehled.',
// Notifications
'notif.trip_invite.title': 'Pozvánka na výlet',
'notif.trip_invite.text': '{actor} vás pozval na {trip}',
'notif.booking_change.title': 'Rezervace aktualizována',
'notif.booking_change.text': '{actor} aktualizoval rezervaci v {trip}',
'notif.trip_reminder.title': 'Připomínka výletu',
'notif.trip_reminder.text': 'Váš výlet {trip} se blíží!',
'notif.vacay_invite.title': 'Pozvánka Vacay Fusion',
'notif.vacay_invite.text': '{actor} vás pozval ke spojení dovolenkových plánů',
'notif.photos_shared.title': 'Fotky sdíleny',
'notif.photos_shared.text': '{actor} sdílel {count} foto v {trip}',
'notif.collab_message.title': 'Nová zpráva',
'notif.collab_message.text': '{actor} poslal zprávu v {trip}',
'notif.packing_tagged.title': 'Přiřazení balení',
'notif.packing_tagged.text': '{actor} vás přiřadil k {category} v {trip}',
'notif.version_available.title': 'Nová verze dostupná',
'notif.version_available.text': 'TREK {version} je nyní dostupný',
'notif.action.view_trip': 'Zobrazit výlet',
'notif.action.view_collab': 'Zobrazit zprávy',
'notif.action.view_packing': 'Zobrazit balení',
'notif.action.view_photos': 'Zobrazit fotky',
'notif.action.view_vacay': 'Zobrazit Vacay',
'notif.action.view_admin': 'Jít do adminu',
'notif.action.view': 'Zobrazit',
'notif.action.accept': 'Přijmout',
'notif.action.decline': 'Odmítnout',
'notif.generic.title': 'Oznámení',
'notif.generic.text': 'Máte nové oznámení',
'notif.dev.unknown_event.title': '[DEV] Neznámá událost',
'notif.dev.unknown_event.text': 'Typ události "{event}" není registrován v EVENT_NOTIFICATION_CONFIG',
}
export default cs
+212 -5
View File
@@ -80,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',
@@ -99,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',
@@ -108,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)',
@@ -122,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...',
@@ -237,6 +250,15 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -377,7 +399,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',
@@ -459,7 +481,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',
@@ -483,8 +505,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',
@@ -589,7 +611,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.',
@@ -681,8 +704,10 @@ 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...',
@@ -732,6 +757,8 @@ 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...',
@@ -926,6 +953,32 @@ 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',
@@ -952,6 +1005,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',
@@ -1052,6 +1106,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',
@@ -1485,6 +1543,155 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'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
+234 -19
View File
@@ -80,7 +80,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'dashboard.sharedBy': 'Shared by {name}',
'dashboard.days': 'Days',
'dashboard.places': 'Places',
'dashboard.members': 'Buddies',
'dashboard.archive': 'Archive',
'dashboard.copyTrip': 'Copy',
'dashboard.copySuffix': 'copy',
'dashboard.restore': 'Restore',
'dashboard.archived': 'Archived',
'dashboard.status.ongoing': 'Ongoing',
@@ -99,6 +102,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'dashboard.toast.archiveError': 'Failed to archive trip',
'dashboard.toast.restored': 'Trip restored',
'dashboard.toast.restoreError': 'Failed to restore trip',
'dashboard.toast.copied': 'Trip copied!',
'dashboard.toast.copyError': 'Failed to copy trip',
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
'dashboard.editTrip': 'Edit Trip',
'dashboard.createTrip': 'Create New Trip',
@@ -108,6 +113,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'dashboard.tripDescriptionPlaceholder': 'What is this trip about?',
'dashboard.startDate': 'Start Date',
'dashboard.endDate': 'End Date',
'dashboard.dayCount': 'Number of Days',
'dashboard.dayCountHint': 'How many days to plan for when no travel dates are set.',
'dashboard.noDateHint': 'No date set — 7 default days will be created. You can change this anytime.',
'dashboard.coverImage': 'Cover Image',
'dashboard.addCoverImage': 'Add cover image (or drag & drop)',
@@ -122,6 +129,12 @@ const en: Record<string, string | { name: string; category: string }[]> = {
// Settings
'settings.title': 'Settings',
'settings.subtitle': 'Configure your personal settings',
'settings.tabs.display': 'Display',
'settings.tabs.map': 'Map',
'settings.tabs.notifications': 'Notifications',
'settings.tabs.integrations': 'Integrations',
'settings.tabs.account': 'Account',
'settings.tabs.about': 'About',
'settings.map': 'Map',
'settings.mapTemplate': 'Map Template',
'settings.mapTemplatePlaceholder.select': 'Select template...',
@@ -158,23 +171,44 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyCollabMessage': 'Chat messages (Collab)',
'settings.notifyPackingTagged': 'Packing list: assignments',
'settings.notifyWebhook': 'Webhook notifications',
'settings.notifyVersionAvailable': 'New version available',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.noChannels': 'No notification channels are configured. Ask an admin to set up email or webhook notifications.',
'settings.webhookUrl.label': 'Webhook URL',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Enter your Discord, Slack, or custom webhook URL to receive notifications.',
'settings.webhookUrl.save': 'Save',
'settings.webhookUrl.saved': 'Webhook URL saved',
'settings.webhookUrl.test': 'Test',
'settings.webhookUrl.testSuccess': 'Test webhook sent successfully',
'settings.webhookUrl.testFailed': 'Test webhook failed',
'admin.notifications.title': 'Notifications',
'admin.notifications.hint': 'Choose one notification channel. Only one can be active at a time.',
'admin.notifications.none': 'Disabled',
'admin.notifications.email': 'Email (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Notification Events',
'admin.notifications.eventsHint': 'Choose which events trigger notifications for all users.',
'admin.notifications.configureFirst': 'Configure the SMTP or webhook settings below first, then enable events.',
'admin.notifications.save': 'Save notification settings',
'admin.notifications.saved': 'Notification settings saved',
'admin.notifications.testWebhook': 'Send test webhook',
'admin.notifications.testWebhookSuccess': 'Test webhook sent successfully',
'admin.notifications.testWebhookFailed': 'Test webhook failed',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint': 'In-app notifications are always active and cannot be disabled globally.',
'admin.notifications.adminWebhookPanel.title': 'Admin Webhook',
'admin.notifications.adminWebhookPanel.hint': 'This webhook is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user webhooks and always fires when set.',
'admin.notifications.adminWebhookPanel.saved': 'Admin webhook URL saved',
'admin.notifications.adminWebhookPanel.testSuccess': 'Test webhook sent successfully',
'admin.notifications.adminWebhookPanel.testFailed': 'Test webhook failed',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook always fires when a URL is configured',
'admin.notifications.adminNotificationsHint': 'Configure which channels deliver admin-only notifications (e.g. version alerts).',
'admin.smtp.title': 'Email & Notifications',
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
'admin.smtp.testButton': 'Send test email',
'admin.webhook.hint': 'Send notifications to an external webhook (Discord, Slack, etc.).',
'admin.webhook.hint': 'Allow users to configure their own webhook URLs for notifications (Discord, Slack, etc.).',
'admin.smtp.testSuccess': 'Test email sent successfully',
'admin.smtp.testFailed': 'Test email failed',
'settings.notificationsDisabled': 'Notifications are not configured. Ask an admin to enable email or webhook notifications.',
@@ -237,6 +271,15 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.toast.deleted': 'Token deleted',
'settings.mcp.toast.deleteError': 'Failed to delete token',
'settings.account': 'Account',
'settings.about': 'About',
'settings.about.reportBug': 'Report a Bug',
'settings.about.reportBugHint': 'Found an issue? Let us know',
'settings.about.featureRequest': 'Feature Request',
'settings.about.featureRequestHint': 'Suggest a new feature',
'settings.about.wikiHint': 'Documentation & guides',
'settings.about.description': 'TREK is a self-hosted travel planner that helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place, on your own server.',
'settings.about.madeWith': 'Made with',
'settings.about.madeBy': 'by Maurice and a growing open-source community.',
'settings.username': 'Username',
'settings.email': 'Email',
'settings.role': 'Role',
@@ -377,7 +420,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.users': 'Users',
'admin.tabs.categories': 'Categories',
'admin.tabs.backup': 'Backup',
'admin.tabs.audit': 'Audit log',
'admin.tabs.notifications': 'Notifications',
'admin.tabs.audit': 'Audit',
'admin.stats.users': 'Users',
'admin.stats.trips': 'Trips',
'admin.stats.places': 'Places',
@@ -459,7 +503,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
// Packing Templates & Bag Tracking
'admin.bagTracking.title': 'Bag Tracking',
'admin.bagTracking.subtitle': 'Enable weight and bag assignment for packing items',
'admin.tabs.config': 'Configuration',
'admin.tabs.config': 'Personalization',
'admin.tabs.templates': 'Packing Templates',
'admin.packingTemplates.title': 'Packing Templates',
'admin.packingTemplates.subtitle': 'Create reusable packing lists for your trips',
@@ -483,8 +527,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Enable or disable features to customize your TREK experience.',
'admin.addons.catalog.packing.name': 'Packing',
'admin.addons.catalog.packing.description': 'Checklists to prepare your luggage for each trip',
'admin.addons.catalog.packing.name': 'Lists',
'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips',
'admin.addons.catalog.budget.name': 'Budget',
'admin.addons.catalog.budget.description': 'Track expenses and plan your trip budget',
'admin.addons.catalog.documents.name': 'Documents',
@@ -586,7 +630,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'vacay.subtitle': 'Plan and manage vacation days',
'vacay.settings': 'Settings',
'vacay.year': 'Year',
'vacay.addYear': 'Add year',
'vacay.addYear': 'Add next year',
'vacay.addPrevYear': 'Add previous year',
'vacay.removeYear': 'Remove year',
'vacay.removeYearConfirm': 'Remove {year}?',
'vacay.removeYearHint': 'All vacation entries and company holidays for this year will be permanently deleted.',
@@ -678,8 +723,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'atlas.unmark': 'Remove',
'atlas.confirmMark': 'Mark this country as visited?',
'atlas.confirmUnmark': 'Remove this country from your visited list?',
'atlas.confirmUnmarkRegion': 'Remove this region from your visited list?',
'atlas.markVisited': 'Mark as visited',
'atlas.markVisitedHint': 'Add this country to your visited list',
'atlas.markRegionVisitedHint': 'Add this region to your visited list',
'atlas.addToBucket': 'Add to bucket list',
'atlas.addPoi': 'Add place',
'atlas.searchCountry': 'Search a country...',
@@ -729,6 +776,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'Book',
'trip.tabs.packing': 'Packing List',
'trip.tabs.packingShort': 'Packing',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Files',
'trip.loading': 'Loading trip...',
@@ -923,6 +972,32 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'Link to day assignment',
'reservations.pickAssignment': 'Select an assignment from your plan...',
'reservations.noAssignment': 'No link (standalone)',
'reservations.price': 'Price',
'reservations.budgetCategory': 'Budget category',
'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation',
'reservations.budgetCategoryAuto': 'Auto (from booking type)',
'reservations.budgetHint': 'A budget entry will be created automatically when saving.',
'reservations.departureDate': 'Departure',
'reservations.arrivalDate': 'Arrival',
'reservations.departureTime': 'Dep. time',
'reservations.arrivalTime': 'Arr. time',
'reservations.pickupDate': 'Pickup',
'reservations.returnDate': 'Return',
'reservations.pickupTime': 'Pickup time',
'reservations.returnTime': 'Return time',
'reservations.endDate': 'End date',
'reservations.meta.departureTimezone': 'Dep. TZ',
'reservations.meta.arrivalTimezone': 'Arr. TZ',
'reservations.span.departure': 'Departure',
'reservations.span.arrival': 'Arrival',
'reservations.span.inTransit': 'In transit',
'reservations.span.pickup': 'Pickup',
'reservations.span.return': 'Return',
'reservations.span.active': 'Active',
'reservations.span.start': 'Start',
'reservations.span.end': 'End',
'reservations.span.ongoing': 'Ongoing',
'reservations.validation.endBeforeStart': 'End date/time must be after start date/time',
// Budget
'budget.title': 'Budget',
@@ -949,6 +1024,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'budget.totalBudget': 'Total Budget',
'budget.byCategory': 'By Category',
'budget.editTooltip': 'Click to edit',
'budget.linkedToReservation': 'Linked to a reservation — edit the name there',
'budget.confirm.deleteCategory': 'Are you sure you want to delete the category "{name}" with {count} entries?',
'budget.deleteCategory': 'Delete Category',
'budget.perPerson': 'Per Person',
@@ -1049,6 +1125,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'packing.template': 'Template',
'packing.templateApplied': '{count} items added from template',
'packing.templateError': 'Failed to apply template',
'packing.saveAsTemplate': 'Save as template',
'packing.templateName': 'Template name',
'packing.templateSaved': 'Packing list saved as template',
'packing.assignUser': 'Assign user',
'packing.bags': 'Bags',
'packing.noBag': 'Unassigned',
'packing.totalWeight': 'Total weight',
@@ -1320,11 +1400,12 @@ const en: Record<string, string | { name: string; category: string }[]> = {
// Photos / Immich
'memories.title': 'Photos',
'memories.notConnected': 'Immich not connected',
'memories.notConnectedHint': 'Connect your Immich instance in Settings to see your trip photos here.',
'memories.notConnected': '{provider_name} not connected',
'memories.notConnectedHint': 'Connect your {provider_name} instance in Settings to be able add photos to this trip.',
'memories.notConnectedMultipleHint': 'Connect any of these photo providers: {provider_names} in Settings to be able add photos to this trip.',
'memories.noDates': 'Add dates to your trip to load photos.',
'memories.noPhotos': 'No photos found',
'memories.noPhotosHint': 'No photos found in Immich for this trip\'s date range.',
'memories.noPhotosHint': 'No photos found in {provider_name} for this trip\'s date range.',
'memories.photosFound': 'photos',
'memories.fromOthers': 'from others',
'memories.sharePhotos': 'Share photos',
@@ -1332,23 +1413,31 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'memories.reviewTitle': 'Review your photos',
'memories.reviewHint': 'Click photos to exclude them from sharing.',
'memories.shareCount': 'Share {count} photos',
'memories.immichUrl': 'Immich Server URL',
'memories.immichApiKey': 'API Key',
//-------------------------
//todo section
'memories.providerUrl': 'Server URL',
'memories.providerApiKey': 'API Key',
'memories.providerUsername': 'Username',
'memories.providerPassword': 'Password',
'memories.testConnection': 'Test connection',
'memories.testFirst': 'Test connection first',
'memories.connected': 'Connected',
'memories.disconnected': 'Not connected',
'memories.connectionSuccess': 'Connected to Immich',
'memories.connectionError': 'Could not connect to Immich',
'memories.saved': 'Immich settings saved',
'memories.connectionSuccess': 'Connected to {provider_name}',
'memories.connectionError': 'Could not connect to {provider_name}',
'memories.saved': '{provider_name} settings saved',
'memories.saveError': 'Could not save {provider_name} settings',
//------------------------
'memories.addPhotos': 'Add photos',
'memories.linkAlbum': 'Link Album',
'memories.selectAlbum': 'Select Immich Album',
'memories.selectAlbum': 'Select {provider_name} Album',
'memories.selectAlbumMultiple': 'Select Album',
'memories.noAlbums': 'No albums found',
'memories.syncAlbum': 'Sync album',
'memories.unlinkAlbum': 'Unlink album',
'memories.photos': 'photos',
'memories.selectPhotos': 'Select photos from Immich',
'memories.selectPhotos': 'Select photos from {provider_name}',
'memories.selectPhotosMultiple': 'Select Photos',
'memories.selectHint': 'Tap photos to select them.',
'memories.selected': 'selected',
'memories.addSelected': 'Add {count} photos',
@@ -1363,6 +1452,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'memories.confirmShareTitle': 'Share with trip members?',
'memories.confirmShareHint': '{count} photos will be visible to all members of this trip. You can make individual photos private later.',
'memories.confirmShareButton': 'Share photos',
'memories.error.loadAlbums': 'Failed to load albums',
'memories.error.linkAlbum': 'Failed to link album',
'memories.error.unlinkAlbum': 'Failed to unlink album',
'memories.error.syncAlbum': 'Failed to sync album',
'memories.error.loadPhotos': 'Failed to load photos',
'memories.error.addPhotos': 'Failed to add photos',
'memories.error.removePhoto': 'Failed to remove photo',
'memories.error.toggleSharing': 'Failed to update sharing',
// Collab Addon
'collab.tabs.chat': 'Chat',
@@ -1482,6 +1579,124 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'perm.actionHint.packing_edit': 'Who can manage packing items and bags',
'perm.actionHint.collab_edit': 'Who can create notes, polls and send messages',
'perm.actionHint.share_manage': 'Who can create or delete public share links',
// Undo
'undo.button': 'Undo',
'undo.tooltip': 'Undo: {action}',
'undo.assignPlace': 'Place assigned to day',
'undo.removeAssignment': 'Place removed from day',
'undo.reorder': 'Places reordered',
'undo.optimize': 'Route optimized',
'undo.deletePlace': 'Place deleted',
'undo.moveDay': 'Place moved to another day',
'undo.lock': 'Place lock toggled',
'undo.importGpx': 'GPX import',
'undo.importGoogleList': 'Google Maps import',
'undo.addPlace': 'Place added',
'undo.done': 'Undone: {action}',
// Notifications
'notifications.title': 'Notifications',
'notifications.markAllRead': 'Mark all read',
'notifications.deleteAll': 'Delete all',
'notifications.showAll': 'Show all notifications',
'notifications.empty': 'No notifications',
'notifications.emptyDescription': "You're all caught up!",
'notifications.all': 'All',
'notifications.unreadOnly': 'Unread',
'notifications.markRead': 'Mark as read',
'notifications.markUnread': 'Mark as unread',
'notifications.delete': 'Delete',
'notifications.system': 'System',
// Notification test keys (dev only)
'notifications.versionAvailable.title': 'Update Available',
'notifications.versionAvailable.text': 'TREK {version} is now available.',
'notifications.versionAvailable.button': 'View Details',
'notifications.test.title': 'Test notification from {actor}',
'notifications.test.text': 'This is a simple test notification.',
'notifications.test.booleanTitle': '{actor} asks for your approval',
'notifications.test.booleanText': 'This is a test boolean notification. Choose an action below.',
'notifications.test.accept': 'Approve',
'notifications.test.decline': 'Decline',
'notifications.test.navigateTitle': 'Check something out',
'notifications.test.navigateText': 'This is a test navigate notification.',
'notifications.test.goThere': 'Go there',
'notifications.test.adminTitle': 'Admin broadcast',
'notifications.test.adminText': '{actor} sent a test notification to all admins.',
'notifications.test.tripTitle': '{actor} posted in your trip',
'notifications.test.tripText': 'Test notification for trip "{trip}".',
// Todo
'todo.subtab.packing': 'Packing List',
'todo.subtab.todo': 'To-Do',
'todo.completed': 'completed',
'todo.filter.all': 'All',
'todo.filter.open': 'Open',
'todo.filter.done': 'Done',
'todo.uncategorized': 'Uncategorized',
'todo.namePlaceholder': 'Task name',
'todo.descriptionPlaceholder': 'Description (optional)',
'todo.unassigned': 'Unassigned',
'todo.noCategory': 'No category',
'todo.hasDescription': 'Has description',
'todo.addItem': 'Add new task...',
'todo.newCategory': 'Category name',
'todo.addCategory': 'Add category',
'todo.newItem': 'New task',
'todo.empty': 'No tasks yet. Add a task to get started!',
'todo.filter.my': 'My Tasks',
'todo.filter.overdue': 'Overdue',
'todo.sidebar.tasks': 'Tasks',
'todo.sidebar.categories': 'Categories',
'todo.detail.title': 'Task',
'todo.detail.description': 'Description',
'todo.detail.category': 'Category',
'todo.detail.dueDate': 'Due date',
'todo.detail.assignedTo': 'Assigned to',
'todo.detail.delete': 'Delete',
'todo.detail.save': 'Save changes',
'todo.sortByPrio': 'Priority',
'todo.detail.priority': 'Priority',
'todo.detail.noPriority': 'None',
'todo.detail.create': 'Create task',
// Notifications — dev test events
'notif.test.title': '[Test] Notification',
'notif.test.simple.text': 'This is a simple test notification.',
'notif.test.boolean.text': 'Do you accept this test notification?',
'notif.test.navigate.text': 'Click below to navigate to the dashboard.',
// Notifications
'notif.trip_invite.title': 'Trip Invitation',
'notif.trip_invite.text': '{actor} invited you to {trip}',
'notif.booking_change.title': 'Booking Updated',
'notif.booking_change.text': '{actor} updated a booking in {trip}',
'notif.trip_reminder.title': 'Trip Reminder',
'notif.trip_reminder.text': 'Your trip {trip} is coming up soon!',
'notif.vacay_invite.title': 'Vacay Fusion Invite',
'notif.vacay_invite.text': '{actor} invited you to fuse vacation plans',
'notif.photos_shared.title': 'Photos Shared',
'notif.photos_shared.text': '{actor} shared {count} photo(s) in {trip}',
'notif.collab_message.title': 'New Message',
'notif.collab_message.text': '{actor} sent a message in {trip}',
'notif.packing_tagged.title': 'Packing Assignment',
'notif.packing_tagged.text': '{actor} assigned you to {category} in {trip}',
'notif.version_available.title': 'New Version Available',
'notif.version_available.text': 'TREK {version} is now available',
'notif.action.view_trip': 'View Trip',
'notif.action.view_collab': 'View Messages',
'notif.action.view_packing': 'View Packing',
'notif.action.view_photos': 'View Photos',
'notif.action.view_vacay': 'View Vacay',
'notif.action.view_admin': 'Go to Admin',
'notif.action.view': 'View',
'notif.action.accept': 'Accept',
'notif.action.decline': 'Decline',
'notif.generic.title': 'Notification',
'notif.generic.text': 'You have a new notification',
'notif.dev.unknown_event.title': '[DEV] Unknown Event',
'notif.dev.unknown_event.text': 'Event type "{event}" is not registered in EVENT_NOTIFICATION_CONFIG',
}
export default en
+216 -14
View File
@@ -81,7 +81,10 @@ const es: Record<string, string> = {
'dashboard.sharedBy': 'Compartido por {name}',
'dashboard.days': 'Días',
'dashboard.places': 'Lugares',
'dashboard.members': 'Compañeros de viaje',
'dashboard.archive': 'Archivar',
'dashboard.copyTrip': 'Copiar',
'dashboard.copySuffix': 'copia',
'dashboard.restore': 'Restaurar',
'dashboard.archived': 'Archivado',
'dashboard.status.ongoing': 'En curso',
@@ -100,6 +103,8 @@ const es: Record<string, string> = {
'dashboard.toast.archiveError': 'No se pudo archivar el viaje',
'dashboard.toast.restored': 'Viaje restaurado',
'dashboard.toast.restoreError': 'No se pudo restaurar el viaje',
'dashboard.toast.copied': '¡Viaje copiado!',
'dashboard.toast.copyError': 'No se pudo copiar el viaje',
'dashboard.confirm.delete': '¿Eliminar el viaje "{title}"? Todos los lugares y planes se borrarán permanentemente.',
'dashboard.editTrip': 'Editar viaje',
'dashboard.createTrip': 'Crear nuevo viaje',
@@ -109,6 +114,8 @@ const es: Record<string, string> = {
'dashboard.tripDescriptionPlaceholder': '¿De qué trata este viaje?',
'dashboard.startDate': 'Fecha de inicio',
'dashboard.endDate': 'Fecha de fin',
'dashboard.dayCount': 'Número de días',
'dashboard.dayCountHint': 'Cuántos días planificar cuando no se han establecido fechas de viaje.',
'dashboard.noDateHint': 'Sin fecha definida: se crearán 7 días por defecto. Puedes cambiarlo cuando quieras.',
'dashboard.coverImage': 'Imagen de portada',
'dashboard.addCoverImage': 'Añadir imagen de portada',
@@ -123,6 +130,12 @@ const es: Record<string, string> = {
// Settings
'settings.title': 'Ajustes',
'settings.subtitle': 'Configura tus ajustes personales',
'settings.tabs.display': 'Pantalla',
'settings.tabs.map': 'Mapa',
'settings.tabs.notifications': 'Notificaciones',
'settings.tabs.integrations': 'Integraciones',
'settings.tabs.account': 'Cuenta',
'settings.tabs.about': 'Acerca de',
'settings.map': 'Mapa',
'settings.mapTemplate': 'Plantilla del mapa',
'settings.mapTemplatePlaceholder.select': 'Seleccionar plantilla...',
@@ -238,6 +251,15 @@ const es: Record<string, string> = {
'settings.mcp.toast.deleted': 'Token eliminado',
'settings.mcp.toast.deleteError': 'Error al eliminar el token',
'settings.account': 'Cuenta',
'settings.about': 'Acerca de',
'settings.about.reportBug': 'Reportar un error',
'settings.about.reportBugHint': 'Encontraste un problema? Avísanos',
'settings.about.featureRequest': 'Solicitar función',
'settings.about.featureRequestHint': 'Sugiere una nueva función',
'settings.about.wikiHint': 'Documentación y guías',
'settings.about.description': 'TREK es un planificador de viajes autoalojado que te ayuda a organizar tus viajes desde la primera idea hasta el último recuerdo. Planificación diaria, presupuesto, listas de equipaje, fotos y mucho más — todo en un solo lugar, en tu propio servidor.',
'settings.about.madeWith': 'Hecho con',
'settings.about.madeBy': 'por Maurice y una creciente comunidad de código abierto.',
'settings.username': 'Usuario',
'settings.email': 'Correo',
'settings.role': 'Rol',
@@ -321,7 +343,7 @@ const es: Record<string, string> = {
'login.signingIn': 'Iniciando sesión…',
'login.signIn': 'Entrar',
'login.createAdmin': 'Crear cuenta de administrador',
'login.createAdminHint': 'Configura la primera cuenta administradora de NOMAD.',
'login.createAdminHint': 'Configura la primera cuenta administradora de TREK.',
'login.setNewPassword': 'Establecer nueva contraseña',
'login.setNewPasswordHint': 'Debe cambiar su contraseña antes de continuar.',
'login.createAccount': 'Crear cuenta',
@@ -375,7 +397,7 @@ const es: Record<string, string> = {
'admin.tabs.users': 'Usuarios',
'admin.tabs.categories': 'Categorías',
'admin.tabs.backup': 'Copia de seguridad',
'admin.tabs.audit': 'Registro de auditoría',
'admin.tabs.audit': 'Audit',
'admin.stats.users': 'Usuarios',
'admin.stats.trips': 'Viajes',
'admin.stats.places': 'Lugares',
@@ -454,7 +476,7 @@ const es: Record<string, string> = {
'admin.bagTracking.title': 'Seguimiento de equipaje',
'admin.bagTracking.subtitle': 'Activar peso y asignación de equipaje para artículos de la lista',
'admin.tabs.config': 'Configuración',
'admin.tabs.config': 'Personalización',
'admin.tabs.templates': 'Plantillas de equipaje',
'admin.packingTemplates.title': 'Plantillas de equipaje',
'admin.packingTemplates.subtitle': 'Crear listas de equipaje reutilizables para tus viajes',
@@ -477,7 +499,7 @@ const es: Record<string, string> = {
// Addons
'admin.tabs.addons': 'Complementos',
'admin.addons.title': 'Complementos',
'admin.addons.subtitle': 'Activa o desactiva funciones para personalizar tu experiencia en NOMAD.',
'admin.addons.subtitle': 'Activa o desactiva funciones para personalizar tu experiencia en TREK.',
'admin.addons.subtitleBefore': 'Activa o desactiva funciones para personalizar tu experiencia en ',
'admin.addons.subtitleAfter': '.',
'admin.addons.enabled': 'Activo',
@@ -493,7 +515,7 @@ const es: Record<string, string> = {
'admin.addons.noAddons': 'No hay complementos disponibles',
'admin.weather.title': 'Datos meteorológicos',
'admin.weather.badge': 'Desde el 24 de marzo de 2026',
'admin.weather.description': 'NOMAD utiliza Open-Meteo como fuente de datos meteorológicos. Open-Meteo es un servicio meteorológico gratuito y de código abierto: no requiere clave API.',
'admin.weather.description': 'TREK utiliza Open-Meteo como fuente de datos meteorológicos. Open-Meteo es un servicio meteorológico gratuito y de código abierto: no requiere clave API.',
'admin.weather.forecast': 'Pronóstico de 16 días',
'admin.weather.forecastDesc': 'Antes eran 5 días (OpenWeatherMap)',
'admin.weather.climate': 'Datos climáticos históricos',
@@ -545,11 +567,11 @@ const es: Record<string, string> = {
'admin.github.error': 'No se pudieron cargar las versiones',
'admin.github.by': 'por',
'admin.update.available': 'Actualización disponible',
'admin.update.text': 'NOMAD {version} está disponible. Estás usando {current}.',
'admin.update.text': 'TREK {version} está disponible. Estás usando {current}.',
'admin.update.button': 'Ver en GitHub',
'admin.update.install': 'Instalar actualización',
'admin.update.confirmTitle': '¿Instalar actualización?',
'admin.update.confirmText': 'NOMAD se actualizará de {current} a {version}. Después, el servidor se reiniciará automáticamente.',
'admin.update.confirmText': 'TREK se actualizará de {current} a {version}. Después, el servidor se reiniciará automáticamente.',
'admin.update.dataInfo': 'Todos tus datos (viajes, usuarios, claves API, subidas, Vacay, Atlas, presupuestos) se conservarán.',
'admin.update.warning': 'La app estará brevemente no disponible durante el reinicio.',
'admin.update.confirm': 'Actualizar ahora',
@@ -559,14 +581,15 @@ const es: Record<string, string> = {
'admin.update.backupHint': 'Recomendamos crear una copia de seguridad antes de actualizar.',
'admin.update.backupLink': 'Ir a Copia de seguridad',
'admin.update.howTo': 'Cómo actualizar',
'admin.update.dockerText': 'Tu instancia de NOMAD se ejecuta en Docker. Para actualizar a {version}, ejecuta los siguientes comandos en tu servidor:',
'admin.update.dockerText': 'Tu instancia de TREK se ejecuta en Docker. Para actualizar a {version}, ejecuta los siguientes comandos en tu servidor:',
'admin.update.reloadHint': 'Recarga la página en unos segundos.',
// Vacay addon
'vacay.subtitle': 'Planifica y gestiona días de vacaciones',
'vacay.settings': 'Ajustes',
'vacay.year': 'Año',
'vacay.addYear': 'Añadir año',
'vacay.addYear': 'Añadir año siguiente',
'vacay.addPrevYear': 'Añadir año anterior',
'vacay.removeYear': 'Eliminar año',
'vacay.removeYearConfirm': '¿Eliminar {year}?',
'vacay.removeYearHint': 'Todas las vacaciones y festivos de empresa de este año se borrarán permanentemente.',
@@ -613,9 +636,9 @@ const es: Record<string, string> = {
'vacay.carryOver': 'Arrastrar saldo',
'vacay.carryOverHint': 'Trasladar automáticamente los días restantes al año siguiente',
'vacay.sharing': 'Compartir',
'vacay.sharingHint': 'Comparte tu calendario de vacaciones con otros usuarios de NOMAD',
'vacay.sharingHint': 'Comparte tu calendario de vacaciones con otros usuarios de TREK',
'vacay.owner': 'Propietario',
'vacay.shareEmailPlaceholder': 'Correo electrónico del usuario de NOMAD',
'vacay.shareEmailPlaceholder': 'Correo electrónico del usuario de TREK',
'vacay.shareSuccess': 'Plan compartido correctamente',
'vacay.shareError': 'No se pudo compartir el plan',
'vacay.dissolve': 'Deshacer fusión',
@@ -627,7 +650,7 @@ const es: Record<string, string> = {
'vacay.noData': 'Sin datos',
'vacay.changeColor': 'Cambiar color',
'vacay.inviteUser': 'Invitar usuario',
'vacay.inviteHint': 'Invita a otro usuario de NOMAD a compartir un calendario combinado de vacaciones.',
'vacay.inviteHint': 'Invita a otro usuario de TREK a compartir un calendario combinado de vacaciones.',
'vacay.selectUser': 'Seleccionar usuario',
'vacay.sendInvite': 'Enviar invitación',
'vacay.inviteSent': 'Invitación enviada',
@@ -693,8 +716,10 @@ const es: Record<string, string> = {
'atlas.unmark': 'Eliminar',
'atlas.confirmMark': '¿Marcar este país como visitado?',
'atlas.confirmUnmark': '¿Eliminar este país de tu lista de visitados?',
'atlas.confirmUnmarkRegion': '¿Eliminar esta región de tu lista de visitados?',
'atlas.markVisited': 'Marcar como visitado',
'atlas.markVisitedHint': 'Añadir este país a tu lista de visitados',
'atlas.markRegionVisitedHint': 'Añadir esta región a tu lista de visitados',
'atlas.addToBucket': 'Añadir a lista de deseos',
'atlas.addPoi': 'Añadir lugar',
'atlas.searchCountry': 'Buscar un país...',
@@ -708,6 +733,8 @@ const es: Record<string, string> = {
'trip.tabs.reservationsShort': 'Reservas',
'trip.tabs.packing': 'Lista de equipaje',
'trip.tabs.packingShort': 'Equipaje',
'trip.tabs.lists': 'Listas',
'trip.tabs.listsShort': 'Listas',
'trip.tabs.budget': 'Presupuesto',
'trip.tabs.files': 'Archivos',
'trip.loading': 'Cargando viaje...',
@@ -886,6 +913,32 @@ const es: Record<string, string> = {
'reservations.linkAssignment': 'Vincular a una asignación del día',
'reservations.pickAssignment': 'Selecciona una asignación de tu plan...',
'reservations.noAssignment': 'Sin vínculo (independiente)',
'reservations.price': 'Precio',
'reservations.budgetCategory': 'Categoría de presupuesto',
'reservations.budgetCategoryPlaceholder': 'ej. Transporte, Alojamiento',
'reservations.budgetCategoryAuto': 'Automático (según tipo de reserva)',
'reservations.budgetHint': 'Se creará automáticamente una entrada presupuestaria al guardar.',
'reservations.departureDate': 'Salida',
'reservations.arrivalDate': 'Llegada',
'reservations.departureTime': 'Hora salida',
'reservations.arrivalTime': 'Hora llegada',
'reservations.pickupDate': 'Recogida',
'reservations.returnDate': 'Devolución',
'reservations.pickupTime': 'Hora recogida',
'reservations.returnTime': 'Hora devolución',
'reservations.endDate': 'Fecha fin',
'reservations.meta.departureTimezone': 'TZ salida',
'reservations.meta.arrivalTimezone': 'TZ llegada',
'reservations.span.departure': 'Salida',
'reservations.span.arrival': 'Llegada',
'reservations.span.inTransit': 'En tránsito',
'reservations.span.pickup': 'Recogida',
'reservations.span.return': 'Devolución',
'reservations.span.active': 'Activo',
'reservations.span.start': 'Inicio',
'reservations.span.end': 'Fin',
'reservations.span.ongoing': 'En curso',
'reservations.validation.endBeforeStart': 'La fecha/hora de fin debe ser posterior a la de inicio',
// Budget
'budget.title': 'Presupuesto',
@@ -1156,8 +1209,8 @@ const es: Record<string, string> = {
'admin.addons.catalog.memories.description': 'Comparte fotos de viaje a través de tu instancia de Immich',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'Protocolo de contexto de modelo para integración con asistentes de IA',
'admin.addons.catalog.packing.name': 'Equipaje',
'admin.addons.catalog.packing.description': 'Prepara tu equipaje con listas de comprobación para cada viaje',
'admin.addons.catalog.packing.name': 'Listas',
'admin.addons.catalog.packing.description': 'Listas de equipaje y tareas pendientes para tus viajes',
'admin.addons.catalog.budget.name': 'Presupuesto',
'admin.addons.catalog.budget.description': 'Controla los gastos y planifica el presupuesto del viaje',
'admin.addons.catalog.documents.name': 'Documentos',
@@ -1490,6 +1543,155 @@ const es: Record<string, string> = {
'perm.actionHint.packing_edit': 'Quién puede gestionar artículos de equipaje y bolsas',
'perm.actionHint.collab_edit': 'Quién puede crear notas, encuestas y enviar mensajes',
'perm.actionHint.share_manage': 'Quién puede crear o eliminar enlaces compartidos públicos',
// Undo
'undo.button': 'Deshacer',
'undo.tooltip': 'Deshacer: {action}',
'undo.assignPlace': 'Lugar asignado al día',
'undo.removeAssignment': 'Lugar eliminado del día',
'undo.reorder': 'Lugares reordenados',
'undo.optimize': 'Ruta optimizada',
'undo.deletePlace': 'Lugar eliminado',
'undo.moveDay': 'Lugar movido a otro día',
'undo.lock': 'Bloqueo de lugar activado/desactivado',
'undo.importGpx': 'Importación GPX',
'undo.importGoogleList': 'Importación de Google Maps',
// Notifications
'notifications.title': 'Notificaciones',
'notifications.markAllRead': 'Marcar todo como leído',
'notifications.deleteAll': 'Eliminar todo',
'notifications.showAll': 'Ver todas las notificaciones',
'notifications.empty': 'Sin notificaciones',
'notifications.emptyDescription': '¡Estás al día!',
'notifications.all': 'Todas',
'notifications.unreadOnly': 'No leídas',
'notifications.markRead': 'Marcar como leída',
'notifications.markUnread': 'Marcar como no leída',
'notifications.delete': 'Eliminar',
'notifications.system': 'Sistema',
'memories.error.loadAlbums': 'Error al cargar los álbumes',
'memories.error.linkAlbum': 'Error al vincular el álbum',
'memories.error.unlinkAlbum': 'Error al desvincular el álbum',
'memories.error.syncAlbum': 'Error al sincronizar el álbum',
'memories.error.loadPhotos': 'Error al cargar las fotos',
'memories.error.addPhotos': 'Error al agregar las fotos',
'memories.error.removePhoto': 'Error al eliminar la foto',
'memories.error.toggleSharing': 'Error al actualizar el uso compartido',
'undo.addPlace': 'Lugar agregado',
'undo.done': 'Deshecho: {action}',
'notifications.test.title': 'Notificación de prueba de {actor}',
'notifications.test.text': 'Esta es una notificación de prueba simple.',
'notifications.test.booleanTitle': '{actor} solicita tu aprobación',
'notifications.test.booleanText': 'Notificación de prueba booleana.',
'notifications.test.accept': 'Aprobar',
'notifications.test.decline': 'Rechazar',
'notifications.test.navigateTitle': 'Mira esto',
'notifications.test.navigateText': 'Notificación de prueba de navegación.',
'notifications.test.goThere': 'Ir allí',
'notifications.test.adminTitle': 'Difusión de administrador',
'notifications.test.adminText': '{actor} envió una notificación de prueba a todos los administradores.',
'notifications.test.tripTitle': '{actor} publicó en tu viaje',
'notifications.test.tripText': 'Notificación de prueba para el viaje "{trip}".',
// Todo
'todo.subtab.packing': 'Lista de equipaje',
'todo.subtab.todo': 'Por hacer',
'todo.completed': 'completado(s)',
'todo.filter.all': 'Todo',
'todo.filter.open': 'Abierto',
'todo.filter.done': 'Hecho',
'todo.uncategorized': 'Sin categoría',
'todo.namePlaceholder': 'Nombre de la tarea',
'todo.descriptionPlaceholder': 'Descripción (opcional)',
'todo.unassigned': 'Sin asignar',
'todo.noCategory': 'Sin categoría',
'todo.hasDescription': 'Con descripción',
'todo.addItem': 'Añadir nueva tarea...',
'todo.newCategory': 'Nombre de la categoría',
'todo.addCategory': 'Añadir categoría',
'todo.newItem': 'Nueva tarea',
'todo.empty': 'Aún no hay tareas. ¡Añade una tarea para empezar!',
'todo.filter.my': 'Mis tareas',
'todo.filter.overdue': 'Vencida',
'todo.sidebar.tasks': 'Tareas',
'todo.sidebar.categories': 'Categorías',
'todo.detail.title': 'Tarea',
'todo.detail.description': 'Descripción',
'todo.detail.category': 'Categoría',
'todo.detail.dueDate': 'Fecha límite',
'todo.detail.assignedTo': 'Asignado a',
'todo.detail.delete': 'Eliminar',
'todo.detail.save': 'Guardar cambios',
'todo.detail.create': 'Crear tarea',
'todo.detail.priority': 'Prioridad',
'todo.detail.noPriority': 'Ninguna',
'todo.sortByPrio': 'Prioridad',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'Nueva versión disponible',
'settings.notificationPreferences.noChannels': 'No hay canales de notificación configurados. Pide a un administrador que configure notificaciones por correo o webhook.',
'settings.webhookUrl.label': 'URL del webhook',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Introduce tu URL de webhook de Discord, Slack o personalizada para recibir notificaciones.',
'settings.webhookUrl.save': 'Guardar',
'settings.webhookUrl.saved': 'URL del webhook guardada',
'settings.webhookUrl.test': 'Probar',
'settings.webhookUrl.testSuccess': 'Webhook de prueba enviado correctamente',
'settings.webhookUrl.testFailed': 'Error al enviar el webhook de prueba',
'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': 'Las notificaciones in-app siempre están activas y no se pueden desactivar globalmente.',
'admin.notifications.adminWebhookPanel.title': 'Webhook de admin',
'admin.notifications.adminWebhookPanel.hint': 'Este webhook se usa exclusivamente para notificaciones de admin (ej. alertas de versión). Es independiente de los webhooks de usuario y se activa automáticamente si hay una URL configurada.',
'admin.notifications.adminWebhookPanel.saved': 'URL del webhook de admin guardada',
'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de prueba enviado correctamente',
'admin.notifications.adminWebhookPanel.testFailed': 'Error al enviar el webhook de prueba',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'El webhook de admin se activa automáticamente si hay una URL configurada',
'admin.notifications.adminNotificationsHint': 'Configura qué canales entregan notificaciones de admin (ej. alertas de versión). El webhook se activa automáticamente si hay una URL de webhook de admin configurada.',
'admin.tabs.notifications': 'Notificaciones',
'notifications.versionAvailable.title': 'Actualización disponible',
'notifications.versionAvailable.text': 'TREK {version} ya está disponible.',
'notifications.versionAvailable.button': 'Ver detalles',
'notif.test.title': '[Test] Notificación',
'notif.test.simple.text': 'Esta es una notificación de prueba simple.',
'notif.test.boolean.text': '¿Aceptas esta notificación de prueba?',
'notif.test.navigate.text': 'Haz clic abajo para ir al panel de control.',
// Notifications
'notif.trip_invite.title': 'Invitación al viaje',
'notif.trip_invite.text': '{actor} te invitó a {trip}',
'notif.booking_change.title': 'Reserva actualizada',
'notif.booking_change.text': '{actor} actualizó una reserva en {trip}',
'notif.trip_reminder.title': 'Recordatorio de viaje',
'notif.trip_reminder.text': '¡Tu viaje {trip} se acerca!',
'notif.vacay_invite.title': 'Invitación Vacay Fusion',
'notif.vacay_invite.text': '{actor} te invitó a fusionar planes de vacaciones',
'notif.photos_shared.title': 'Fotos compartidas',
'notif.photos_shared.text': '{actor} compartió {count} foto(s) en {trip}',
'notif.collab_message.title': 'Nuevo mensaje',
'notif.collab_message.text': '{actor} envió un mensaje en {trip}',
'notif.packing_tagged.title': 'Asignación de equipaje',
'notif.packing_tagged.text': '{actor} te asignó a {category} en {trip}',
'notif.version_available.title': 'Nueva versión disponible',
'notif.version_available.text': 'TREK {version} ya está disponible',
'notif.action.view_trip': 'Ver viaje',
'notif.action.view_collab': 'Ver mensajes',
'notif.action.view_packing': 'Ver equipaje',
'notif.action.view_photos': 'Ver fotos',
'notif.action.view_vacay': 'Ver Vacay',
'notif.action.view_admin': 'Ir al admin',
'notif.action.view': 'Ver',
'notif.action.accept': 'Aceptar',
'notif.action.decline': 'Rechazar',
'notif.generic.title': 'Notificación',
'notif.generic.text': 'Tienes una nueva notificación',
'notif.dev.unknown_event.title': '[DEV] Evento desconocido',
'notif.dev.unknown_event.text': 'El tipo de evento "{event}" no está registrado en EVENT_NOTIFICATION_CONFIG',
}
export default es
+207 -5
View File
@@ -80,7 +80,10 @@ const fr: Record<string, string> = {
'dashboard.sharedBy': 'Partagé par {name}',
'dashboard.days': 'Jours',
'dashboard.places': 'Lieux',
'dashboard.members': 'Compagnons de voyage',
'dashboard.archive': 'Archiver',
'dashboard.copyTrip': 'Copier',
'dashboard.copySuffix': 'copie',
'dashboard.restore': 'Restaurer',
'dashboard.archived': 'Archivé',
'dashboard.status.ongoing': 'En cours',
@@ -99,6 +102,8 @@ const fr: Record<string, string> = {
'dashboard.toast.archiveError': "Impossible d'archiver le voyage",
'dashboard.toast.restored': 'Voyage restauré',
'dashboard.toast.restoreError': 'Impossible de restaurer le voyage',
'dashboard.toast.copied': 'Voyage copié !',
'dashboard.toast.copyError': 'Impossible de copier le voyage',
'dashboard.confirm.delete': 'Supprimer le voyage « {title} » ? Tous les lieux et plans seront définitivement supprimés.',
'dashboard.editTrip': 'Modifier le voyage',
'dashboard.createTrip': 'Créer un nouveau voyage',
@@ -108,6 +113,8 @@ const fr: Record<string, string> = {
'dashboard.tripDescriptionPlaceholder': 'De quoi parle ce voyage ?',
'dashboard.startDate': 'Date de début',
'dashboard.endDate': 'Date de fin',
'dashboard.dayCount': 'Nombre de jours',
'dashboard.dayCountHint': 'Nombre de jours à planifier lorsqu\'aucune date de voyage n\'est définie.',
'dashboard.noDateHint': 'Aucune date définie — 7 jours par défaut seront créés. Vous pouvez modifier cela à tout moment.',
'dashboard.coverImage': 'Image de couverture',
'dashboard.addCoverImage': 'Ajouter une image de couverture',
@@ -122,6 +129,12 @@ const fr: Record<string, string> = {
// Settings
'settings.title': 'Paramètres',
'settings.subtitle': 'Configurez vos paramètres personnels',
'settings.tabs.display': 'Affichage',
'settings.tabs.map': 'Carte',
'settings.tabs.notifications': 'Notifications',
'settings.tabs.integrations': 'Intégrations',
'settings.tabs.account': 'Compte',
'settings.tabs.about': 'À propos',
'settings.map': 'Carte',
'settings.mapTemplate': 'Modèle de carte',
'settings.mapTemplatePlaceholder.select': 'Sélectionner un modèle…',
@@ -237,6 +250,15 @@ const fr: Record<string, string> = {
'settings.mcp.toast.deleted': 'Token supprimé',
'settings.mcp.toast.deleteError': 'Impossible de supprimer le token',
'settings.account': 'Compte',
'settings.about': 'À propos',
'settings.about.reportBug': 'Signaler un bug',
'settings.about.reportBugHint': 'Un problème ? Faites-le nous savoir',
'settings.about.featureRequest': 'Proposer une fonctionnalité',
'settings.about.featureRequestHint': 'Suggérez une nouvelle fonctionnalité',
'settings.about.wikiHint': 'Documentation et guides',
'settings.about.description': 'TREK est un planificateur de voyage auto-hébergé qui vous aide à organiser vos voyages de la première idée au dernier souvenir. Planification journalière, budget, listes de bagages, photos et bien plus — le tout au même endroit, sur votre propre serveur.',
'settings.about.madeWith': 'Fait avec',
'settings.about.madeBy': 'par Maurice et une communauté open-source grandissante.',
'settings.username': 'Nom d\'utilisateur',
'settings.email': 'E-mail',
'settings.role': 'Rôle',
@@ -457,7 +479,7 @@ const fr: Record<string, string> = {
'admin.bagTracking.title': 'Suivi des bagages',
'admin.bagTracking.subtitle': 'Activer le poids et l\'attribution de bagages pour les articles',
'admin.tabs.config': 'Configuration',
'admin.tabs.config': 'Personnalisation',
'admin.tabs.templates': 'Modèles de bagages',
'admin.packingTemplates.title': 'Modèles de bagages',
'admin.packingTemplates.subtitle': 'Créer des listes de bagages réutilisables pour vos voyages',
@@ -485,8 +507,8 @@ const fr: Record<string, string> = {
'admin.addons.catalog.memories.description': 'Partagez vos photos de voyage via votre instance Immich',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'Protocole de contexte de modèle pour l\'intégration d\'assistants IA',
'admin.addons.catalog.packing.name': 'Bagages',
'admin.addons.catalog.packing.description': 'Listes de contrôle pour préparer vos bagages pour chaque voyage',
'admin.addons.catalog.packing.name': 'Listes',
'admin.addons.catalog.packing.description': 'Listes de bagages et tâches à faire pour vos voyages',
'admin.addons.catalog.budget.name': 'Budget',
'admin.addons.catalog.budget.description': 'Suivez les dépenses et planifiez votre budget de voyage',
'admin.addons.catalog.documents.name': 'Documents',
@@ -522,7 +544,7 @@ const fr: Record<string, string> = {
'admin.weather.requestsDesc': 'Gratuit, aucune clé API requise',
'admin.weather.locationHint': 'La météo est basée sur le premier lieu avec des coordonnées de chaque jour. Si aucun lieu n\'est attribué à un jour, un lieu de la liste est utilisé comme référence.',
'admin.tabs.audit': 'Journal d\'audit',
'admin.tabs.audit': 'Audit',
'admin.audit.subtitle': 'Événements sensibles de sécurité et d\'administration (sauvegardes, utilisateurs, 2FA, paramètres).',
'admin.audit.empty': 'Aucune entrée d\'audit.',
@@ -588,7 +610,8 @@ const fr: Record<string, string> = {
'vacay.subtitle': 'Planifiez et gérez vos jours de congés',
'vacay.settings': 'Paramètres',
'vacay.year': 'Année',
'vacay.addYear': 'Ajouter une année',
'vacay.addYear': 'Ajouter l\'année suivante',
'vacay.addPrevYear': 'Ajouter l\'année précédente',
'vacay.removeYear': 'Supprimer l\'année',
'vacay.removeYearConfirm': 'Supprimer {year} ?',
'vacay.removeYearHint': 'Toutes les entrées de vacances et jours fériés d\'entreprise de cette année seront définitivement supprimés.',
@@ -716,8 +739,10 @@ const fr: Record<string, string> = {
'atlas.unmark': 'Retirer',
'atlas.confirmMark': 'Marquer ce pays comme visité ?',
'atlas.confirmUnmark': 'Retirer ce pays de votre liste ?',
'atlas.confirmUnmarkRegion': 'Retirer cette région de votre liste ?',
'atlas.markVisited': 'Marquer comme visité',
'atlas.markVisitedHint': 'Ajouter ce pays à votre liste de visités',
'atlas.markRegionVisitedHint': 'Ajouter cette région à votre liste de visités',
'atlas.addToBucket': 'Ajouter à la bucket list',
'atlas.addPoi': 'Ajouter un lieu',
'atlas.searchCountry': 'Rechercher un pays…',
@@ -731,6 +756,8 @@ const fr: Record<string, string> = {
'trip.tabs.reservationsShort': 'Résa',
'trip.tabs.packing': 'Liste de bagages',
'trip.tabs.packingShort': 'Bagages',
'trip.tabs.lists': 'Listes',
'trip.tabs.listsShort': 'Listes',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Fichiers',
'trip.loading': 'Chargement du voyage…',
@@ -925,6 +952,32 @@ const fr: Record<string, string> = {
'reservations.linkAssignment': 'Lier à l\'affectation du jour',
'reservations.pickAssignment': 'Sélectionnez une affectation de votre plan…',
'reservations.noAssignment': 'Aucun lien (autonome)',
'reservations.price': 'Prix',
'reservations.budgetCategory': 'Catégorie budgétaire',
'reservations.budgetCategoryPlaceholder': 'ex. Transport, Hébergement',
'reservations.budgetCategoryAuto': 'Auto (selon le type de réservation)',
'reservations.budgetHint': 'Une entrée budgétaire sera créée automatiquement lors de l\'enregistrement.',
'reservations.departureDate': 'Départ',
'reservations.arrivalDate': 'Arrivée',
'reservations.departureTime': 'Heure dép.',
'reservations.arrivalTime': 'Heure arr.',
'reservations.pickupDate': 'Prise en charge',
'reservations.returnDate': 'Restitution',
'reservations.pickupTime': 'Heure prise en charge',
'reservations.returnTime': 'Heure restitution',
'reservations.endDate': 'Date de fin',
'reservations.meta.departureTimezone': 'TZ dép.',
'reservations.meta.arrivalTimezone': 'TZ arr.',
'reservations.span.departure': 'Départ',
'reservations.span.arrival': 'Arrivée',
'reservations.span.inTransit': 'En transit',
'reservations.span.pickup': 'Prise en charge',
'reservations.span.return': 'Restitution',
'reservations.span.active': 'Actif',
'reservations.span.start': 'Début',
'reservations.span.end': 'Fin',
'reservations.span.ongoing': 'En cours',
'reservations.validation.endBeforeStart': 'La date/heure de fin doit être postérieure à la date/heure de début',
// Budget
'budget.title': 'Budget',
@@ -1484,6 +1537,155 @@ const fr: Record<string, string> = {
'perm.actionHint.packing_edit': 'Qui peut gérer les articles de bagages et les sacs',
'perm.actionHint.collab_edit': 'Qui peut créer des notes, des sondages et envoyer des messages',
'perm.actionHint.share_manage': 'Qui peut créer ou supprimer des liens de partage publics',
// Undo
'undo.button': 'Annuler',
'undo.tooltip': 'Annuler : {action}',
'undo.assignPlace': 'Lieu ajouté au jour',
'undo.removeAssignment': 'Lieu retiré du jour',
'undo.reorder': 'Lieux réorganisés',
'undo.optimize': 'Itinéraire optimisé',
'undo.deletePlace': 'Lieu supprimé',
'undo.moveDay': 'Lieu déplacé vers un autre jour',
'undo.lock': 'Verrouillage du lieu modifié',
'undo.importGpx': 'Import GPX',
'undo.importGoogleList': 'Import Google Maps',
// Notifications
'notifications.title': 'Notifications',
'notifications.markAllRead': 'Tout marquer comme lu',
'notifications.deleteAll': 'Tout supprimer',
'notifications.showAll': 'Voir toutes les notifications',
'notifications.empty': 'Aucune notification',
'notifications.emptyDescription': 'Vous êtes à jour !',
'notifications.all': 'Toutes',
'notifications.unreadOnly': 'Non lues',
'notifications.markRead': 'Marquer comme lu',
'notifications.markUnread': 'Marquer comme non lu',
'notifications.delete': 'Supprimer',
'notifications.system': 'Système',
'memories.error.loadAlbums': 'Impossible de charger les albums',
'memories.error.linkAlbum': 'Impossible de lier l\'album',
'memories.error.unlinkAlbum': 'Impossible de dissocier l\'album',
'memories.error.syncAlbum': 'Impossible de synchroniser l\'album',
'memories.error.loadPhotos': 'Impossible de charger les photos',
'memories.error.addPhotos': 'Impossible d\'ajouter les photos',
'memories.error.removePhoto': 'Impossible de supprimer la photo',
'memories.error.toggleSharing': 'Impossible de mettre à jour le partage',
'undo.addPlace': 'Lieu ajouté',
'undo.done': 'Annulé : {action}',
'notifications.test.title': 'Notification test de {actor}',
'notifications.test.text': 'Ceci est une simple notification de test.',
'notifications.test.booleanTitle': '{actor} demande votre approbation',
'notifications.test.booleanText': 'Notification de test booléenne.',
'notifications.test.accept': 'Approuver',
'notifications.test.decline': 'Refuser',
'notifications.test.navigateTitle': 'Allez voir quelque chose',
'notifications.test.navigateText': 'Notification de test de navigation.',
'notifications.test.goThere': 'Y aller',
'notifications.test.adminTitle': 'Diffusion admin',
'notifications.test.adminText': '{actor} a envoyé une notification de test à tous les admins.',
'notifications.test.tripTitle': '{actor} a publié dans votre voyage',
'notifications.test.tripText': 'Notification de test pour le voyage "{trip}".',
// Todo
'todo.subtab.packing': 'Liste de bagages',
'todo.subtab.todo': 'À faire',
'todo.completed': 'terminé(s)',
'todo.filter.all': 'Tout',
'todo.filter.open': 'En cours',
'todo.filter.done': 'Terminé',
'todo.uncategorized': 'Sans catégorie',
'todo.namePlaceholder': 'Nom de la tâche',
'todo.descriptionPlaceholder': 'Description (facultative)',
'todo.unassigned': 'Non assigné',
'todo.noCategory': 'Aucune catégorie',
'todo.hasDescription': 'Avec description',
'todo.addItem': 'Ajouter une tâche...',
'todo.newCategory': 'Nom de la catégorie',
'todo.addCategory': 'Ajouter une catégorie',
'todo.newItem': 'Nouvelle tâche',
'todo.empty': 'Aucune tâche pour l\'instant. Ajoutez une tâche pour commencer !',
'todo.filter.my': 'Mes tâches',
'todo.filter.overdue': 'En retard',
'todo.sidebar.tasks': 'Tâches',
'todo.sidebar.categories': 'Catégories',
'todo.detail.title': 'Tâche',
'todo.detail.description': 'Description',
'todo.detail.category': 'Catégorie',
'todo.detail.dueDate': 'Date d\'échéance',
'todo.detail.assignedTo': 'Assigné à',
'todo.detail.delete': 'Supprimer',
'todo.detail.save': 'Enregistrer les modifications',
'todo.detail.create': 'Créer la tâche',
'todo.detail.priority': 'Priorité',
'todo.detail.noPriority': 'Aucune',
'todo.sortByPrio': 'Priorité',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'Nouvelle version disponible',
'settings.notificationPreferences.noChannels': 'Aucun canal de notification n\'est configuré. Demandez à un administrateur de configurer les notifications par e-mail ou webhook.',
'settings.webhookUrl.label': 'URL du webhook',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Entrez votre URL de webhook Discord, Slack ou personnalisée pour recevoir des notifications.',
'settings.webhookUrl.save': 'Enregistrer',
'settings.webhookUrl.saved': 'URL du webhook enregistrée',
'settings.webhookUrl.test': 'Tester',
'settings.webhookUrl.testSuccess': 'Webhook de test envoyé avec succès',
'settings.webhookUrl.testFailed': 'Échec du webhook de test',
'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': 'Les notifications in-app sont toujours actives et ne peuvent pas être désactivées globalement.',
'admin.notifications.adminWebhookPanel.title': 'Webhook admin',
'admin.notifications.adminWebhookPanel.hint': 'Ce webhook est utilisé exclusivement pour les notifications admin (ex. alertes de version). Il est séparé des webhooks utilisateur et s\'active automatiquement si une URL est configurée.',
'admin.notifications.adminWebhookPanel.saved': 'URL du webhook admin enregistrée',
'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de test envoyé avec succès',
'admin.notifications.adminWebhookPanel.testFailed': 'Échec du webhook de test',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Le webhook admin s\'active automatiquement si une URL est configurée',
'admin.notifications.adminNotificationsHint': 'Configurez quels canaux envoient les notifications admin (ex. alertes de version). Le webhook s\'active automatiquement si une URL webhook admin est définie.',
'admin.tabs.notifications': 'Notifications',
'notifications.versionAvailable.title': 'Mise à jour disponible',
'notifications.versionAvailable.text': 'TREK {version} est maintenant disponible.',
'notifications.versionAvailable.button': 'Voir les détails',
'notif.test.title': '[Test] Notification',
'notif.test.simple.text': 'Ceci est une simple notification de test.',
'notif.test.boolean.text': 'Acceptez-vous cette notification de test ?',
'notif.test.navigate.text': 'Cliquez ci-dessous pour accéder au tableau de bord.',
// Notifications
'notif.trip_invite.title': 'Invitation au voyage',
'notif.trip_invite.text': '{actor} vous a invité à {trip}',
'notif.booking_change.title': 'Réservation mise à jour',
'notif.booking_change.text': '{actor} a mis à jour une réservation dans {trip}',
'notif.trip_reminder.title': 'Rappel de voyage',
'notif.trip_reminder.text': 'Votre voyage {trip} approche !',
'notif.vacay_invite.title': 'Invitation Vacay Fusion',
'notif.vacay_invite.text': '{actor} vous invite à fusionner les plans de vacances',
'notif.photos_shared.title': 'Photos partagées',
'notif.photos_shared.text': '{actor} a partagé {count} photo(s) dans {trip}',
'notif.collab_message.title': 'Nouveau message',
'notif.collab_message.text': '{actor} a envoyé un message dans {trip}',
'notif.packing_tagged.title': 'Affectation bagages',
'notif.packing_tagged.text': '{actor} vous a assigné à {category} dans {trip}',
'notif.version_available.title': 'Nouvelle version disponible',
'notif.version_available.text': 'TREK {version} est maintenant disponible',
'notif.action.view_trip': 'Voir le voyage',
'notif.action.view_collab': 'Voir les messages',
'notif.action.view_packing': 'Voir les bagages',
'notif.action.view_photos': 'Voir les photos',
'notif.action.view_vacay': 'Voir Vacay',
'notif.action.view_admin': 'Aller à l\'admin',
'notif.action.view': 'Voir',
'notif.action.accept': 'Accepter',
'notif.action.decline': 'Refuser',
'notif.generic.title': 'Notification',
'notif.generic.text': 'Vous avez une nouvelle notification',
'notif.dev.unknown_event.title': '[DEV] Événement inconnu',
'notif.dev.unknown_event.text': 'Le type d\'événement "{event}" n\'est pas enregistré dans EVENT_NOTIFICATION_CONFIG',
}
export default fr
+207 -5
View File
@@ -80,7 +80,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'dashboard.sharedBy': 'Megosztotta: {name}',
'dashboard.days': 'nap',
'dashboard.places': 'hely',
'dashboard.members': 'Útitársak',
'dashboard.archive': 'Archiválás',
'dashboard.copyTrip': 'Másolás',
'dashboard.copySuffix': 'másolat',
'dashboard.restore': 'Visszaállítás',
'dashboard.archived': 'Archivált',
'dashboard.status.ongoing': 'Folyamatban',
@@ -99,6 +102,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'dashboard.toast.archiveError': 'Nem sikerült archiválni',
'dashboard.toast.restored': 'Utazás visszaállítva',
'dashboard.toast.restoreError': 'Nem sikerült visszaállítani',
'dashboard.toast.copied': 'Utazás másolva!',
'dashboard.toast.copyError': 'Nem sikerült másolni az utazást',
'dashboard.confirm.delete': '"{title}" utazás törlése? Minden hely és terv véglegesen törlődik.',
'dashboard.editTrip': 'Utazás szerkesztése',
'dashboard.createTrip': 'Új utazás létrehozása',
@@ -108,6 +113,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'dashboard.tripDescriptionPlaceholder': 'Miről szól ez az utazás?',
'dashboard.startDate': 'Kezdő dátum',
'dashboard.endDate': 'Záró dátum',
'dashboard.dayCount': 'Napok száma',
'dashboard.dayCountHint': 'Hány napot tervezzen, ha nincsenek utazási dátumok megadva.',
'dashboard.noDateHint': 'Nincs dátum megadva — 7 alapértelmezett nap jön létre. Ezt bármikor módosíthatod.',
'dashboard.coverImage': 'Borítókép',
'dashboard.addCoverImage': 'Borítókép hozzáadása',
@@ -122,6 +129,12 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
// Beállítások
'settings.title': 'Beállítások',
'settings.subtitle': 'Személyes beállítások konfigurálása',
'settings.tabs.display': 'Megjelenés',
'settings.tabs.map': 'Térkép',
'settings.tabs.notifications': 'Értesítések',
'settings.tabs.integrations': 'Integrációk',
'settings.tabs.account': 'Fiók',
'settings.tabs.about': 'Névjegy',
'settings.map': 'Térkép',
'settings.mapTemplate': 'Térkép sablon',
'settings.mapTemplatePlaceholder.select': 'Sablon kiválasztása...',
@@ -189,6 +202,15 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.toast.deleted': 'Token törölve',
'settings.mcp.toast.deleteError': 'Nem sikerült törölni a tokent',
'settings.account': 'Fiók',
'settings.about': 'Névjegy',
'settings.about.reportBug': 'Hiba bejelentése',
'settings.about.reportBugHint': 'Problémát találtál? Jelezd nekünk',
'settings.about.featureRequest': 'Funkció javaslat',
'settings.about.featureRequestHint': 'Javasolj egy új funkciót',
'settings.about.wikiHint': 'Dokumentáció és útmutatók',
'settings.about.description': 'A TREK egy saját szerveren üzemeltetett útitervező, amely segít az utazásaid megszervezésében az első ötlettől az utolsó emlékig. Napi tervezés, költségvetés, csomagolási listák, fotók és még sok más — minden egy helyen, a saját szervereden.',
'settings.about.madeWith': 'Készítve',
'settings.about.madeBy': 'Maurice és egy növekvő nyílt forráskódú közösség által.',
'settings.username': 'Felhasználónév',
'settings.email': 'E-mail',
'settings.role': 'Szerepkör',
@@ -458,7 +480,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
// Csomagolási sablonok és poggyászkövetés
'admin.bagTracking.title': 'Poggyászkövetés',
'admin.bagTracking.subtitle': 'Súly- és táskahozzárendelés engedélyezése csomagolási tételeknél',
'admin.tabs.config': 'Konfiguráció',
'admin.tabs.config': 'Személyre szabás',
'admin.tabs.templates': 'Csomagolási sablonok',
'admin.packingTemplates.title': 'Csomagolási sablonok',
'admin.packingTemplates.subtitle': 'Újrafelhasználható csomagolási listák létrehozása utazásaidhoz',
@@ -482,8 +504,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.addons': 'Bővítmények',
'admin.addons.title': 'Bővítmények',
'admin.addons.subtitle': 'Funkciók engedélyezése vagy letiltása a TREK testreszabásához.',
'admin.addons.catalog.packing.name': 'Csomagolás',
'admin.addons.catalog.packing.description': 'Ellenőrzőlisták a poggyász előkészítéséhez minden utazáshoz',
'admin.addons.catalog.packing.name': 'Listák',
'admin.addons.catalog.packing.description': 'Csomagolási listák és teendők az utazásaidhoz',
'admin.addons.catalog.budget.name': 'Költségvetés',
'admin.addons.catalog.budget.description': 'Kiadások nyomon követése és az utazási költségvetés tervezése',
'admin.addons.catalog.documents.name': 'Dokumentumok',
@@ -523,7 +545,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'admin.weather.requestsDesc': 'Ingyenes, nincs szükség API kulcsra',
'admin.weather.locationHint': 'Az időjárás az adott nap első koordinátákkal rendelkező helye alapján készül. Ha nincs hely hozzárendelve a naphoz, a helylista bármelyik helye szolgál referenciául.',
'admin.tabs.audit': 'Auditnapló',
'admin.tabs.audit': 'Audit',
'admin.audit.subtitle': 'Biztonsági és adminisztrációs események (mentések, felhasználók, 2FA, beállítások).',
'admin.audit.empty': 'Még nincsenek audit bejegyzések.',
@@ -589,7 +611,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'vacay.subtitle': 'Szabadságnapok tervezése és kezelése',
'vacay.settings': 'Beállítások',
'vacay.year': 'Év',
'vacay.addYear': 'Év hozzáadása',
'vacay.addYear': 'Következő év hozzáadása',
'vacay.addPrevYear': 'Előző év hozzáadása',
'vacay.removeYear': 'Év eltávolítása',
'vacay.removeYearConfirm': '{year} eltávolítása?',
'vacay.removeYearHint': 'Az adott év összes szabadság-bejegyzése és céges szabadnapja véglegesen törlődik.',
@@ -681,8 +704,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'atlas.unmark': 'Eltávolítás',
'atlas.confirmMark': 'Megjelölöd ezt az országot meglátogatottként?',
'atlas.confirmUnmark': 'Eltávolítod ezt az országot a meglátogatottak listájáról?',
'atlas.confirmUnmarkRegion': 'Eltávolítod ezt a régiót a meglátogatottak listájáról?',
'atlas.markVisited': 'Megjelölés meglátogatottként',
'atlas.markVisitedHint': 'Ország hozzáadása a meglátogatottak listájához',
'atlas.markRegionVisitedHint': 'Régió hozzáadása a meglátogatottak listájához',
'atlas.addToBucket': 'Hozzáadás a bakancslistához',
'atlas.addPoi': 'Hely hozzáadása',
'atlas.searchCountry': 'Ország keresése...',
@@ -732,6 +757,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'Foglalás',
'trip.tabs.packing': 'Csomagolási lista',
'trip.tabs.packingShort': 'Csomag',
'trip.tabs.lists': 'Listák',
'trip.tabs.listsShort': 'Listák',
'trip.tabs.budget': 'Költségvetés',
'trip.tabs.files': 'Fájlok',
'trip.loading': 'Utazás betöltése...',
@@ -926,6 +953,32 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'Összekapcsolás napi tervvel',
'reservations.pickAssignment': 'Válassz hozzárendelést a tervedből...',
'reservations.noAssignment': 'Nincs összekapcsolás (önálló)',
'reservations.price': 'Ár',
'reservations.budgetCategory': 'Költségvetési kategória',
'reservations.budgetCategoryPlaceholder': 'pl. Közlekedés, Szállás',
'reservations.budgetCategoryAuto': 'Automatikus (foglalás típusa alapján)',
'reservations.budgetHint': 'Mentéskor automatikusan létrejön egy költségvetési tétel.',
'reservations.departureDate': 'Indulás',
'reservations.arrivalDate': 'Érkezés',
'reservations.departureTime': 'Indulási idő',
'reservations.arrivalTime': 'Érkezési idő',
'reservations.pickupDate': 'Felvétel',
'reservations.returnDate': 'Visszaadás',
'reservations.pickupTime': 'Felvétel ideje',
'reservations.returnTime': 'Visszaadás ideje',
'reservations.endDate': 'Befejezés dátuma',
'reservations.meta.departureTimezone': 'TZ indulás',
'reservations.meta.arrivalTimezone': 'TZ érkezés',
'reservations.span.departure': 'Indulás',
'reservations.span.arrival': 'Érkezés',
'reservations.span.inTransit': 'Úton',
'reservations.span.pickup': 'Felvétel',
'reservations.span.return': 'Visszaadás',
'reservations.span.active': 'Aktív',
'reservations.span.start': 'Kezdés',
'reservations.span.end': 'Vége',
'reservations.span.ongoing': 'Folyamatban',
'reservations.validation.endBeforeStart': 'A befejezés dátuma/időpontja a kezdés utáni kell legyen',
// Költségvetés
'budget.title': 'Költségvetés',
@@ -1485,6 +1538,155 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'perm.actionHint.packing_edit': 'Ki kezelheti a csomagolási tételeket és táskákat',
'perm.actionHint.collab_edit': 'Ki hozhat létre jegyzeteket, szavazásokat és küldhet üzeneteket',
'perm.actionHint.share_manage': 'Ki hozhat létre vagy törölhet nyilvános megosztási linkeket',
// Undo
'undo.button': 'Visszavonás',
'undo.tooltip': 'Visszavonás: {action}',
'undo.assignPlace': 'Hely naphoz rendelve',
'undo.removeAssignment': 'Hely eltávolítva a napról',
'undo.reorder': 'Helyek átrendezve',
'undo.optimize': 'Útvonal optimalizálva',
'undo.deletePlace': 'Hely törölve',
'undo.moveDay': 'Hely áthelyezve másik napra',
'undo.lock': 'Hely zárolása váltva',
'undo.importGpx': 'GPX importálás',
'undo.importGoogleList': 'Google Maps importálás',
// Notifications
'notifications.title': 'Értesítések',
'notifications.markAllRead': 'Összes olvasottnak jelölése',
'notifications.deleteAll': 'Összes törlése',
'notifications.showAll': 'Összes értesítés megtekintése',
'notifications.empty': 'Nincsenek értesítések',
'notifications.emptyDescription': 'Mindennel naprakész vagy!',
'notifications.all': 'Összes',
'notifications.unreadOnly': 'Olvasatlan',
'notifications.markRead': 'Olvasottnak jelölés',
'notifications.markUnread': 'Olvasatlannak jelölés',
'notifications.delete': 'Törlés',
'notifications.system': 'Rendszer',
'memories.error.loadAlbums': 'Az albumok betöltése sikertelen',
'memories.error.linkAlbum': 'Az album csatolása sikertelen',
'memories.error.unlinkAlbum': 'Az album leválasztása sikertelen',
'memories.error.syncAlbum': 'Az album szinkronizálása sikertelen',
'memories.error.loadPhotos': 'A fotók betöltése sikertelen',
'memories.error.addPhotos': 'A fotók hozzáadása sikertelen',
'memories.error.removePhoto': 'A fotó eltávolítása sikertelen',
'memories.error.toggleSharing': 'A megosztás frissítése sikertelen',
'undo.addPlace': 'Hely hozzáadva',
'undo.done': 'Visszavonva: {action}',
'notifications.test.title': 'Teszt értesítés {actor} részéről',
'notifications.test.text': 'Ez egy egyszerű teszt értesítés.',
'notifications.test.booleanTitle': '{actor} jóváhagyásodat kéri',
'notifications.test.booleanText': 'Teszt igen/nem értesítés.',
'notifications.test.accept': 'Jóváhagyás',
'notifications.test.decline': 'Elutasítás',
'notifications.test.navigateTitle': 'Nézz meg valamit',
'notifications.test.navigateText': 'Teszt navigációs értesítés.',
'notifications.test.goThere': 'Odamegyek',
'notifications.test.adminTitle': 'Adminisztrátor üzenet',
'notifications.test.adminText': '{actor} teszt értesítést küldött az összes adminisztrátornak.',
'notifications.test.tripTitle': '{actor} üzenetet küldött az utazásodba',
'notifications.test.tripText': 'Teszt értesítés a(z) "{trip}" utazáshoz.',
// Todo
'todo.subtab.packing': 'Csomagolási lista',
'todo.subtab.todo': 'Teendők',
'todo.completed': 'kész',
'todo.filter.all': 'Mind',
'todo.filter.open': 'Nyitott',
'todo.filter.done': 'Kész',
'todo.uncategorized': 'Kategória nélküli',
'todo.namePlaceholder': 'Feladat neve',
'todo.descriptionPlaceholder': 'Leírás (opcionális)',
'todo.unassigned': 'Nem hozzárendelt',
'todo.noCategory': 'Nincs kategória',
'todo.hasDescription': 'Van leírás',
'todo.addItem': 'Új feladat hozzáadása...',
'todo.newCategory': 'Kategória neve',
'todo.addCategory': 'Kategória hozzáadása',
'todo.newItem': 'Új feladat',
'todo.empty': 'Még nincsenek feladatok. Adj hozzá egyet a kezdéshez!',
'todo.filter.my': 'Saját feladataim',
'todo.filter.overdue': 'Lejárt',
'todo.sidebar.tasks': 'Feladatok',
'todo.sidebar.categories': 'Kategóriák',
'todo.detail.title': 'Feladat',
'todo.detail.description': 'Leírás',
'todo.detail.category': 'Kategória',
'todo.detail.dueDate': 'Határidő',
'todo.detail.assignedTo': 'Hozzárendelve',
'todo.detail.delete': 'Törlés',
'todo.detail.save': 'Módosítások mentése',
'todo.detail.create': 'Feladat létrehozása',
'todo.detail.priority': 'Prioritás',
'todo.detail.noPriority': 'Nincs',
'todo.sortByPrio': 'Prioritás',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'Új verzió elérhető',
'settings.notificationPreferences.noChannels': 'Nincsenek értesítési csatornák beállítva. Kérd meg a rendszergazdát, hogy állítson be e-mail vagy webhook értesítéseket.',
'settings.webhookUrl.label': 'Webhook URL',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Adja meg a Discord, Slack vagy egyéni webhook URL-jét az értesítések fogadásához.',
'settings.webhookUrl.save': 'Mentés',
'settings.webhookUrl.saved': 'Webhook URL mentve',
'settings.webhookUrl.test': 'Teszt',
'settings.webhookUrl.testSuccess': 'Teszt webhook sikeresen elküldve',
'settings.webhookUrl.testFailed': 'Teszt webhook sikertelen',
'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': 'Az alkalmazáson belüli értesítések mindig aktívak, és globálisan nem kapcsolhatók ki.',
'admin.notifications.adminWebhookPanel.title': 'Admin webhook',
'admin.notifications.adminWebhookPanel.hint': 'Ez a webhook kizárólag admin értesítésekhez használatos (pl. verziófrissítési figyelmeztetések). Független a felhasználói webhookoktól, és automatikusan küld, ha URL van beállítva.',
'admin.notifications.adminWebhookPanel.saved': 'Admin webhook URL mentve',
'admin.notifications.adminWebhookPanel.testSuccess': 'Teszt webhook sikeresen elküldve',
'admin.notifications.adminWebhookPanel.testFailed': 'Teszt webhook sikertelen',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Az admin webhook automatikusan küld, ha URL van beállítva',
'admin.notifications.adminNotificationsHint': 'Állítsa be, hogy mely csatornák szállítsák az admin értesítéseket (pl. verziófrissítési figyelmeztetések). A webhook automatikusan küld, ha admin webhook URL van megadva.',
'admin.tabs.notifications': 'Értesítések',
'notifications.versionAvailable.title': 'Elérhető frissítés',
'notifications.versionAvailable.text': 'A TREK {version} már elérhető.',
'notifications.versionAvailable.button': 'Részletek megtekintése',
'notif.test.title': '[Teszt] Értesítés',
'notif.test.simple.text': 'Ez egy egyszerű teszt értesítés.',
'notif.test.boolean.text': 'Elfogadod ezt a teszt értesítést?',
'notif.test.navigate.text': 'Kattints alább az irányítópultra navigáláshoz.',
// Notifications
'notif.trip_invite.title': 'Utazásra meghívó',
'notif.trip_invite.text': '{actor} meghívott a(z) {trip} utazásra',
'notif.booking_change.title': 'Foglalás frissítve',
'notif.booking_change.text': '{actor} frissített egy foglalást a(z) {trip} utazásban',
'notif.trip_reminder.title': 'Utazás emlékeztető',
'notif.trip_reminder.text': 'A(z) {trip} utazás hamarosan kezdődik!',
'notif.vacay_invite.title': 'Vacay Fusion meghívó',
'notif.vacay_invite.text': '{actor} meghívott a nyaralási tervek összevonásához',
'notif.photos_shared.title': 'Fotók megosztva',
'notif.photos_shared.text': '{actor} {count} fotót osztott meg a(z) {trip} utazásban',
'notif.collab_message.title': 'Új üzenet',
'notif.collab_message.text': '{actor} üzenetet küldött a(z) {trip} utazásban',
'notif.packing_tagged.title': 'Csomagolási feladat',
'notif.packing_tagged.text': '{actor} hozzárendelte Önt a {category} kategóriához a(z) {trip} utazásban',
'notif.version_available.title': 'Új verzió elérhető',
'notif.version_available.text': 'A TREK {version} elérhető',
'notif.action.view_trip': 'Utazás megtekintése',
'notif.action.view_collab': 'Üzenetek megtekintése',
'notif.action.view_packing': 'Csomagolás megtekintése',
'notif.action.view_photos': 'Fotók megtekintése',
'notif.action.view_vacay': 'Vacay megtekintése',
'notif.action.view_admin': 'Admin megnyitása',
'notif.action.view': 'Megtekintés',
'notif.action.accept': 'Elfogadás',
'notif.action.decline': 'Elutasítás',
'notif.generic.title': 'Értesítés',
'notif.generic.text': 'Új értesítésed érkezett',
'notif.dev.unknown_event.title': '[DEV] Ismeretlen esemény',
'notif.dev.unknown_event.text': 'A(z) "{event}" eseménytípus nincs regisztrálva az EVENT_NOTIFICATION_CONFIG-ban',
}
export default hu
+208 -6
View File
@@ -80,7 +80,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'dashboard.sharedBy': 'Condiviso da {name}',
'dashboard.days': 'Giorni',
'dashboard.places': 'Luoghi',
'dashboard.members': 'Compagni di viaggio',
'dashboard.archive': 'Archivia',
'dashboard.copyTrip': 'Copia',
'dashboard.copySuffix': 'copia',
'dashboard.restore': 'Ripristina',
'dashboard.archived': 'Archiviati',
'dashboard.status.ongoing': 'In corso',
@@ -99,6 +102,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'dashboard.toast.archiveError': 'Impossibile archiviare il viaggio',
'dashboard.toast.restored': 'Viaggio ripristinato',
'dashboard.toast.restoreError': 'Impossibile ripristinare il viaggio',
'dashboard.toast.copied': 'Viaggio copiato!',
'dashboard.toast.copyError': 'Impossibile copiare il viaggio',
'dashboard.confirm.delete': 'Eliminare il viaggio "{title}"? Tutti i luoghi e i programmi verranno eliminati in modo permanente.',
'dashboard.editTrip': 'Modifica Viaggio',
'dashboard.createTrip': 'Crea Nuovo Viaggio',
@@ -108,6 +113,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'dashboard.tripDescriptionPlaceholder': 'Di cosa tratta questo viaggio?',
'dashboard.startDate': 'Data di inizio',
'dashboard.endDate': 'Data di fine',
'dashboard.dayCount': 'Numero di giorni',
'dashboard.dayCountHint': 'Quanti giorni pianificare quando non sono impostate date di viaggio.',
'dashboard.noDateHint': 'Nessuna data impostata — verranno creati 7 giorni predefiniti. Puoi cambiarlo in qualsiasi momento.',
'dashboard.coverImage': 'Immagine di copertina',
'dashboard.addCoverImage': 'Aggiungi immagine di copertina (o trascinala qui)',
@@ -122,6 +129,12 @@ const it: Record<string, string | { name: string; category: string }[]> = {
// Settings
'settings.title': 'Impostazioni',
'settings.subtitle': 'Configura le tue impostazioni personali',
'settings.tabs.display': 'Visualizzazione',
'settings.tabs.map': 'Mappa',
'settings.tabs.notifications': 'Notifiche',
'settings.tabs.integrations': 'Integrazioni',
'settings.tabs.account': 'Account',
'settings.tabs.about': 'Informazioni',
'settings.map': 'Mappa',
'settings.mapTemplate': 'Modello Mappa',
'settings.mapTemplatePlaceholder.select': 'Seleziona modello...',
@@ -189,6 +202,15 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.toast.deleted': 'Token eliminato',
'settings.mcp.toast.deleteError': 'Impossibile eliminare il token',
'settings.account': 'Account',
'settings.about': 'Informazioni',
'settings.about.reportBug': 'Segnala un bug',
'settings.about.reportBugHint': 'Hai trovato un problema? Faccelo sapere',
'settings.about.featureRequest': 'Richiedi funzionalità',
'settings.about.featureRequestHint': 'Suggerisci una nuova funzionalità',
'settings.about.wikiHint': 'Documentazione e guide',
'settings.about.description': 'TREK è un pianificatore di viaggi self-hosted che ti aiuta a organizzare i tuoi viaggi dalla prima idea all\'ultimo ricordo. Pianificazione giornaliera, budget, liste bagagli, foto e molto altro — tutto in un unico posto, sul tuo server.',
'settings.about.madeWith': 'Fatto con',
'settings.about.madeBy': 'da Maurice e una crescente comunità open-source.',
'settings.username': 'Username',
'settings.email': 'Email',
'settings.role': 'Ruolo',
@@ -457,7 +479,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
// Packing Templates & Bag Tracking
'admin.bagTracking.title': 'Tracciamento valigia',
'admin.bagTracking.subtitle': 'Abilita il peso e l\'assegnazione della valigia per gli elementi della lista valigia',
'admin.tabs.config': 'Configurazione',
'admin.tabs.config': 'Personalizzazione',
'admin.tabs.templates': 'Modelli lista valigia',
'admin.packingTemplates.title': 'Modelli lista valigia',
'admin.packingTemplates.subtitle': 'Crea liste valigia riutilizzabili per i tuoi viaggi',
@@ -481,8 +503,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.addons': 'Moduli',
'admin.addons.title': 'Moduli',
'admin.addons.subtitle': 'Abilita o disabilita le funzionalità per personalizzare la tua esperienza TREK.',
'admin.addons.catalog.packing.name': 'Lista valigia',
'admin.addons.catalog.packing.description': 'Checklist per preparare la valigia per ogni viaggio',
'admin.addons.catalog.packing.name': 'Liste',
'admin.addons.catalog.packing.description': 'Liste di imballaggio e attività da svolgere per i tuoi viaggi',
'admin.addons.catalog.budget.name': 'Budget',
'admin.addons.catalog.budget.description': 'Tieni traccia delle spese e pianifica il budget del tuo viaggio',
'admin.addons.catalog.documents.name': 'Documenti',
@@ -523,7 +545,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.weather.requestsDesc': 'Gratis, nessuna chiave API richiesta',
'admin.weather.locationHint': 'Il meteo si basa sul primo luogo con coordinate di ogni giorno. Se a un giorno non è assegnato alcun luogo, viene utilizzato come riferimento un qualsiasi luogo dell\'elenco.',
'admin.tabs.audit': 'Log di audit',
'admin.tabs.audit': 'Audit',
'admin.audit.subtitle': 'Eventi sensibili di sicurezza e amministrazione (backup, utenti, 2FA, impostazioni).',
'admin.audit.empty': 'Nessuna voce di audit.',
@@ -589,7 +611,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'vacay.subtitle': 'Pianifica e gestisci i giorni di ferie',
'vacay.settings': 'Impostazioni',
'vacay.year': 'Anno',
'vacay.addYear': 'Aggiungi anno',
'vacay.addYear': 'Aggiungi anno successivo',
'vacay.addPrevYear': 'Aggiungi anno precedente',
'vacay.removeYear': 'Rimuovi anno',
'vacay.removeYearConfirm': 'Rimuovere {year}?',
'vacay.removeYearHint': 'Tutte le voci delle ferie e le ferie aziendali di questo anno verranno eliminate in modo permanente.',
@@ -681,8 +704,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'atlas.unmark': 'Rimuovi',
'atlas.confirmMark': 'Segnare questo paese come visitato?',
'atlas.confirmUnmark': 'Rimuovere questo paese dalla tua lista dei visitati?',
'atlas.confirmUnmarkRegion': 'Rimuovere questa regione dalla tua lista dei visitati?',
'atlas.markVisited': 'Segna come visitato',
'atlas.markVisitedHint': 'Aggiungi questo paese alla tua lista dei visitati',
'atlas.markRegionVisitedHint': 'Aggiungi questa regione alla tua lista dei visitati',
'atlas.addToBucket': 'Aggiungi alla lista desideri',
'atlas.addPoi': 'Aggiungi luogo',
'atlas.bucketNamePlaceholder': 'Nome (paese, città, luogo...)',
@@ -732,6 +757,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'Pren.',
'trip.tabs.packing': 'Lista valigia',
'trip.tabs.packingShort': 'Valigia',
'trip.tabs.lists': 'Liste',
'trip.tabs.listsShort': 'Liste',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'File',
'trip.loading': 'Caricamento viaggio...',
@@ -926,6 +953,32 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'Collega all\'assegnazione del giorno',
'reservations.pickAssignment': 'Seleziona un\'assegnazione dal tuo programma...',
'reservations.noAssignment': 'Nessun collegamento (autonomo)',
'reservations.price': 'Prezzo',
'reservations.budgetCategory': 'Categoria budget',
'reservations.budgetCategoryPlaceholder': 'es. Trasporto, Alloggio',
'reservations.budgetCategoryAuto': 'Auto (dal tipo di prenotazione)',
'reservations.budgetHint': 'Una voce di budget verrà creata automaticamente al salvataggio.',
'reservations.departureDate': 'Partenza',
'reservations.arrivalDate': 'Arrivo',
'reservations.departureTime': 'Ora part.',
'reservations.arrivalTime': 'Ora arr.',
'reservations.pickupDate': 'Ritiro',
'reservations.returnDate': 'Riconsegna',
'reservations.pickupTime': 'Ora ritiro',
'reservations.returnTime': 'Ora riconsegna',
'reservations.endDate': 'Data fine',
'reservations.meta.departureTimezone': 'TZ part.',
'reservations.meta.arrivalTimezone': 'TZ arr.',
'reservations.span.departure': 'Partenza',
'reservations.span.arrival': 'Arrivo',
'reservations.span.inTransit': 'In transito',
'reservations.span.pickup': 'Ritiro',
'reservations.span.return': 'Riconsegna',
'reservations.span.active': 'Attivo',
'reservations.span.start': 'Inizio',
'reservations.span.end': 'Fine',
'reservations.span.ongoing': 'In corso',
'reservations.validation.endBeforeStart': 'La data/ora di fine deve essere successiva alla data/ora di inizio',
// Budget
'budget.title': 'Budget',
@@ -965,7 +1018,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
// Files
'files.title': 'File',
'files.count': '{count} file',
'files.countSingular': '1 file',
'files.countSingular': '1 documento',
'files.uploaded': '{count} caricati',
'files.uploadError': 'Caricamento non riuscito',
'files.dropzone': 'Trascina qui i file',
@@ -1485,6 +1538,155 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'perm.actionHint.packing_edit': 'Chi può gestire articoli da bagaglio e borse',
'perm.actionHint.collab_edit': 'Chi può creare note, sondaggi e inviare messaggi',
'perm.actionHint.share_manage': 'Chi può creare o eliminare link di condivisione pubblici',
// Undo
'undo.button': 'Annulla',
'undo.tooltip': 'Annulla: {action}',
'undo.assignPlace': 'Luogo assegnato al giorno',
'undo.removeAssignment': 'Luogo rimosso dal giorno',
'undo.reorder': 'Luoghi riordinati',
'undo.optimize': 'Percorso ottimizzato',
'undo.deletePlace': 'Luogo eliminato',
'undo.moveDay': 'Luogo spostato in altro giorno',
'undo.lock': 'Blocco luogo modificato',
'undo.importGpx': 'Importazione GPX',
'undo.importGoogleList': 'Importazione Google Maps',
'undo.addPlace': 'Luogo aggiunto',
'undo.done': 'Annullato: {action}',
// Notifications
'notifications.title': 'Notifiche',
'notifications.markAllRead': 'Segna tutto come letto',
'notifications.deleteAll': 'Elimina tutto',
'notifications.showAll': 'Vedi tutte le notifiche',
'notifications.empty': 'Nessuna notifica',
'notifications.emptyDescription': 'Sei aggiornato!',
'notifications.all': 'Tutte',
'notifications.unreadOnly': 'Non lette',
'notifications.markRead': 'Segna come letto',
'notifications.markUnread': 'Segna come non letto',
'notifications.delete': 'Elimina',
'notifications.system': 'Sistema',
'memories.error.loadAlbums': 'Caricamento album non riuscito',
'memories.error.linkAlbum': 'Collegamento album non riuscito',
'memories.error.unlinkAlbum': 'Scollegamento album non riuscito',
'memories.error.syncAlbum': 'Sincronizzazione album non riuscita',
'memories.error.loadPhotos': 'Caricamento foto non riuscito',
'memories.error.addPhotos': 'Aggiunta foto non riuscita',
'memories.error.removePhoto': 'Rimozione foto non riuscita',
'memories.error.toggleSharing': 'Aggiornamento condivisione non riuscito',
'notifications.test.title': 'Notifica di test da {actor}',
'notifications.test.text': 'Questa è una semplice notifica di test.',
'notifications.test.booleanTitle': '{actor} richiede la tua approvazione',
'notifications.test.booleanText': 'Notifica di test con risposta.',
'notifications.test.accept': 'Approva',
'notifications.test.decline': 'Rifiuta',
'notifications.test.navigateTitle': 'Dai un\'occhiata',
'notifications.test.navigateText': 'Notifica di test con navigazione.',
'notifications.test.goThere': 'Vai',
'notifications.test.adminTitle': 'Comunicazione admin',
'notifications.test.adminText': '{actor} ha inviato una notifica di test a tutti gli amministratori.',
'notifications.test.tripTitle': '{actor} ha pubblicato nel tuo viaggio',
'notifications.test.tripText': 'Notifica di test per il viaggio "{trip}".',
// Todo
'todo.subtab.packing': 'Lista di imballaggio',
'todo.subtab.todo': 'Da fare',
'todo.completed': 'completato/i',
'todo.filter.all': 'Tutti',
'todo.filter.open': 'Aperto',
'todo.filter.done': 'Fatto',
'todo.uncategorized': 'Senza categoria',
'todo.namePlaceholder': 'Nome attività',
'todo.descriptionPlaceholder': 'Descrizione (facoltativa)',
'todo.unassigned': 'Non assegnato',
'todo.noCategory': 'Nessuna categoria',
'todo.hasDescription': 'Ha descrizione',
'todo.addItem': 'Aggiungi nuova attività...',
'todo.newCategory': 'Nome categoria',
'todo.addCategory': 'Aggiungi categoria',
'todo.newItem': 'Nuova attività',
'todo.empty': 'Nessuna attività ancora. Aggiungi un\'attività per iniziare!',
'todo.filter.my': 'Le mie attività',
'todo.filter.overdue': 'Scaduta',
'todo.sidebar.tasks': 'Attività',
'todo.sidebar.categories': 'Categorie',
'todo.detail.title': 'Attività',
'todo.detail.description': 'Descrizione',
'todo.detail.category': 'Categoria',
'todo.detail.dueDate': 'Scadenza',
'todo.detail.assignedTo': 'Assegnato a',
'todo.detail.delete': 'Elimina',
'todo.detail.save': 'Salva modifiche',
'todo.detail.create': 'Crea attività',
'todo.detail.priority': 'Priorità',
'todo.detail.noPriority': 'Nessuna',
'todo.sortByPrio': 'Priorità',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'Nuova versione disponibile',
'settings.notificationPreferences.noChannels': 'Nessun canale di notifica configurato. Chiedi a un amministratore di configurare notifiche via e-mail o webhook.',
'settings.webhookUrl.label': 'URL webhook',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Inserisci il tuo URL webhook Discord, Slack o personalizzato per ricevere notifiche.',
'settings.webhookUrl.save': 'Salva',
'settings.webhookUrl.saved': 'URL webhook salvato',
'settings.webhookUrl.test': 'Test',
'settings.webhookUrl.testSuccess': 'Webhook di test inviato con successo',
'settings.webhookUrl.testFailed': 'Invio webhook di test fallito',
'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': 'Le notifiche in-app sono sempre attive e non possono essere disabilitate globalmente.',
'admin.notifications.adminWebhookPanel.title': 'Webhook admin',
'admin.notifications.adminWebhookPanel.hint': 'Questo webhook viene usato esclusivamente per le notifiche admin (es. avvisi di versione). È separato dai webhook utente e si attiva automaticamente quando è configurato un URL.',
'admin.notifications.adminWebhookPanel.saved': 'URL webhook admin salvato',
'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook di test inviato con successo',
'admin.notifications.adminWebhookPanel.testFailed': 'Invio webhook di test fallito',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Il webhook admin si attiva automaticamente quando è configurato un URL',
'admin.notifications.adminNotificationsHint': 'Configura quali canali consegnano le notifiche admin (es. avvisi di versione). Il webhook si attiva automaticamente se è impostato un URL webhook admin.',
'admin.tabs.notifications': 'Notifications',
'notifications.versionAvailable.title': 'Aggiornamento disponibile',
'notifications.versionAvailable.text': 'TREK {version} è ora disponibile.',
'notifications.versionAvailable.button': 'Visualizza dettagli',
'notif.test.title': '[Test] Notifica',
'notif.test.simple.text': 'Questa è una semplice notifica di test.',
'notif.test.boolean.text': 'Accetti questa notifica di test?',
'notif.test.navigate.text': 'Clicca qui sotto per accedere alla dashboard.',
// Notifications
'notif.trip_invite.title': 'Invito al viaggio',
'notif.trip_invite.text': '{actor} ti ha invitato a {trip}',
'notif.booking_change.title': 'Prenotazione aggiornata',
'notif.booking_change.text': '{actor} ha aggiornato una prenotazione in {trip}',
'notif.trip_reminder.title': 'Promemoria viaggio',
'notif.trip_reminder.text': 'Il tuo viaggio {trip} si avvicina!',
'notif.vacay_invite.title': 'Invito Vacay Fusion',
'notif.vacay_invite.text': '{actor} ti ha invitato a fondere i piani vacanza',
'notif.photos_shared.title': 'Foto condivise',
'notif.photos_shared.text': '{actor} ha condiviso {count} foto in {trip}',
'notif.collab_message.title': 'Nuovo messaggio',
'notif.collab_message.text': '{actor} ha inviato un messaggio in {trip}',
'notif.packing_tagged.title': 'Assegnazione bagagli',
'notif.packing_tagged.text': '{actor} ti ha assegnato a {category} in {trip}',
'notif.version_available.title': 'Nuova versione disponibile',
'notif.version_available.text': 'TREK {version} è ora disponibile',
'notif.action.view_trip': 'Vedi viaggio',
'notif.action.view_collab': 'Vedi messaggi',
'notif.action.view_packing': 'Vedi bagagli',
'notif.action.view_photos': 'Vedi foto',
'notif.action.view_vacay': 'Vedi Vacay',
'notif.action.view_admin': 'Vai all\'admin',
'notif.action.view': 'Vedi',
'notif.action.accept': 'Accetta',
'notif.action.decline': 'Rifiuta',
'notif.generic.title': 'Notifica',
'notif.generic.text': 'Hai una nuova notifica',
'notif.dev.unknown_event.title': '[DEV] Evento sconosciuto',
'notif.dev.unknown_event.text': 'Il tipo di evento "{event}" non è registrato in EVENT_NOTIFICATION_CONFIG',
}
export default it
+209 -7
View File
@@ -80,7 +80,10 @@ const nl: Record<string, string> = {
'dashboard.sharedBy': 'Gedeeld door {name}',
'dashboard.days': 'Dagen',
'dashboard.places': 'Plaatsen',
'dashboard.members': 'Reisgenoten',
'dashboard.archive': 'Archiveren',
'dashboard.copyTrip': 'Kopiëren',
'dashboard.copySuffix': 'kopie',
'dashboard.restore': 'Herstellen',
'dashboard.archived': 'Gearchiveerd',
'dashboard.status.ongoing': 'Lopend',
@@ -99,6 +102,8 @@ const nl: Record<string, string> = {
'dashboard.toast.archiveError': 'Reis archiveren mislukt',
'dashboard.toast.restored': 'Reis hersteld',
'dashboard.toast.restoreError': 'Reis herstellen mislukt',
'dashboard.toast.copied': 'Reis gekopieerd!',
'dashboard.toast.copyError': 'Reis kopiëren mislukt',
'dashboard.confirm.delete': 'Reis "{title}" verwijderen? Alle plaatsen en plannen worden permanent verwijderd.',
'dashboard.editTrip': 'Reis bewerken',
'dashboard.createTrip': 'Nieuwe reis aanmaken',
@@ -108,6 +113,8 @@ const nl: Record<string, string> = {
'dashboard.tripDescriptionPlaceholder': 'Waar gaat deze reis over?',
'dashboard.startDate': 'Startdatum',
'dashboard.endDate': 'Einddatum',
'dashboard.dayCount': 'Aantal dagen',
'dashboard.dayCountHint': 'Hoeveel dagen te plannen wanneer er geen reisdata zijn ingesteld.',
'dashboard.noDateHint': 'Geen datum ingesteld — er worden standaard 7 dagen aangemaakt. Je kunt dit altijd wijzigen.',
'dashboard.coverImage': 'Omslagafbeelding',
'dashboard.addCoverImage': 'Omslagafbeelding toevoegen',
@@ -122,6 +129,12 @@ const nl: Record<string, string> = {
// Settings
'settings.title': 'Instellingen',
'settings.subtitle': 'Configureer je persoonlijke instellingen',
'settings.tabs.display': 'Weergave',
'settings.tabs.map': 'Kaart',
'settings.tabs.notifications': 'Meldingen',
'settings.tabs.integrations': 'Integraties',
'settings.tabs.account': 'Account',
'settings.tabs.about': 'Over',
'settings.map': 'Kaart',
'settings.mapTemplate': 'Kaartsjabloon',
'settings.mapTemplatePlaceholder.select': 'Selecteer sjabloon...',
@@ -237,6 +250,15 @@ const nl: Record<string, string> = {
'settings.mcp.toast.deleted': 'Token verwijderd',
'settings.mcp.toast.deleteError': 'Token verwijderen mislukt',
'settings.account': 'Account',
'settings.about': 'Over',
'settings.about.reportBug': 'Bug melden',
'settings.about.reportBugHint': 'Probleem gevonden? Laat het ons weten',
'settings.about.featureRequest': 'Feature aanvragen',
'settings.about.featureRequestHint': 'Stel een nieuwe functie voor',
'settings.about.wikiHint': 'Documentatie en handleidingen',
'settings.about.description': 'TREK is een zelf-gehoste reisplanner die je helpt je reizen te organiseren van het eerste idee tot de laatste herinnering. Dagplanning, budget, paklijsten, foto\'s en nog veel meer — alles op één plek, op je eigen server.',
'settings.about.madeWith': 'Gemaakt met',
'settings.about.madeBy': 'door Maurice en een groeiende open-source community.',
'settings.username': 'Gebruikersnaam',
'settings.email': 'E-mail',
'settings.role': 'Rol',
@@ -377,7 +399,7 @@ const nl: Record<string, string> = {
'admin.tabs.users': 'Gebruikers',
'admin.tabs.categories': 'Categorieën',
'admin.tabs.backup': 'Back-up',
'admin.tabs.audit': 'Auditlog',
'admin.tabs.audit': 'Audit',
'admin.stats.users': 'Gebruikers',
'admin.stats.trips': 'Reizen',
'admin.stats.places': 'Plaatsen',
@@ -458,7 +480,7 @@ const nl: Record<string, string> = {
'admin.bagTracking.title': 'Bagagetracking',
'admin.bagTracking.subtitle': 'Gewicht en bagagetoewijzing inschakelen voor paklijstitems',
'admin.tabs.config': 'Configuratie',
'admin.tabs.config': 'Personalisatie',
'admin.tabs.templates': 'Paksjablonen',
'admin.packingTemplates.title': 'Paksjablonen',
'admin.packingTemplates.subtitle': 'Herbruikbare paklijsten maken voor je reizen',
@@ -486,8 +508,8 @@ const nl: Record<string, string> = {
'admin.addons.catalog.memories.description': 'Deel reisfoto\'s via je Immich-instantie',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'Model Context Protocol voor AI-assistent integratie',
'admin.addons.catalog.packing.name': 'Inpakken',
'admin.addons.catalog.packing.description': 'Checklists om je bagage voor elke reis voor te bereiden',
'admin.addons.catalog.packing.name': 'Lijsten',
'admin.addons.catalog.packing.description': 'Paklijsten en to-dotaken voor je reizen',
'admin.addons.catalog.budget.name': 'Budget',
'admin.addons.catalog.budget.description': 'Houd uitgaven bij en plan je reisbudget',
'admin.addons.catalog.documents.name': 'Documenten',
@@ -588,7 +610,8 @@ const nl: Record<string, string> = {
'vacay.subtitle': 'Plan en beheer vakantiedagen',
'vacay.settings': 'Instellingen',
'vacay.year': 'Jaar',
'vacay.addYear': 'Jaar toevoegen',
'vacay.addYear': 'Volgend jaar toevoegen',
'vacay.addPrevYear': 'Vorig jaar toevoegen',
'vacay.removeYear': 'Jaar verwijderen',
'vacay.removeYearConfirm': '{year} verwijderen?',
'vacay.removeYearHint': 'Alle vakantie-invoeren en bedrijfsvakanties voor dit jaar worden permanent verwijderd.',
@@ -716,8 +739,10 @@ const nl: Record<string, string> = {
'atlas.unmark': 'Verwijderen',
'atlas.confirmMark': 'Dit land als bezocht markeren?',
'atlas.confirmUnmark': 'Dit land van je bezochte lijst verwijderen?',
'atlas.confirmUnmarkRegion': 'Deze regio van je bezochte lijst verwijderen?',
'atlas.markVisited': 'Markeren als bezocht',
'atlas.markVisitedHint': 'Dit land toevoegen aan je bezochte lijst',
'atlas.markRegionVisitedHint': 'Deze regio toevoegen aan je bezochte lijst',
'atlas.addToBucket': 'Aan bucket list toevoegen',
'atlas.addPoi': 'Plaats toevoegen',
'atlas.searchCountry': 'Zoek een land...',
@@ -731,6 +756,8 @@ const nl: Record<string, string> = {
'trip.tabs.reservationsShort': 'Boek',
'trip.tabs.packing': 'Paklijst',
'trip.tabs.packingShort': 'Inpakken',
'trip.tabs.lists': 'Lijsten',
'trip.tabs.listsShort': 'Lijsten',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Bestanden',
'trip.loading': 'Reis laden...',
@@ -925,6 +952,32 @@ const nl: Record<string, string> = {
'reservations.linkAssignment': 'Koppelen aan dagtoewijzing',
'reservations.pickAssignment': 'Selecteer een toewijzing uit je plan...',
'reservations.noAssignment': 'Geen koppeling (zelfstandig)',
'reservations.price': 'Prijs',
'reservations.budgetCategory': 'Budgetcategorie',
'reservations.budgetCategoryPlaceholder': 'bijv. Transport, Accommodatie',
'reservations.budgetCategoryAuto': 'Automatisch (op basis van boekingstype)',
'reservations.budgetHint': 'Er wordt automatisch een budgetpost aangemaakt bij het opslaan.',
'reservations.departureDate': 'Vertrek',
'reservations.arrivalDate': 'Aankomst',
'reservations.departureTime': 'Vertrektijd',
'reservations.arrivalTime': 'Aankomsttijd',
'reservations.pickupDate': 'Ophalen',
'reservations.returnDate': 'Inleveren',
'reservations.pickupTime': 'Ophaaltijd',
'reservations.returnTime': 'Inlevertijd',
'reservations.endDate': 'Einddatum',
'reservations.meta.departureTimezone': 'TZ vertrek',
'reservations.meta.arrivalTimezone': 'TZ aankomst',
'reservations.span.departure': 'Vertrek',
'reservations.span.arrival': 'Aankomst',
'reservations.span.inTransit': 'Onderweg',
'reservations.span.pickup': 'Ophalen',
'reservations.span.return': 'Inleveren',
'reservations.span.active': 'Actief',
'reservations.span.start': 'Start',
'reservations.span.end': 'Einde',
'reservations.span.ongoing': 'Lopend',
'reservations.validation.endBeforeStart': 'Einddatum/-tijd moet na de startdatum/-tijd liggen',
// Budget
'budget.title': 'Budget',
@@ -1369,7 +1422,7 @@ const nl: Record<string, string> = {
// Collab Addon
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notities',
'collab.tabs.polls': 'Polls',
'collab.tabs.polls': 'Peilingen',
'collab.whatsNext.title': 'Wat komt er',
'collab.whatsNext.today': 'Vandaag',
'collab.whatsNext.tomorrow': 'Morgen',
@@ -1415,7 +1468,7 @@ const nl: Record<string, string> = {
'collab.notes.attachFiles': 'Bestanden bijvoegen',
'collab.notes.noCategoriesYet': 'Nog geen categorieën',
'collab.notes.emptyDesc': 'Maak een notitie om te beginnen',
'collab.polls.title': 'Polls',
'collab.polls.title': 'Peilingen',
'collab.polls.new': 'Nieuwe poll',
'collab.polls.empty': 'Nog geen polls',
'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen',
@@ -1484,6 +1537,155 @@ const nl: Record<string, string> = {
'perm.actionHint.packing_edit': 'Wie kan pakitems en tassen beheren',
'perm.actionHint.collab_edit': 'Wie kan notities, polls aanmaken en berichten versturen',
'perm.actionHint.share_manage': 'Wie kan openbare deellinks aanmaken of verwijderen',
// Undo
'undo.button': 'Ongedaan maken',
'undo.tooltip': 'Ongedaan maken: {action}',
'undo.assignPlace': 'Locatie aan dag toegewezen',
'undo.removeAssignment': 'Locatie uit dag verwijderd',
'undo.reorder': 'Locaties hergeordend',
'undo.optimize': 'Route geoptimaliseerd',
'undo.deletePlace': 'Locatie verwijderd',
'undo.moveDay': 'Locatie naar andere dag verplaatst',
'undo.lock': 'Vergrendeling locatie gewijzigd',
'undo.importGpx': 'GPX-import',
'undo.importGoogleList': 'Google Maps-import',
// Notifications
'notifications.title': 'Meldingen',
'notifications.markAllRead': 'Alles als gelezen markeren',
'notifications.deleteAll': 'Alles verwijderen',
'notifications.showAll': 'Alle meldingen weergeven',
'notifications.empty': 'Geen meldingen',
'notifications.emptyDescription': 'Je bent helemaal bijgewerkt!',
'notifications.all': 'Alle',
'notifications.unreadOnly': 'Ongelezen',
'notifications.markRead': 'Markeren als gelezen',
'notifications.markUnread': 'Markeren als ongelezen',
'notifications.delete': 'Verwijderen',
'notifications.system': 'Systeem',
'memories.error.loadAlbums': 'Albums laden mislukt',
'memories.error.linkAlbum': 'Album koppelen mislukt',
'memories.error.unlinkAlbum': 'Album ontkoppelen mislukt',
'memories.error.syncAlbum': 'Album synchroniseren mislukt',
'memories.error.loadPhotos': 'Foto\'s laden mislukt',
'memories.error.addPhotos': 'Foto\'s toevoegen mislukt',
'memories.error.removePhoto': 'Foto verwijderen mislukt',
'memories.error.toggleSharing': 'Delen bijwerken mislukt',
'undo.addPlace': 'Locatie toegevoegd',
'undo.done': 'Ongedaan gemaakt: {action}',
'notifications.test.title': 'Testmelding van {actor}',
'notifications.test.text': 'Dit is een eenvoudige testmelding.',
'notifications.test.booleanTitle': '{actor} vraagt om uw goedkeuring',
'notifications.test.booleanText': 'Booleaanse testmelding.',
'notifications.test.accept': 'Goedkeuren',
'notifications.test.decline': 'Afwijzen',
'notifications.test.navigateTitle': 'Bekijk iets',
'notifications.test.navigateText': 'Navigatie-testmelding.',
'notifications.test.goThere': 'Ga erheen',
'notifications.test.adminTitle': 'Admin-broadcast',
'notifications.test.adminText': '{actor} heeft een testmelding naar alle admins gestuurd.',
'notifications.test.tripTitle': '{actor} heeft gepost in uw reis',
'notifications.test.tripText': 'Testmelding voor reis "{trip}".',
// Todo
'todo.subtab.packing': 'Paklijst',
'todo.subtab.todo': 'Taken',
'todo.completed': 'voltooid',
'todo.filter.all': 'Alles',
'todo.filter.open': 'Open',
'todo.filter.done': 'Klaar',
'todo.uncategorized': 'Zonder categorie',
'todo.namePlaceholder': 'Taaknaam',
'todo.descriptionPlaceholder': 'Beschrijving (optioneel)',
'todo.unassigned': 'Niet toegewezen',
'todo.noCategory': 'Geen categorie',
'todo.hasDescription': 'Heeft beschrijving',
'todo.addItem': 'Nieuwe taak toevoegen...',
'todo.newCategory': 'Categorienaam',
'todo.addCategory': 'Categorie toevoegen',
'todo.newItem': 'Nieuwe taak',
'todo.empty': 'Nog geen taken. Voeg een taak toe om te beginnen!',
'todo.filter.my': 'Mijn taken',
'todo.filter.overdue': 'Verlopen',
'todo.sidebar.tasks': 'Taken',
'todo.sidebar.categories': 'Categorieën',
'todo.detail.title': 'Taak',
'todo.detail.description': 'Beschrijving',
'todo.detail.category': 'Categorie',
'todo.detail.dueDate': 'Vervaldatum',
'todo.detail.assignedTo': 'Toegewezen aan',
'todo.detail.delete': 'Verwijderen',
'todo.detail.save': 'Wijzigingen opslaan',
'todo.detail.create': 'Taak aanmaken',
'todo.detail.priority': 'Prioriteit',
'todo.detail.noPriority': 'Geen',
'todo.sortByPrio': 'Prioriteit',
// Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'Nieuwe versie beschikbaar',
'settings.notificationPreferences.noChannels': 'Er zijn geen meldingskanalen geconfigureerd. Vraag een beheerder om e-mail- of webhookmeldingen in te stellen.',
'settings.webhookUrl.label': 'Webhook-URL',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Voer je Discord-, Slack- of aangepaste webhook-URL in om meldingen te ontvangen.',
'settings.webhookUrl.save': 'Opslaan',
'settings.webhookUrl.saved': 'Webhook-URL opgeslagen',
'settings.webhookUrl.test': 'Testen',
'settings.webhookUrl.testSuccess': 'Test-webhook succesvol verzonden',
'settings.webhookUrl.testFailed': 'Test-webhook mislukt',
'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-meldingen zijn altijd actief en kunnen niet globaal worden uitgeschakeld.',
'admin.notifications.adminWebhookPanel.title': 'Admin-webhook',
'admin.notifications.adminWebhookPanel.hint': 'Deze webhook wordt uitsluitend gebruikt voor admin-meldingen (bijv. versie-updates). Hij staat los van gebruikerswebhooks en verstuurt automatisch als er een URL is ingesteld.',
'admin.notifications.adminWebhookPanel.saved': 'Admin-webhook-URL opgeslagen',
'admin.notifications.adminWebhookPanel.testSuccess': 'Test-webhook succesvol verzonden',
'admin.notifications.adminWebhookPanel.testFailed': 'Test-webhook mislukt',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin-webhook verstuurt automatisch als er een URL is ingesteld',
'admin.notifications.adminNotificationsHint': 'Stel in via welke kanalen admin-meldingen worden bezorgd (bijv. versie-updates). De webhook verstuurt automatisch als er een admin-webhook-URL is ingesteld.',
'admin.tabs.notifications': 'Meldingen',
'notifications.versionAvailable.title': 'Update beschikbaar',
'notifications.versionAvailable.text': 'TREK {version} is nu beschikbaar.',
'notifications.versionAvailable.button': 'Details bekijken',
'notif.test.title': '[Test] Melding',
'notif.test.simple.text': 'Dit is een eenvoudige testmelding.',
'notif.test.boolean.text': 'Accepteer je deze testmelding?',
'notif.test.navigate.text': 'Klik hieronder om naar het dashboard te gaan.',
// Notifications
'notif.trip_invite.title': 'Reisuitnodiging',
'notif.trip_invite.text': '{actor} heeft je uitgenodigd voor {trip}',
'notif.booking_change.title': 'Boeking bijgewerkt',
'notif.booking_change.text': '{actor} heeft een boeking bijgewerkt in {trip}',
'notif.trip_reminder.title': 'Reisherinnering',
'notif.trip_reminder.text': 'Je reis {trip} komt eraan!',
'notif.vacay_invite.title': 'Vacay Fusion-uitnodiging',
'notif.vacay_invite.text': '{actor} nodigt je uit om vakantieplannen te fuseren',
'notif.photos_shared.title': 'Foto\'s gedeeld',
'notif.photos_shared.text': '{actor} heeft {count} foto(\'s) gedeeld in {trip}',
'notif.collab_message.title': 'Nieuw bericht',
'notif.collab_message.text': '{actor} heeft een bericht gestuurd in {trip}',
'notif.packing_tagged.title': 'Paklijsttaak',
'notif.packing_tagged.text': '{actor} heeft je toegewezen aan {category} in {trip}',
'notif.version_available.title': 'Nieuwe versie beschikbaar',
'notif.version_available.text': 'TREK {version} is nu beschikbaar',
'notif.action.view_trip': 'Reis bekijken',
'notif.action.view_collab': 'Berichten bekijken',
'notif.action.view_packing': 'Paklijst bekijken',
'notif.action.view_photos': 'Foto\'s bekijken',
'notif.action.view_vacay': 'Vacay bekijken',
'notif.action.view_admin': 'Naar admin',
'notif.action.view': 'Bekijken',
'notif.action.accept': 'Accepteren',
'notif.action.decline': 'Weigeren',
'notif.generic.title': 'Melding',
'notif.generic.text': 'Je hebt een nieuwe melding',
'notif.dev.unknown_event.title': '[DEV] Onbekende gebeurtenis',
'notif.dev.unknown_event.text': 'Gebeurtenistype "{event}" is niet geregistreerd in EVENT_NOTIFICATION_CONFIG',
}
export default nl
File diff suppressed because it is too large Load Diff
+207 -5
View File
@@ -80,7 +80,10 @@ const ru: Record<string, string> = {
'dashboard.sharedBy': 'Поделился {name}',
'dashboard.days': 'Дни',
'dashboard.places': 'Места',
'dashboard.members': 'Попутчики',
'dashboard.archive': 'Архивировать',
'dashboard.copyTrip': 'Копировать',
'dashboard.copySuffix': 'копия',
'dashboard.restore': 'Восстановить',
'dashboard.archived': 'В архиве',
'dashboard.status.ongoing': 'В процессе',
@@ -99,6 +102,8 @@ const ru: Record<string, string> = {
'dashboard.toast.archiveError': 'Не удалось архивировать поездку',
'dashboard.toast.restored': 'Поездка восстановлена',
'dashboard.toast.restoreError': 'Не удалось восстановить поездку',
'dashboard.toast.copied': 'Поездка скопирована!',
'dashboard.toast.copyError': 'Не удалось скопировать поездку',
'dashboard.confirm.delete': 'Удалить поездку «{title}»? Все места и планы будут безвозвратно удалены.',
'dashboard.editTrip': 'Редактировать поездку',
'dashboard.createTrip': 'Создать новую поездку',
@@ -108,6 +113,8 @@ const ru: Record<string, string> = {
'dashboard.tripDescriptionPlaceholder': 'О чём эта поездка?',
'dashboard.startDate': 'Дата начала',
'dashboard.endDate': 'Дата окончания',
'dashboard.dayCount': 'Количество дней',
'dashboard.dayCountHint': 'Сколько дней планировать, если даты поездки не указаны.',
'dashboard.noDateHint': 'Дата не указана — будет создано 7 дней по умолчанию. Вы можете изменить это в любое время.',
'dashboard.coverImage': 'Обложка',
'dashboard.addCoverImage': 'Добавить обложку',
@@ -122,6 +129,12 @@ const ru: Record<string, 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': 'Выберите шаблон...',
@@ -237,6 +250,15 @@ const ru: Record<string, string> = {
'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': 'Морисом и растущим open-source сообществом.',
'settings.username': 'Имя пользователя',
'settings.email': 'Эл. почта',
'settings.role': 'Роль',
@@ -377,7 +399,7 @@ const ru: Record<string, string> = {
'admin.tabs.users': 'Пользователи',
'admin.tabs.categories': 'Категории',
'admin.tabs.backup': 'Резервная копия',
'admin.tabs.audit': 'Журнал аудита',
'admin.tabs.audit': 'Аудит',
'admin.stats.users': 'Пользователи',
'admin.stats.trips': 'Поездки',
'admin.stats.places': 'Места',
@@ -458,7 +480,7 @@ const ru: Record<string, string> = {
'admin.bagTracking.title': 'Отслеживание багажа',
'admin.bagTracking.subtitle': 'Включить вес и привязку к багажу для вещей',
'admin.tabs.config': 'Конфигурация',
'admin.tabs.config': 'Персонализация',
'admin.tabs.templates': 'Шаблоны упаковки',
'admin.packingTemplates.title': 'Шаблоны упаковки',
'admin.packingTemplates.subtitle': 'Создавайте многоразовые списки вещей для поездок',
@@ -486,8 +508,8 @@ const ru: Record<string, string> = {
'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.packing.name': 'Списки',
'admin.addons.catalog.packing.description': 'Списки вещей и задачи для ваших поездок',
'admin.addons.catalog.budget.name': 'Бюджет',
'admin.addons.catalog.budget.description': 'Отслеживайте расходы и планируйте бюджет поездки',
'admin.addons.catalog.documents.name': 'Документы',
@@ -588,7 +610,8 @@ const ru: Record<string, string> = {
'vacay.subtitle': 'Планируйте и управляйте днями отпуска',
'vacay.settings': 'Настройки',
'vacay.year': 'Год',
'vacay.addYear': 'Добавить год',
'vacay.addYear': 'Добавить следующий год',
'vacay.addPrevYear': 'Добавить предыдущий год',
'vacay.removeYear': 'Удалить год',
'vacay.removeYearConfirm': 'Удалить {year}?',
'vacay.removeYearHint': 'Все записи об отпуске и корпоративные выходные за этот год будут безвозвратно удалены.',
@@ -716,8 +739,10 @@ const ru: Record<string, string> = {
'atlas.unmark': 'Удалить',
'atlas.confirmMark': 'Отметить эту страну как посещённую?',
'atlas.confirmUnmark': 'Удалить эту страну из списка посещённых?',
'atlas.confirmUnmarkRegion': 'Удалить этот регион из списка посещённых?',
'atlas.markVisited': 'Отметить как посещённую',
'atlas.markVisitedHint': 'Добавить эту страну в список посещённых',
'atlas.markRegionVisitedHint': 'Добавить этот регион в список посещённых',
'atlas.addToBucket': 'В список желаний',
'atlas.addPoi': 'Добавить место',
'atlas.searchCountry': 'Поиск страны...',
@@ -731,6 +756,8 @@ const ru: Record<string, string> = {
'trip.tabs.reservationsShort': 'Брони',
'trip.tabs.packing': 'Список вещей',
'trip.tabs.packingShort': 'Вещи',
'trip.tabs.lists': 'Списки',
'trip.tabs.listsShort': 'Списки',
'trip.tabs.budget': 'Бюджет',
'trip.tabs.files': 'Файлы',
'trip.loading': 'Загрузка поездки...',
@@ -925,6 +952,32 @@ const ru: Record<string, 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': 'Бюджет',
@@ -1484,6 +1537,155 @@ const ru: Record<string, string> = {
'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 Maps',
// 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': 'URL вебхука',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Введите URL вашего вебхука Discord, Slack или пользовательского для получения уведомлений.',
'settings.webhookUrl.save': 'Сохранить',
'settings.webhookUrl.saved': 'URL вебхука сохранён',
'settings.webhookUrl.test': 'Тест',
'settings.webhookUrl.testSuccess': 'Тестовый вебхук успешно отправлен',
'settings.webhookUrl.testFailed': 'Ошибка тестового вебхука',
'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': 'Вебхук администратора',
'admin.notifications.adminWebhookPanel.hint': 'Этот вебхук используется исключительно для уведомлений администратора (например, оповещения о версиях). Он независим от пользовательских вебхуков и отправляется автоматически при наличии URL.',
'admin.notifications.adminWebhookPanel.saved': 'URL вебхука администратора сохранён',
'admin.notifications.adminWebhookPanel.testSuccess': 'Тестовый вебхук успешно отправлен',
'admin.notifications.adminWebhookPanel.testFailed': 'Ошибка тестового вебхука',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Вебхук администратора отправляется автоматически при наличии URL',
'admin.notifications.adminNotificationsHint': 'Настройте, какие каналы доставляют уведомления администратора (например, оповещения о версиях). Вебхук отправляется автоматически, если задан URL вебхука администратора.',
'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': 'Приглашение Vacay Fusion',
'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 ru
+207 -5
View File
@@ -80,7 +80,10 @@ const zh: Record<string, string> = {
'dashboard.sharedBy': '由 {name} 分享',
'dashboard.days': '天',
'dashboard.places': '地点',
'dashboard.members': '旅伴',
'dashboard.archive': '归档',
'dashboard.copyTrip': '复制',
'dashboard.copySuffix': '副本',
'dashboard.restore': '恢复',
'dashboard.archived': '已归档',
'dashboard.status.ongoing': '进行中',
@@ -99,6 +102,8 @@ const zh: Record<string, string> = {
'dashboard.toast.archiveError': '归档旅行失败',
'dashboard.toast.restored': '旅行已恢复',
'dashboard.toast.restoreError': '恢复旅行失败',
'dashboard.toast.copied': '旅行已复制!',
'dashboard.toast.copyError': '复制旅行失败',
'dashboard.confirm.delete': '删除旅行「{title}」?所有地点和计划将被永久删除。',
'dashboard.editTrip': '编辑旅行',
'dashboard.createTrip': '创建新旅行',
@@ -108,6 +113,8 @@ const zh: Record<string, string> = {
'dashboard.tripDescriptionPlaceholder': '这次旅行是关于什么的?',
'dashboard.startDate': '开始日期',
'dashboard.endDate': '结束日期',
'dashboard.dayCount': '天数',
'dashboard.dayCountHint': '未设置旅行日期时要规划的天数。',
'dashboard.noDateHint': '未设置日期——将默认创建 7 天。你可以随时修改。',
'dashboard.coverImage': '封面图片',
'dashboard.addCoverImage': '添加封面图片',
@@ -122,6 +129,12 @@ const zh: Record<string, 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': '选择模板...',
@@ -237,6 +250,15 @@ const zh: Record<string, string> = {
'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': '由 Maurice 和不断壮大的开源社区打造。',
'settings.username': '用户名',
'settings.email': '邮箱',
'settings.role': '角色',
@@ -377,7 +399,7 @@ const zh: Record<string, string> = {
'admin.tabs.users': '用户',
'admin.tabs.categories': '分类',
'admin.tabs.backup': '备份',
'admin.tabs.audit': '审计日志',
'admin.tabs.audit': '审计',
'admin.stats.users': '用户',
'admin.stats.trips': '旅行',
'admin.stats.places': '地点',
@@ -458,7 +480,7 @@ const zh: Record<string, string> = {
'admin.bagTracking.title': '行李追踪',
'admin.bagTracking.subtitle': '为打包物品启用重量和行李分配',
'admin.tabs.config': '配置',
'admin.tabs.config': '个性化',
'admin.tabs.templates': '打包模板',
'admin.packingTemplates.title': '打包模板',
'admin.packingTemplates.subtitle': '创建可复用的旅行打包清单',
@@ -486,8 +508,8 @@ const zh: Record<string, string> = {
'admin.addons.catalog.memories.description': '通过 Immich 实例分享旅行照片',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': '用于 AI 助手集成的模型上下文协议',
'admin.addons.catalog.packing.name': '行李',
'admin.addons.catalog.packing.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': '文档',
@@ -588,7 +610,8 @@ const zh: Record<string, string> = {
'vacay.subtitle': '规划和管理假期',
'vacay.settings': '设置',
'vacay.year': '年份',
'vacay.addYear': '添加年',
'vacay.addYear': '添加下一年',
'vacay.addPrevYear': '添加上一年',
'vacay.removeYear': '移除年份',
'vacay.removeYearConfirm': '移除 {year}',
'vacay.removeYearHint': '该年度所有假期记录和公司假日将被永久删除。',
@@ -716,8 +739,10 @@ const zh: Record<string, string> = {
'atlas.unmark': '移除',
'atlas.confirmMark': '将此国家标记为已访问?',
'atlas.confirmUnmark': '从已访问列表中移除此国家?',
'atlas.confirmUnmarkRegion': '从已访问列表中移除此地区?',
'atlas.markVisited': '标记为已访问',
'atlas.markVisitedHint': '将此国家添加到已访问列表',
'atlas.markRegionVisitedHint': '将此地区添加到已访问列表',
'atlas.addToBucket': '添加到心愿单',
'atlas.addPoi': '添加地点',
'atlas.searchCountry': '搜索国家...',
@@ -731,6 +756,8 @@ const zh: Record<string, string> = {
'trip.tabs.reservationsShort': '预订',
'trip.tabs.packing': '行李清单',
'trip.tabs.packingShort': '行李',
'trip.tabs.lists': '列表',
'trip.tabs.listsShort': '列表',
'trip.tabs.budget': '预算',
'trip.tabs.files': '文件',
'trip.loading': '加载旅行中...',
@@ -925,6 +952,32 @@ const zh: Record<string, 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': '出发时区',
'reservations.meta.arrivalTimezone': '到达时区',
'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': '预算',
@@ -1484,6 +1537,155 @@ const zh: Record<string, string> = {
'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 URL',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': '输入您的 Discord、Slack 或自定义 Webhook URL 以接收通知。',
'settings.webhookUrl.save': '保存',
'settings.webhookUrl.saved': 'Webhook URL 已保存',
'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 专用于管理员通知(如版本更新提醒)。它与用户 Webhook 相互独立,配置 URL 后自动触发。',
'admin.notifications.adminWebhookPanel.saved': '管理员 Webhook URL 已保存',
'admin.notifications.adminWebhookPanel.testSuccess': '测试 Webhook 发送成功',
'admin.notifications.adminWebhookPanel.testFailed': '测试 Webhook 失败',
'admin.notifications.adminWebhookPanel.alwaysOnHint': '配置 URL 后管理员 Webhook 自动触发',
'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': 'Vacay 融合邀请',
'notif.vacay_invite.text': '{actor} 邀请您合并假期计划',
'notif.photos_shared.title': '照片已分享',
'notif.photos_shared.text': '{actor} 在 {trip} 中分享了 {count} 张照片',
'notif.collab_message.title': '新消息',
'notif.collab_message.text': '{actor} 在 {trip} 中发送了消息',
'notif.packing_tagged.title': '行李分配',
'notif.packing_tagged.text': '{actor} 将您分配到 {trip} 中的 {category}',
'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 zh
File diff suppressed because it is too large Load Diff
+318 -190
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import apiClient, { adminApi, authApi, notificationsApi } from '../api/client'
import DevNotificationsPanel from '../components/Admin/DevNotificationsPanel'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { useAddonStore } from '../store/addonStore'
@@ -17,7 +18,7 @@ import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
import AuditLogPanel from '../components/Admin/AuditLogPanel'
import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
import PermissionsPanel from '../components/Admin/PermissionsPanel'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, GitBranch, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle } from 'lucide-react'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle } from 'lucide-react'
import CustomSelect from '../components/shared/CustomSelect'
interface AdminUser {
@@ -56,20 +57,124 @@ interface UpdateInfo {
is_docker?: boolean
}
const ADMIN_EVENT_LABEL_KEYS: Record<string, string> = {
version_available: 'settings.notifyVersionAvailable',
}
const ADMIN_CHANNEL_LABEL_KEYS: Record<string, string> = {
inapp: 'settings.notificationPreferences.inapp',
email: 'settings.notificationPreferences.email',
webhook: 'settings.notificationPreferences.webhook',
}
function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast: ReturnType<typeof useToast> }) {
const [matrix, setMatrix] = useState<any>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
adminApi.getNotificationPreferences().then((data: any) => setMatrix(data)).catch(() => {})
}, [])
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading</p>
const visibleChannels = (['inapp', 'email', 'webhook'] as const).filter(ch => {
if (!matrix.available_channels[ch]) return false
return matrix.event_types.some((evt: string) => matrix.implemented_combos[evt]?.includes(ch))
})
const toggle = async (eventType: string, channel: string) => {
const current = matrix.preferences[eventType]?.[channel] ?? true
const updated = { ...matrix.preferences, [eventType]: { ...matrix.preferences[eventType], [channel]: !current } }
setMatrix((m: any) => m ? { ...m, preferences: updated } : m)
setSaving(true)
try {
await adminApi.updateNotificationPreferences(updated)
} catch {
setMatrix((m: any) => m ? { ...m, preferences: matrix.preferences } : m)
toast.error(t('common.error'))
} finally {
setSaving(false)
}
}
if (matrix.event_types.length === 0) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<p style={{ fontSize: 13, color: 'var(--text-faint)' }}>{t('settings.notificationPreferences.noChannels')}</p>
</div>
)
}
return (
<div className="space-y-4">
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">{t('admin.tabs.notifications')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminNotificationsHint')}</p>
</div>
<div className="p-6">
{saving && <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>Saving</p>}
{/* Header row */}
<div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '80px').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(ADMIN_CHANNEL_LABEL_KEYS[ch]) || ch}
</span>
))}
</div>
{/* Event rows */}
{matrix.event_types.map((eventType: string) => {
const implementedForEvent = matrix.implemented_combos[eventType] ?? []
return (
<div key={eventType} style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '80px').join(' ')}`, gap: 4, alignItems: 'center', padding: '8px 0', borderBottom: '1px solid var(--border-primary)' }}>
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>
{t(ADMIN_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' }}>
<button
onClick={() => toggle(eventType, ch)}
className="relative inline-flex h-5 w-9 items-center rounded-full transition-colors flex-shrink-0"
style={{ background: isOn ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-4 w-4 rounded-full bg-white transition-transform duration-200"
style={{ transform: isOn ? 'translateX(16px)' : 'translateX(0)' }} />
</button>
</div>
)
})}
</div>
)
})}
</div>
</div>
</div>
)
}
export default function AdminPage(): React.ReactElement {
const { demoMode, serverTimezone } = useAuthStore()
const { t, locale } = useTranslation()
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
const mcpEnabled = useAddonStore(s => s.isEnabled('mcp'))
const devMode = useAuthStore(s => s.devMode)
const TABS = [
{ id: 'users', label: t('admin.tabs.users') },
{ id: 'config', label: t('admin.tabs.config') },
{ id: 'addons', label: t('admin.tabs.addons') },
{ id: 'settings', label: t('admin.tabs.settings') },
{ id: 'notifications', label: t('admin.tabs.notifications') },
{ id: 'backup', label: t('admin.tabs.backup') },
{ id: 'audit', label: t('admin.tabs.audit') },
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') }] : []),
{ id: 'github', label: t('admin.tabs.github') },
...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications' }] : []),
]
const [activeTab, setActiveTab] = useState<string>('users')
@@ -966,189 +1071,6 @@ export default function AdminPage(): React.ReactElement {
</button>
</div>
</div>
{/* Notifications — exclusive channel selector */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">{t('admin.notifications.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.hint')}</p>
</div>
<div className="p-6 space-y-4">
{/* Channel selector */}
<div className="flex gap-2">
{(['none', 'email', 'webhook'] as const).map(ch => {
const active = (smtpValues.notification_channel || 'none') === ch
const labels: Record<string, string> = { none: t('admin.notifications.none'), email: t('admin.notifications.email'), webhook: t('admin.notifications.webhook') }
return (
<button
key={ch}
onClick={() => setSmtpValues(prev => ({ ...prev, notification_channel: ch }))}
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-colors border ${active ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-600 border-slate-300 hover:bg-slate-50'}`}
>
{labels[ch]}
</button>
)
})}
</div>
{/* Notification event toggles — shown when any channel is active */}
{(smtpValues.notification_channel || 'none') !== 'none' && (() => {
const ch = smtpValues.notification_channel || 'none'
const configValid = ch === 'email' ? !!(smtpValues.smtp_host?.trim()) : ch === 'webhook' ? !!(smtpValues.notification_webhook_url?.trim()) : false
return (
<div className={`space-y-2 pt-2 border-t border-slate-100 ${!configValid ? 'opacity-50 pointer-events-none' : ''}`}>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-2">{t('admin.notifications.events')}</p>
{!configValid && (
<p className="text-[10px] text-amber-600 mb-3">{t('admin.notifications.configureFirst')}</p>
)}
<p className="text-[10px] text-slate-400 mb-3">{t('admin.notifications.eventsHint')}</p>
{[
{ key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
{ key: 'notify_booking_change', label: t('settings.notifyBookingChange') },
{ key: 'notify_trip_reminder', label: t('settings.notifyTripReminder') },
{ key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') },
{ key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') },
{ key: 'notify_collab_message', label: t('settings.notifyCollabMessage') },
{ key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') },
].map(opt => {
const isOn = (smtpValues[opt.key] ?? 'true') !== 'false'
return (
<div key={opt.key} className="flex items-center justify-between py-1">
<span className="text-sm text-slate-700">{opt.label}</span>
<button
onClick={() => {
const newVal = isOn ? 'false' : 'true'
setSmtpValues(prev => ({ ...prev, [opt.key]: newVal }))
}}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: isOn ? '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: isOn ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
)
})}
</div>
)
})()}
{/* Email (SMTP) settings — shown when email channel is active */}
{(smtpValues.notification_channel || 'none') === 'email' && (
<div className="space-y-3 pt-2 border-t border-slate-100">
<p className="text-xs text-slate-400">{t('admin.smtp.hint')}</p>
{smtpLoaded && [
{ key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' },
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587' },
{ key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' },
{ key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
].map(field => (
<div key={field.key}>
<label className="block text-xs font-medium text-slate-500 mb-1">{field.label}</label>
<input
type={field.type || 'text'}
value={smtpValues[field.key] || ''}
onChange={e => setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder}
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 style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 0' }}>
<div>
<span className="text-xs font-medium text-slate-500">Skip TLS certificate check</span>
<p className="text-[10px] text-slate-400 mt-0.5">Enable for self-signed certificates on local mail servers</p>
</div>
<button onClick={() => {
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
}}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: smtpValues.smtp_skip_tls_verify === 'true' ? '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: smtpValues.smtp_skip_tls_verify === 'true' ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
)}
{/* Webhook settings — shown when webhook channel is active */}
{(smtpValues.notification_channel || 'none') === 'webhook' && (
<div className="space-y-3 pt-2 border-t border-slate-100">
<p className="text-xs text-slate-400">{t('admin.webhook.hint')}</p>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Webhook URL</label>
<input
type="text"
value={smtpValues.notification_webhook_url || ''}
onChange={e => setSmtpValues(prev => ({ ...prev, notification_webhook_url: e.target.value }))}
placeholder="https://discord.com/api/webhooks/..."
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-[10px] text-slate-400 mt-1">TREK will POST JSON with event, title, body, and timestamp to this URL.</p>
</div>
</div>
)}
{/* Save + Test buttons */}
<div className="flex items-center gap-2 pt-2 border-t border-slate-100">
<button
onClick={async () => {
const notifKeys = ['notification_channel', 'notification_webhook_url', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', 'notify_trip_invite', 'notify_booking_change', 'notify_trip_reminder', 'notify_vacay_invite', 'notify_photos_shared', 'notify_collab_message', 'notify_packing_tagged']
const payload: Record<string, string> = {}
for (const k of notifKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] }
try {
await authApi.updateAppSettings(payload)
toast.success(t('admin.notifications.saved'))
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
}).catch(() => {})
} catch { toast.error(t('common.error')) }
}}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
>
<Save className="w-4 h-4" />
{t('common.save')}
</button>
{(smtpValues.notification_channel || 'none') === 'email' && (
<button
onClick={async () => {
const smtpKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
const payload: Record<string, string> = {}
for (const k of smtpKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] }
await authApi.updateAppSettings(payload).catch(() => {})
try {
const result = await notificationsApi.testSmtp()
if (result.success) toast.success(t('admin.smtp.testSuccess'))
else toast.error(result.error || t('admin.smtp.testFailed'))
} catch { toast.error(t('admin.smtp.testFailed')) }
}}
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
>
{t('admin.smtp.testButton')}
</button>
)}
{(smtpValues.notification_channel || 'none') === 'webhook' && (
<button
onClick={async () => {
if (smtpValues.notification_webhook_url) {
await authApi.updateAppSettings({ notification_webhook_url: smtpValues.notification_webhook_url }).catch(() => {})
}
try {
const result = await notificationsApi.testWebhook()
if (result.success) toast.success(t('admin.notifications.testWebhookSuccess'))
else toast.error(result.error || t('admin.notifications.testWebhookFailed'))
} catch { toast.error(t('admin.notifications.testWebhookFailed')) }
}}
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
>
{t('admin.notifications.testWebhook')}
</button>
)}
</div>
</div>
</div>
{/* Danger Zone */}
<div className="bg-white rounded-xl border border-red-200 overflow-hidden">
<div className="px-6 py-4 border-b border-red-100 bg-red-50">
@@ -1176,6 +1098,210 @@ export default function AdminPage(): React.ReactElement {
</div>
)}
{activeTab === 'notifications' && (() => {
// Derive active channels from smtpValues.notification_channels (plural)
// with fallback to notification_channel (singular) for existing installs
const rawChannels = smtpValues.notification_channels ?? smtpValues.notification_channel ?? 'none'
const activeChans = rawChannels === 'none' ? [] : rawChannels.split(',').map((c: string) => c.trim())
const emailActive = activeChans.includes('email')
const webhookActive = activeChans.includes('webhook')
const setChannels = async (email: boolean, webhook: boolean) => {
const chans = [email && 'email', webhook && 'webhook'].filter(Boolean).join(',') || 'none'
setSmtpValues(prev => ({ ...prev, notification_channels: chans }))
try {
await authApi.updateAppSettings({ notification_channels: chans })
} catch {
// Revert state on failure
const reverted = [emailActive && 'email', webhookActive && 'webhook'].filter(Boolean).join(',') || 'none'
setSmtpValues(prev => ({ ...prev, notification_channels: reverted }))
toast.error(t('common.error'))
}
}
const smtpConfigured = !!(smtpValues.smtp_host?.trim())
const saveNotifications = async () => {
// Saves credentials only — channel activation is auto-saved by the toggle
const notifKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
const payload: Record<string, string> = {}
for (const k of notifKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] }
try {
await authApi.updateAppSettings(payload)
toast.success(t('admin.notifications.saved'))
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
}).catch(() => {})
} catch { toast.error(t('common.error')) }
}
return (<>
<div className="space-y-4">
{/* Email Panel */}
<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('admin.notifications.emailPanel.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
</div>
<button
onClick={() => setChannels(!emailActive, webhookActive)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
style={{ background: emailActive ? '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: emailActive ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
<div className={`p-6 space-y-3 ${!emailActive ? 'opacity-50 pointer-events-none' : ''}`}>
{smtpLoaded && [
{ key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' },
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587' },
{ key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' },
{ key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
].map(field => (
<div key={field.key}>
<label className="block text-xs font-medium text-slate-500 mb-1">{field.label}</label>
<input
type={field.type || 'text'}
value={smtpValues[field.key] || ''}
onChange={e => setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder}
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 style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 0' }}>
<div>
<span className="text-xs font-medium text-slate-500">Skip TLS certificate check</span>
<p className="text-[10px] text-slate-400 mt-0.5">Enable for self-signed certificates on local mail servers</p>
</div>
<button onClick={() => {
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
}}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: smtpValues.smtp_skip_tls_verify === 'true' ? '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: smtpValues.smtp_skip_tls_verify === 'true' ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
<div className="px-6 pb-4 flex items-center gap-2 border-t border-slate-100 pt-4">
<button onClick={saveNotifications}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors">
<Save className="w-4 h-4" />{t('common.save')}
</button>
<button
onClick={async () => {
const smtpKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
const payload: Record<string, string> = {}
for (const k of smtpKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] }
await authApi.updateAppSettings(payload).catch(() => {})
try {
const result = await notificationsApi.testSmtp()
if (result.success) toast.success(t('admin.smtp.testSuccess'))
else toast.error(result.error || t('admin.smtp.testFailed'))
} catch { toast.error(t('admin.smtp.testFailed')) }
}}
disabled={!smtpConfigured}
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors disabled:opacity-40"
>
{t('admin.smtp.testButton')}
</button>
</div>
</div>
{/* Webhook Panel */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 flex items-center justify-between">
<div>
<h2 className="font-semibold text-slate-900">{t('admin.notifications.webhookPanel.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.webhook.hint')}</p>
</div>
<button
onClick={() => setChannels(emailActive, !webhookActive)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
style={{ background: webhookActive ? '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: webhookActive ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
{/* In-App Panel */}
<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('admin.notifications.inappPanel.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.inappPanel.hint')}</p>
</div>
<div className="relative inline-flex h-6 w-11 items-center rounded-full flex-shrink-0"
style={{ background: 'var(--text-primary)', opacity: 0.5, cursor: 'not-allowed' }}>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: 'translateX(20px)' }} />
</div>
</div>
</div>
{/* Admin Webhook Panel */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">{t('admin.notifications.adminWebhookPanel.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminWebhookPanel.hint')}</p>
</div>
<div className="p-6 space-y-3">
{smtpLoaded && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminWebhookPanel.title')}</label>
<input
type="text"
value={smtpValues.admin_webhook_url === '••••••••' ? '' : smtpValues.admin_webhook_url || ''}
onChange={e => setSmtpValues(prev => ({ ...prev, admin_webhook_url: e.target.value }))}
placeholder={smtpValues.admin_webhook_url === '••••••••' ? '••••••••' : 'https://discord.com/api/webhooks/...'}
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 className="px-6 pb-4 flex items-center gap-2 border-t border-slate-100 pt-4">
<button
onClick={async () => {
try {
await authApi.updateAppSettings({ admin_webhook_url: smtpValues.admin_webhook_url || '' })
toast.success(t('admin.notifications.adminWebhookPanel.saved'))
} catch { toast.error(t('common.error')) }
}}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors">
<Save className="w-4 h-4" />{t('common.save')}
</button>
<button
onClick={async () => {
const url = smtpValues.admin_webhook_url === '••••••••' ? undefined : smtpValues.admin_webhook_url
if (!url && smtpValues.admin_webhook_url !== '••••••••') return
try {
if (url) await authApi.updateAppSettings({ admin_webhook_url: url }).catch(() => {})
const result = await notificationsApi.testWebhook(url)
if (result.success) toast.success(t('admin.notifications.adminWebhookPanel.testSuccess'))
else toast.error(result.error || t('admin.notifications.adminWebhookPanel.testFailed'))
} catch { toast.error(t('admin.notifications.adminWebhookPanel.testFailed')) }
}}
disabled={!smtpValues.admin_webhook_url?.trim()}
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors disabled:opacity-40"
>
{t('admin.smtp.testButton')}
</button>
</div>
</div>
</div>
<div className="mt-6">
<AdminNotificationsPanel t={t} toast={toast} />
</div>
</>)
})()}
{activeTab === 'backup' && <BackupPanel />}
{activeTab === 'audit' && <AuditLogPanel serverTimezone={serverTimezone} />}
@@ -1183,6 +1309,8 @@ export default function AdminPage(): React.ReactElement {
{activeTab === 'mcp-tokens' && <AdminMcpTokensPanel />}
{activeTab === 'github' && <GitHubPanel />}
{activeTab === 'dev-notifications' && <DevNotificationsPanel />}
</div>
</div>
@@ -1353,14 +1481,14 @@ export default function AdminPage(): React.ReactElement {
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
>
{`docker pull mauriceboe/nomad:latest
docker stop nomad && docker rm nomad
docker run -d --name nomad \\
{`docker pull mauriceboe/trek:latest
docker stop trek && docker rm trek
docker run -d --name trek \\
-p 3000:3000 \\
-v /opt/nomad/data:/app/data \\
-v /opt/nomad/uploads:/app/uploads \\
-v /opt/trek/data:/app/data \\
-v /opt/trek/uploads:/app/uploads \\
--restart unless-stopped \\
mauriceboe/nomad:latest`}
mauriceboe/trek:latest`}
</div>
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
+336 -8
View File
@@ -154,7 +154,16 @@ export default function AtlasPage(): React.ReactElement {
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket'; code: string; name: string } | null>(null)
const [visitedRegions, setVisitedRegions] = useState<Record<string, { code: string; name: string; placeCount: number; manuallyMarked?: boolean }[]>>({})
const regionLayerRef = useRef<L.GeoJSON | null>(null)
const regionGeoCache = useRef<Record<string, GeoJsonFeatureCollection>>({})
const [showRegions, setShowRegions] = useState(false)
const [regionGeoLoaded, setRegionGeoLoaded] = useState(0)
const regionTooltipRef = useRef<HTMLDivElement>(null)
const loadCountryDetailRef = useRef<(code: string) => void>(() => {})
const handleMarkCountryRef = useRef<(code: string, name: string) => void>(() => {})
const setConfirmActionRef = useRef<typeof setConfirmAction>(() => {})
const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket' | 'choose-region' | 'unmark-region'; code: string; name: string; regionCode?: string; countryName?: string } | null>(null)
const [bucketMonth, setBucketMonth] = useState(0)
const [bucketYear, setBucketYear] = useState(0)
@@ -221,6 +230,41 @@ export default function AtlasPage(): React.ReactElement {
.catch(() => {})
}, [])
// Load visited regions (geocoded from places/trips) — once on mount
useEffect(() => {
apiClient.get(`/addons/atlas/regions?_t=${Date.now()}`)
.then(r => setVisitedRegions(r.data?.regions || {}))
.catch(() => {})
}, [])
// Load admin-1 GeoJSON for countries visible in the current viewport
const loadRegionsForViewportRef = useRef<() => void>(() => {})
const loadRegionsForViewport = (): void => {
if (!mapInstance.current) return
const bounds = mapInstance.current.getBounds()
const toLoad: string[] = []
for (const [code, layer] of Object.entries(country_layer_by_a2_ref.current)) {
if (regionGeoCache.current[code]) continue
try {
if (bounds.intersects((layer as any).getBounds())) toLoad.push(code)
} catch {}
}
if (!toLoad.length) return
apiClient.get(`/addons/atlas/regions/geo?countries=${toLoad.join(',')}`)
.then(geoRes => {
const geo = geoRes.data
if (!geo?.features) return
let added = false
for (const c of toLoad) {
const features = geo.features.filter((f: any) => f.properties?.iso_a2?.toUpperCase() === c)
if (features.length > 0) { regionGeoCache.current[c] = { type: 'FeatureCollection', features }; added = true }
}
if (added) setRegionGeoLoaded(v => v + 1)
})
.catch(() => {})
}
loadRegionsForViewportRef.current = loadRegionsForViewport
// Initialize map — runs after loading is done and mapRef is available
useEffect(() => {
if (loading || !mapRef.current) return
@@ -230,7 +274,7 @@ export default function AtlasPage(): React.ReactElement {
center: [25, 0],
zoom: 3,
minZoom: 3,
maxZoom: 7,
maxZoom: 10,
zoomControl: false,
attributionControl: false,
maxBounds: [[-90, -220], [90, 220]],
@@ -246,7 +290,7 @@ export default function AtlasPage(): React.ReactElement {
: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png'
L.tileLayer(tileUrl, {
maxZoom: 8,
maxZoom: 10,
keepBuffer: 25,
updateWhenZooming: true,
updateWhenIdle: false,
@@ -257,14 +301,49 @@ export default function AtlasPage(): React.ReactElement {
// Preload adjacent zoom level tiles
L.tileLayer(tileUrl, {
maxZoom: 8,
maxZoom: 10,
keepBuffer: 10,
opacity: 0,
tileSize: 256,
crossOrigin: true,
}).addTo(map)
// Custom pane for region layer — above overlay (z-index 400)
map.createPane('regionPane')
map.getPane('regionPane')!.style.zIndex = '401'
mapInstance.current = map
// Zoom-based region switching
map.on('zoomend', () => {
const z = map.getZoom()
const shouldShow = z >= 5
setShowRegions(shouldShow)
const overlayPane = map.getPane('overlayPane')
if (overlayPane) {
overlayPane.style.opacity = shouldShow ? '0.35' : '1'
overlayPane.style.pointerEvents = shouldShow ? 'none' : 'auto'
}
if (shouldShow) {
// Re-add region layer if it was removed while zoomed out
if (regionLayerRef.current && !map.hasLayer(regionLayerRef.current)) {
regionLayerRef.current.addTo(map)
}
loadRegionsForViewportRef.current()
} else {
// Physically remove region layer so its SVG paths can't intercept events
if (regionTooltipRef.current) regionTooltipRef.current.style.display = 'none'
if (regionLayerRef.current && map.hasLayer(regionLayerRef.current)) {
regionLayerRef.current.resetStyle()
regionLayerRef.current.removeFrom(map)
}
}
})
map.on('moveend', () => {
if (map.getZoom() >= 6) loadRegionsForViewportRef.current()
})
return () => { map.remove(); mapInstance.current = null }
}, [dark, loading])
@@ -339,10 +418,7 @@ export default function AtlasPage(): React.ReactElement {
})
layer.on('click', () => {
if (c.placeCount === 0 && c.tripCount === 0) {
// Manually marked only — show unmark popup
handleUnmarkCountry(c.code)
} else {
loadCountryDetail(c.code)
}
})
layer.on('mouseover', (e) => {
@@ -379,9 +455,153 @@ export default function AtlasPage(): React.ReactElement {
mapInstance.current.setView(currentCenter, currentZoom, { animate: false })
}, [geoData, data, dark])
// Render sub-national region layer (zoom >= 5)
useEffect(() => {
if (!mapInstance.current) return
// Remove existing region layer
if (regionLayerRef.current) {
mapInstance.current.removeLayer(regionLayerRef.current)
regionLayerRef.current = null
}
if (Object.keys(regionGeoCache.current).length === 0) return
// Build set of visited region codes first
const visitedRegionCodes = new Set<string>()
const visitedRegionNames = new Set<string>()
const regionPlaceCounts: Record<string, number> = {}
for (const [, regions] of Object.entries(visitedRegions)) {
for (const r of regions) {
visitedRegionCodes.add(r.code)
visitedRegionNames.add(r.name.toLowerCase())
regionPlaceCounts[r.code] = r.placeCount
regionPlaceCounts[r.name.toLowerCase()] = r.placeCount
}
}
// Match feature by ISO code OR region name
const isVisitedFeature = (f: any) => {
if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true
const name = (f.properties?.name || '').toLowerCase()
if (visitedRegionNames.has(name)) return true
// Fuzzy: check if any visited name is contained in feature name or vice versa
for (const vn of visitedRegionNames) {
if (name.includes(vn) || vn.includes(name)) return true
}
return false
}
// Include ALL region features — visited ones get colored fill, unvisited get outline only
const allFeatures: any[] = []
for (const geo of Object.values(regionGeoCache.current)) {
for (const f of geo.features) {
allFeatures.push(f)
}
}
if (allFeatures.length === 0) return
// Use same colors as country layer
const VISITED_COLORS = ['#6366f1','#ec4899','#14b8a6','#f97316','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#e11d48','#0d9488','#7c3aed','#2563eb','#dc2626','#059669','#d946ef']
const countryA3Set = data ? data.countries.map(c => A2_TO_A3[c.code]).filter(Boolean) : []
const countryColorMap: Record<string, string> = {}
countryA3Set.forEach((a3, i) => { countryColorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] })
// Map country A2 code to country color
const a2ColorMap: Record<string, string> = {}
if (data) data.countries.forEach(c => { if (A2_TO_A3[c.code] && countryColorMap[A2_TO_A3[c.code]]) a2ColorMap[c.code] = countryColorMap[A2_TO_A3[c.code]] })
const mergedGeo = { type: 'FeatureCollection', features: allFeatures }
const svgRenderer = L.svg({ pane: 'regionPane' })
regionLayerRef.current = L.geoJSON(mergedGeo as any, {
renderer: svgRenderer,
interactive: true,
pane: 'regionPane',
style: (feature) => {
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
const visited = isVisitedFeature(feature)
return visited ? {
fillColor: a2ColorMap[countryA2] || '#6366f1',
fillOpacity: 0.85,
color: dark ? '#888' : '#64748b',
weight: 1.2,
} : {
fillColor: dark ? '#ffffff' : '#000000',
fillOpacity: 0.03,
color: dark ? '#555' : '#94a3b8',
weight: 1,
}
},
onEachFeature: (feature, layer) => {
const regionName = feature?.properties?.name || ''
const countryName = feature?.properties?.admin || ''
const regionCode = feature?.properties?.iso_3166_2 || ''
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
const visited = isVisitedFeature(feature)
const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || 0
layer.on('click', () => {
if (!countryA2) return
if (visited) {
const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode)
if (regionEntry?.manuallyMarked) {
setConfirmActionRef.current({
type: 'unmark-region',
code: countryA2,
name: regionName,
regionCode,
countryName,
})
} else {
loadCountryDetailRef.current(countryA2)
}
} else {
setConfirmActionRef.current({
type: 'choose-region',
code: countryA2, // country A2 code — used for flag display
name: regionName, // region name — shown as heading
regionCode,
countryName,
})
}
})
layer.on('mouseover', (e: any) => {
e.target.setStyle(visited
? { fillOpacity: 0.95, weight: 2, color: dark ? '#818cf8' : '#4f46e5' }
: { fillOpacity: 0.15, fillColor: dark ? '#818cf8' : '#4f46e5', weight: 1.5, color: dark ? '#818cf8' : '#4f46e5' }
)
const tt = regionTooltipRef.current
if (tt) {
tt.style.display = 'block'
tt.style.left = e.originalEvent.clientX + 12 + 'px'
tt.style.top = e.originalEvent.clientY - 10 + 'px'
tt.innerHTML = visited
? `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div><div style="margin-top:5px;font-size:11px"><b>${count}</b> ${count === 1 ? 'place' : 'places'}</div>`
: `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div>`
}
})
layer.on('mousemove', (e: any) => {
const tt = regionTooltipRef.current
if (tt) { tt.style.left = e.originalEvent.clientX + 12 + 'px'; tt.style.top = e.originalEvent.clientY - 10 + 'px' }
})
layer.on('mouseout', (e: any) => {
regionLayerRef.current?.resetStyle(e.target)
const tt = regionTooltipRef.current
if (tt) tt.style.display = 'none'
})
},
})
// Only add to map if currently in region mode — otherwise hold it ready for when user zooms in
if (mapInstance.current.getZoom() >= 6) {
regionLayerRef.current.addTo(mapInstance.current)
}
}, [regionGeoLoaded, visitedRegions, dark, t])
const handleMarkCountry = (code: string, name: string): void => {
setConfirmAction({ type: 'choose', code, name })
}
handleMarkCountryRef.current = handleMarkCountry
setConfirmActionRef.current = setConfirmAction
const handleUnmarkCountry = (code: string): void => {
const country = data?.countries.find(c => c.code === code)
@@ -435,6 +655,12 @@ export default function AtlasPage(): React.ReactElement {
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
}
})
setVisitedRegions(prev => {
if (!prev[code]) return prev
const next = { ...prev }
delete next[code]
return next
})
}
}
@@ -512,6 +738,7 @@ export default function AtlasPage(): React.ReactElement {
setCountryDetail(r.data)
} catch { /* */ }
}
loadCountryDetailRef.current = loadCountryDetail
const stats = data?.stats || { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 }
const countries = data?.countries || []
@@ -533,6 +760,18 @@ export default function AtlasPage(): React.ReactElement {
<div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0 }}>
{/* Map */}
<div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} />
{/* Region tooltip (custom, always on top, ref-controlled to avoid re-renders) */}
<div ref={regionTooltipRef} style={{
position: 'fixed', display: 'none',
zIndex: 9999, pointerEvents: 'none',
background: dark ? 'rgba(15,15,20,0.92)' : 'rgba(255,255,255,0.96)',
color: dark ? '#fff' : '#111',
borderRadius: 10, padding: '10px 14px',
boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'}`,
fontSize: 12, minWidth: 120,
}} />
<div
className="absolute z-20 flex justify-center"
style={{ top: 14, left: 0, right: 0, pointerEvents: 'none' }}
@@ -769,6 +1008,50 @@ export default function AtlasPage(): React.ReactElement {
</div>
)}
{confirmAction.type === 'choose-region' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{confirmAction.countryName && (
<p style={{ margin: '-8px 0 8px', fontSize: 12, color: 'var(--text-muted)' }}>{confirmAction.countryName}</p>
)}
<button onClick={async () => {
const { code: countryCode, name: rName, regionCode: rCode } = confirmAction
if (!rCode) return
try {
await apiClient.post(`/addons/atlas/region/${rCode}/mark`, { name: rName, country_code: countryCode })
setVisitedRegions(prev => {
const existing = prev[countryCode] || []
if (existing.find(r => r.code === rCode)) return prev
return { ...prev, [countryCode]: [...existing, { code: rCode, name: rName, placeCount: 0, manuallyMarked: true }] }
})
setData(prev => {
if (!prev || prev.countries.find(c => c.code === countryCode)) return prev
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
})
} catch {}
setConfirmAction(null)
}}
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<MapPin size={18} style={{ color: 'var(--text-primary)', flexShrink: 0 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.markVisited')}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.markRegionVisitedHint')}</div>
</div>
</button>
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' })}
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<Star size={18} style={{ color: '#fbbf24', flexShrink: 0 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.addToBucket')}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
</div>
</button>
</div>
)}
{confirmAction.type === 'unmark' && (
<>
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmark')}</p>
@@ -785,6 +1068,51 @@ export default function AtlasPage(): React.ReactElement {
</>
)}
{confirmAction.type === 'unmark-region' && (
<>
{confirmAction.countryName && (
<p style={{ margin: '-8px 0 8px', fontSize: 12, color: 'var(--text-muted)' }}>{confirmAction.countryName}</p>
)}
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmarkRegion')}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button onClick={() => setConfirmAction(null)}
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button onClick={async () => {
const { code: countryCode, regionCode: rCode } = confirmAction
if (!rCode) return
try {
await apiClient.delete(`/addons/atlas/region/${rCode}/mark`)
setVisitedRegions(prev => {
const remaining = (prev[countryCode] || []).filter(r => r.code !== rCode)
const next = { ...prev, [countryCode]: remaining }
if (remaining.length === 0) delete next[countryCode]
return next
})
// If no manually-marked regions remain, also remove country if it has no trips/places
setData(prev => {
if (!prev) return prev
const c = prev.countries.find(c => c.code === countryCode)
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode && r.manuallyMarked)
if (remainingRegions.length > 0) return prev
return {
...prev,
countries: prev.countries.filter(c => c.code !== countryCode),
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
}
})
} catch {}
setConfirmAction(null)
}}
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: '#ef4444', color: 'white' }}>
{t('atlas.unmark')}
</button>
</div>
</>
)}
{confirmAction.type === 'bucket' && (
<>
<p style={{ margin: '0 0 14px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.bucketWhen')}</p>
@@ -815,7 +1143,7 @@ export default function AtlasPage(): React.ReactElement {
</div>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'choose' })}
<button onClick={() => setConfirmAction({ ...confirmAction, type: confirmAction.regionCode ? 'choose-region' : 'choose' })}
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.back')}
</button>
+44 -12
View File
@@ -14,8 +14,8 @@ import ConfirmDialog from '../components/shared/ConfirmDialog'
import { useToast } from '../components/shared/Toast'
import {
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft,
LayoutGrid, List,
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft, Users,
LayoutGrid, List, Copy,
} from 'lucide-react'
import { useCanDo } from '../store/permissionsStore'
@@ -31,6 +31,7 @@ interface DashboardTrip {
owner_username?: string
day_count?: number
place_count?: number
shared_count?: number
[key: string]: string | number | boolean | null | undefined
}
@@ -58,12 +59,12 @@ function getTripStatus(trip: DashboardTrip): string | null {
function formatDate(dateStr: string | null | undefined, locale: string = 'en-US'): string | null {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })
}
function formatDateShort(dateStr: string | null | undefined, locale: string = 'en-US'): string | null {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
}
function sortTrips(trips: DashboardTrip[]): DashboardTrip[] {
@@ -141,6 +142,7 @@ function LiquidGlass({ children, dark, style, className = '', onClick }: LiquidG
interface TripCardProps {
trip: DashboardTrip
onEdit?: (trip: DashboardTrip) => void
onCopy?: (trip: DashboardTrip) => void
onDelete?: (trip: DashboardTrip) => void
onArchive?: (id: number) => void
onClick: (trip: DashboardTrip) => void
@@ -149,7 +151,7 @@ interface TripCardProps {
dark?: boolean
}
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement {
function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement {
const status = getTripStatus(trip)
const coverBg = trip.cover_image
@@ -188,10 +190,11 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
</div>
{/* Top-right actions */}
{(onEdit || onArchive || onDelete) && (
{(onEdit || onCopy || onArchive || onDelete) && (
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 6 }}
onClick={e => e.stopPropagation()}>
{onEdit && <IconBtn onClick={() => onEdit(trip)} title={t('common.edit')}><Edit2 size={14} /></IconBtn>}
{onCopy && <IconBtn onClick={() => onCopy(trip)} title={t('dashboard.copyTrip')}><Copy size={14} /></IconBtn>}
{onArchive && <IconBtn onClick={() => onArchive(trip.id)} title={t('dashboard.archive')}><Archive size={14} /></IconBtn>}
{onDelete && <IconBtn onClick={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>}
</div>
@@ -224,6 +227,9 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
<MapPin size={13} /> {trip.place_count || 0} {t('dashboard.places')}
</div>
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
<Users size={13} /> {trip.shared_count+1 || 0} {t('dashboard.members')}
</div>
</div>
</div>
</div>
@@ -232,7 +238,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
}
// ── Regular Trip Card ────────────────────────────────────────────────────────
function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
function TripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
const status = getTripStatus(trip)
const [hovered, setHovered] = useState(false)
@@ -307,12 +313,14 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omi
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<Stat label={t('dashboard.days')} value={trip.day_count || 0} />
<Stat label={t('dashboard.places')} value={trip.place_count || 0} />
<Stat label={t('dashboard.members')} value={trip.shared_count+1 || 0} />
</div>
{(onEdit || onArchive || onDelete) && (
{(onEdit || onCopy || onArchive || onDelete) && (
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid #f3f4f6', paddingTop: 10 }}
onClick={e => e.stopPropagation()}>
{onEdit && <CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label={t('common.edit')} />}
{onCopy && <CardAction onClick={() => onCopy(trip)} icon={<Copy size={12} />} label={t('dashboard.copyTrip')} />}
{onArchive && <CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label={t('dashboard.archive')} />}
{onDelete && <CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />}
</div>
@@ -323,7 +331,7 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omi
}
// ── List View Item ──────────────────────────────────────────────────────────
function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
function TripListItem({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
const status = getTripStatus(trip)
const [hovered, setHovered] = useState(false)
@@ -406,12 +414,16 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }:
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
<MapPin size={11} /> {trip.place_count || 0}
</div>
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
<Users size={11} /> {trip.shared_count+1 || 0}
</div>
</div>
{/* Actions */}
{(onEdit || onArchive || onDelete) && (
{(onEdit || onCopy || onArchive || onDelete) && (
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
{onEdit && <CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label="" />}
{onCopy && <CardAction onClick={() => onCopy(trip)} icon={<Copy size={12} />} label="" />}
{onArchive && <CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label="" />}
{onDelete && <CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label="" danger />}
</div>
@@ -424,6 +436,7 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }:
interface ArchivedRowProps {
trip: DashboardTrip
onEdit?: (trip: DashboardTrip) => void
onCopy?: (trip: DashboardTrip) => void
onUnarchive?: (id: number) => void
onDelete?: (trip: DashboardTrip) => void
onClick: (trip: DashboardTrip) => void
@@ -431,7 +444,7 @@ interface ArchivedRowProps {
locale: string
}
function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }: ArchivedRowProps): React.ReactElement {
function ArchivedRow({ trip, onEdit, onCopy, onUnarchive, onDelete, onClick, t, locale }: ArchivedRowProps): React.ReactElement {
return (
<div onClick={() => onClick(trip)} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px',
@@ -457,8 +470,13 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
</div>
)}
</div>
{(onEdit || onUnarchive || onDelete) && (
{(onEdit || onCopy || onUnarchive || onDelete) && (
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
{onCopy && <button onClick={() => onCopy(trip)} title={t('dashboard.copyTrip')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-muted)' }}>
<Copy size={12} />
</button>}
{onUnarchive && <button onClick={() => onUnarchive(trip.id)} title={t('dashboard.restore')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-muted)' }}>
@@ -649,6 +667,16 @@ export default function DashboardPage(): React.ReactElement {
setArchivedTrips(prev => prev.map(update))
}
const handleCopy = async (trip: DashboardTrip) => {
try {
const data = await tripsApi.copy(trip.id, { title: `${trip.title} (${t('dashboard.copySuffix')})` })
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.copied'))
} catch {
toast.error(t('dashboard.toast.copyError'))
}
}
const today = new Date().toISOString().split('T')[0]
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|| trips.find(t => t.start_date && t.start_date >= today)
@@ -797,6 +825,7 @@ export default function DashboardPage(): React.ReactElement {
trip={spotlight}
t={t} locale={locale} dark={dark}
onEdit={(can('trip_edit', spotlight) || can('trip_cover_upload', spotlight)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
onCopy={can('trip_create') ? handleCopy : undefined}
onDelete={can('trip_delete', spotlight) ? handleDelete : undefined}
onArchive={can('trip_archive', spotlight) ? handleArchive : undefined}
onClick={tr => navigate(`/trips/${tr.id}`)}
@@ -813,6 +842,7 @@ export default function DashboardPage(): React.ReactElement {
trip={trip}
t={t} locale={locale}
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
onCopy={can('trip_create') ? handleCopy : undefined}
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
onArchive={can('trip_archive', trip) ? handleArchive : undefined}
onClick={tr => navigate(`/trips/${tr.id}`)}
@@ -827,6 +857,7 @@ export default function DashboardPage(): React.ReactElement {
trip={trip}
t={t} locale={locale}
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
onCopy={can('trip_create') ? handleCopy : undefined}
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
onArchive={can('trip_archive', trip) ? handleArchive : undefined}
onClick={tr => navigate(`/trips/${tr.id}`)}
@@ -857,6 +888,7 @@ export default function DashboardPage(): React.ReactElement {
trip={trip}
t={t} locale={locale}
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
onCopy={can('trip_create') ? handleCopy : undefined}
onUnarchive={can('trip_archive', trip) ? handleUnarchive : undefined}
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
onClick={tr => navigate(`/trips/${tr.id}`)}
+150
View File
@@ -0,0 +1,150 @@
import React, { useEffect, useRef, useState } from 'react'
import { Bell, CheckCheck, Trash2 } from 'lucide-react'
import { useTranslation } from '../i18n'
import { useInAppNotificationStore } from '../store/inAppNotificationStore.ts'
import { useSettingsStore } from '../store/settingsStore'
import Navbar from '../components/Layout/Navbar'
import InAppNotificationItem from '../components/Notifications/InAppNotificationItem.tsx'
export default function InAppNotificationsPage(): React.ReactElement {
const { t } = useTranslation()
const { settings } = useSettingsStore()
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const { notifications, unreadCount, total, isLoading, hasMore, fetchNotifications, markAllRead, deleteAll } = useInAppNotificationStore()
const [unreadOnly, setUnreadOnly] = useState(false)
const loaderRef = useRef<HTMLDivElement>(null)
useEffect(() => {
fetchNotifications(true)
}, [])
// Reload when filter changes
useEffect(() => {
// We need to fetch with the unreadOnly filter — re-fetch from scratch
// The store fetchNotifications doesn't take a filter param directly,
// so we use the API directly for filtered view via a side channel.
// For now, reset and fetch — store always loads all, filter is client-side.
fetchNotifications(true)
}, [unreadOnly])
// Infinite scroll
useEffect(() => {
if (!loaderRef.current) return
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore && !isLoading) {
fetchNotifications(false)
}
}, { threshold: 0.1 })
observer.observe(loaderRef.current)
return () => observer.disconnect()
}, [hasMore, isLoading])
const displayed = unreadOnly ? notifications.filter(n => !n.is_read) : notifications
return (
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
<Navbar />
<div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="max-w-2xl mx-auto px-4 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('notifications.title')}
{unreadCount > 0 && (
<span className="ml-2 px-2 py-0.5 rounded-full text-xs font-medium align-middle inline-flex items-center justify-center"
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
{unreadCount}
</span>
)}
</h1>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>
{total} {total === 1 ? 'notification' : 'notifications'}
</p>
</div>
{/* Bulk actions */}
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors"
style={{ background: 'var(--bg-hover)', color: 'var(--text-secondary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-hover)'}
>
<CheckCheck className="w-4 h-4" />
<span className="hidden sm:inline">{t('notifications.markAllRead')}</span>
</button>
)}
{notifications.length > 0 && (
<button
onClick={deleteAll}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors text-red-500 hover:bg-red-500/10"
>
<Trash2 className="w-4 h-4" />
<span className="hidden sm:inline">{t('notifications.deleteAll')}</span>
</button>
)}
</div>
</div>
{/* Filter toggle */}
<div className="flex gap-2 mb-4">
<button
onClick={() => setUnreadOnly(false)}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
style={{
background: !unreadOnly ? 'var(--text-primary)' : 'var(--bg-hover)',
color: !unreadOnly ? 'var(--bg-primary)' : 'var(--text-secondary)',
}}
>
{t('notifications.all')}
</button>
<button
onClick={() => setUnreadOnly(true)}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
style={{
background: unreadOnly ? 'var(--text-primary)' : 'var(--bg-hover)',
color: unreadOnly ? 'var(--bg-primary)' : 'var(--text-secondary)',
}}
>
{t('notifications.unreadOnly')}
</button>
</div>
{/* Notification list */}
<div
className="rounded-xl border overflow-hidden"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
>
{isLoading && displayed.length === 0 ? (
<div className="flex items-center justify-center py-16">
<div className="w-6 h-6 border-2 border-slate-200 border-t-current rounded-full animate-spin" />
</div>
) : displayed.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center gap-3">
<Bell className="w-12 h-12" style={{ color: 'var(--text-faint)' }} />
<p className="text-base font-medium" style={{ color: 'var(--text-muted)' }}>{t('notifications.empty')}</p>
<p className="text-sm" style={{ color: 'var(--text-faint)' }}>{t('notifications.emptyDescription')}</p>
</div>
) : (
displayed.map(n => (
<InAppNotificationItem key={n.id} notification={n} />
))
)}
{/* Infinite scroll trigger */}
{hasMore && (
<div ref={loaderRef} className="flex items-center justify-center py-4">
{isLoading && <div className="w-5 h-5 border-2 border-slate-200 border-t-current rounded-full animate-spin" />}
</div>
)}
</div>
</div>
</div>
</div>
)
}
+18 -8
View File
@@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
import { authApi } from '../api/client'
import { getApiErrorMessage } from '../types'
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound } from 'lucide-react'
interface AppConfig {
@@ -33,6 +34,16 @@ export default function LoginPage(): React.ReactElement {
const { setLanguageLocal } = useSettingsStore()
const navigate = useNavigate()
const redirectTarget = useMemo(() => {
const params = new URLSearchParams(window.location.search)
const redirect = params.get('redirect')
// Only allow relative paths starting with / to prevent open redirect attacks
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//') && !redirect.startsWith('/\\')) {
return redirect
}
return '/dashboard'
}, [])
useEffect(() => {
const params = new URLSearchParams(window.location.search)
@@ -49,7 +60,6 @@ export default function LoginPage(): React.ReactElement {
setError('Invalid or expired invite link')
})
window.history.replaceState({}, '', window.location.pathname)
return
}
if (oidcCode) {
@@ -86,7 +96,7 @@ export default function LoginPage(): React.ReactElement {
if (config) {
setAppConfig(config)
if (!config.has_users) setMode('register')
if (config.oidc_only_mode && config.oidc_configured && config.has_users) {
if (config.oidc_only_mode && config.oidc_configured && config.has_users && !invite) {
window.location.href = '/api/auth/oidc/login'
}
}
@@ -99,7 +109,7 @@ export default function LoginPage(): React.ReactElement {
try {
await demoLogin()
setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
setTimeout(() => navigate(redirectTarget), 2600)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('login.demoFailed'))
} finally {
@@ -128,7 +138,7 @@ export default function LoginPage(): React.ReactElement {
await authApi.changePassword({ current_password: savedLoginPassword, new_password: newPassword })
await loadUser({ silent: true })
setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
setTimeout(() => navigate(redirectTarget), 2600)
return
}
if (mode === 'login' && mfaStep) {
@@ -145,7 +155,7 @@ export default function LoginPage(): React.ReactElement {
return
}
setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
setTimeout(() => navigate(redirectTarget), 2600)
return
}
if (mode === 'register') {
@@ -169,9 +179,9 @@ export default function LoginPage(): React.ReactElement {
}
}
setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
setTimeout(() => navigate(redirectTarget), 2600)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('login.error'))
setError(getApiErrorMessage(err, t('login.error')))
setIsLoading(false)
}
}
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -106,7 +106,7 @@ export default function SharedTripPage() {
{(trip.start_date || trip.end_date) && (
<div style={{ marginTop: 10, display: 'inline-flex', alignItems: 'center', gap: 8, padding: '6px 14px', borderRadius: 20, background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(4px)', border: '1px solid rgba(255,255,255,0.08)' }}>
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8 }}>
{[trip.start_date, trip.end_date].filter(Boolean).map((d: string) => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })).join(' — ')}
{[trip.start_date, trip.end_date].filter(Boolean).map((d: string) => new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })).join(' — ')}
</span>
{days?.length > 0 && <span style={{ fontSize: 11, opacity: 0.4 }}>·</span>}
{days?.length > 0 && <span style={{ fontSize: 11, opacity: 0.5 }}>{days.length} {t('shared.days')}</span>}
@@ -199,7 +199,7 @@ export default function SharedTripPage() {
<div style={{ width: 28, height: 28, borderRadius: '50%', background: selectedDay === day.id ? '#111827' : '#f3f4f6', color: selectedDay === day.id ? 'white' : '#6b7280', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, flexShrink: 0 }}>{di + 1}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: '#111827' }}>{day.title || `Day ${day.day_number}`}</div>
{day.date && <div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>{new Date(day.date + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>}
{day.date && <div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>}
</div>
{dayAccs.map((acc: any) => (
<span key={acc.id} style={{ fontSize: 9, padding: '2px 6px', borderRadius: 4, background: '#f3f4f6', color: '#6b7280', display: 'flex', alignItems: 'center', gap: 3 }}>
@@ -274,7 +274,7 @@ export default function SharedTripPage() {
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
const date = r.reservation_time ? new Date(r.reservation_time.includes('T') ? r.reservation_time : r.reservation_time + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' }) : ''
const date = r.reservation_time ? new Date((r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
return (
<div key={r.id} style={{ background: 'var(--bg-card, white)', borderRadius: 10, padding: '12px 16px', border: '1px solid var(--border-faint, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
+152 -35
View File
@@ -17,12 +17,13 @@ import { ReservationModal } from '../components/Planner/ReservationModal'
import MemoriesPanel from '../components/Memories/MemoriesPanel'
import ReservationsPanel from '../components/Planner/ReservationsPanel'
import PackingListPanel from '../components/Packing/PackingListPanel'
import TodoListPanel from '../components/Todo/TodoListPanel'
import FileManager from '../components/Files/FileManager'
import BudgetPanel from '../components/Budget/BudgetPanel'
import CollabPanel from '../components/Collab/CollabPanel'
import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast'
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Camera, Users } from 'lucide-react'
import { useTranslation } from '../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client'
import ConfirmDialog from '../components/shared/ConfirmDialog'
@@ -30,7 +31,41 @@ import { useResizablePanels } from '../hooks/useResizablePanels'
import { useTripWebSocket } from '../hooks/useTripWebSocket'
import { useRouteCalculation } from '../hooks/useRouteCalculation'
import { usePlaceSelection } from '../hooks/usePlaceSelection'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../types'
import { usePlannerHistory } from '../hooks/usePlannerHistory'
import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types'
import { ListTodo } from 'lucide-react'
function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) {
const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => {
return (sessionStorage.getItem(`trip-lists-subtab-${tripId}`) as 'packing' | 'todo') || 'packing'
})
const setSubTabPersist = (tab: 'packing' | 'todo') => { setSubTab(tab); sessionStorage.setItem(`trip-lists-subtab-${tripId}`, tab) }
const { t } = useTranslation()
return (
<div>
<div style={{ display: 'flex', gap: 4, padding: '4px 16px 0', borderBottom: '1px solid var(--border-faint)', marginBottom: 8 }}>
{([
{ id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck },
{ id: 'todo' as const, label: t('todo.subtab.todo'), icon: ListTodo },
]).map(tab => (
<button key={tab.id} onClick={() => setSubTabPersist(tab.id)}
style={{
display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, fontWeight: 500, padding: '8px 14px',
border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: 'none',
color: subTab === tab.id ? 'var(--text-primary)' : 'var(--text-faint)',
borderBottom: subTab === tab.id ? '2px solid var(--text-primary)' : '2px solid transparent',
marginBottom: -1, transition: 'color 0.15s',
}}>
<tab.icon size={14} />
{tab.label}
</button>
))}
</div>
{subTab === 'packing' && <PackingListPanel tripId={tripId} items={packingItems} />}
{subTab === 'todo' && <TodoListPanel tripId={tripId} items={todoItems} />}
</div>
)
}
export default function TripPlannerPage(): React.ReactElement | null {
const { id: tripId } = useParams<{ id: string }>()
@@ -43,6 +78,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
const places = useTripStore(s => s.places)
const assignments = useTripStore(s => s.assignments)
const packingItems = useTripStore(s => s.packingItems)
const todoItems = useTripStore(s => s.todoItems)
const categories = useTripStore(s => s.categories)
const reservations = useTripStore(s => s.reservations)
const budgetItems = useTripStore(s => s.budgetItems)
@@ -53,6 +89,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
const tripActions = useRef(useTripStore.getState()).current
const can = useCanDo()
const canUploadFiles = can('file_upload', trip)
const { pushUndo, undo, canUndo, lastActionLabel } = usePlannerHistory()
const handleUndo = useCallback(async () => {
const label = lastActionLabel
await undo()
toast.info(t('undo.done', { action: label ?? '' }))
}, [undo, lastActionLabel, toast])
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true })
const [tripAccommodations, setTripAccommodations] = useState<Accommodation[]>([])
@@ -70,7 +113,9 @@ export default function TripPlannerPage(): React.ReactElement | null {
addonsApi.enabled().then(data => {
const map = {}
data.addons.forEach(a => { map[a.id] = true })
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, memories: !!map.memories })
// Check if any photo provider is enabled (for memories tab to show)
const hasPhotoProviders = data.addons.some(a => a.type === 'photo_provider')
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, memories: hasPhotoProviders })
}).catch(() => {})
authApi.getAppConfig().then(config => {
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
@@ -78,13 +123,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
}, [])
const TRIP_TABS = [
{ id: 'plan', label: t('trip.tabs.plan') },
{ id: 'buchungen', label: t('trip.tabs.reservations'), shortLabel: t('trip.tabs.reservationsShort') },
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []),
...(enabledAddons.memories ? [{ id: 'memories', label: t('memories.title') }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name') }] : []),
{ id: 'plan', label: t('trip.tabs.plan'), icon: Map },
{ id: 'buchungen', label: t('trip.tabs.reservations'), shortLabel: t('trip.tabs.reservationsShort'), icon: Ticket },
...(enabledAddons.packing ? [{ id: 'listen', label: t('trip.tabs.lists'), shortLabel: t('trip.tabs.listsShort'), icon: PackageCheck }] : []),
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget'), icon: Wallet }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files'), icon: FolderOpen }] : []),
...(enabledAddons.memories ? [{ id: 'memories', label: t('memories.title'), icon: Camera }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name'), icon: Users }] : []),
]
const [activeTab, setActiveTab] = useState<string>(() => {
@@ -92,6 +137,14 @@ export default function TripPlannerPage(): React.ReactElement | null {
return saved || 'plan'
})
useEffect(() => {
const validTabIds = TRIP_TABS.map(t => t.id)
if (!validTabIds.includes(activeTab)) {
setActiveTab('plan')
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
}
}, [enabledAddons])
const handleTabChange = (tabId: string): void => {
setActiveTab(tabId)
sessionStorage.setItem(`trip-tab-${tripId}`, tabId)
@@ -267,8 +320,14 @@ export default function TripPlannerPage(): React.ReactElement | null {
}
}
toast.success(t('trip.toast.placeAdded'))
if (place?.id) {
const capturedId = place.id
pushUndo(t('undo.addPlace'), async () => {
await tripActions.deletePlace(tripId, capturedId)
})
}
}
}, [editingPlace, editingAssignmentId, tripId, toast])
}, [editingPlace, editingAssignmentId, tripId, toast, pushUndo])
const handleDeletePlace = useCallback((placeId) => {
setDeletePlaceId(placeId)
@@ -276,33 +335,83 @@ export default function TripPlannerPage(): React.ReactElement | null {
const confirmDeletePlace = useCallback(async () => {
if (!deletePlaceId) return
const state = useTripStore.getState()
const capturedPlace = state.places.find(p => p.id === deletePlaceId)
const capturedAssignments = Object.entries(state.assignments).flatMap(([dayId, as]) =>
as.filter(a => a.place?.id === deletePlaceId).map(a => ({ dayId: Number(dayId), orderIndex: a.order_index }))
)
try {
await tripActions.deletePlace(tripId, deletePlaceId)
if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null)
toast.success(t('trip.toast.placeDeleted'))
if (capturedPlace) {
pushUndo(t('undo.deletePlace'), async () => {
const newPlace = await tripActions.addPlace(tripId, {
name: capturedPlace.name,
description: capturedPlace.description,
lat: capturedPlace.lat,
lng: capturedPlace.lng,
address: capturedPlace.address,
category_id: capturedPlace.category_id,
icon: capturedPlace.icon,
price: capturedPlace.price,
})
for (const { dayId, orderIndex } of capturedAssignments) {
await tripActions.assignPlaceToDay(tripId, dayId, newPlace.id, orderIndex)
}
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}, [deletePlaceId, tripId, toast, selectedPlaceId])
}, [deletePlaceId, tripId, toast, selectedPlaceId, pushUndo])
const handleAssignToDay = useCallback(async (placeId, dayId, position) => {
const target = dayId || selectedDayId
if (!target) { toast.error(t('trip.toast.selectDay')); return }
try {
await tripActions.assignPlaceToDay(tripId, target, placeId, position)
const assignment = await tripActions.assignPlaceToDay(tripId, target, placeId, position)
toast.success(t('trip.toast.assignedToDay'))
updateRouteForDay(target)
if (assignment?.id) {
const capturedAssignmentId = assignment.id
const capturedTarget = target
pushUndo(t('undo.assignPlace'), async () => {
await tripActions.removeAssignment(tripId, capturedTarget, capturedAssignmentId)
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}, [selectedDayId, tripId, toast, updateRouteForDay])
}, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo])
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
const state = useTripStore.getState()
const capturedAssignment = (state.assignments[String(dayId)] || []).find(a => a.id === assignmentId)
const capturedPlaceId = capturedAssignment?.place?.id
const capturedOrderIndex = capturedAssignment?.order_index ?? 0
try {
await tripActions.removeAssignment(tripId, dayId, assignmentId)
if (capturedPlaceId != null) {
const capturedDayId = dayId
const capturedPos = capturedOrderIndex
pushUndo(t('undo.removeAssignment'), async () => {
await tripActions.assignPlaceToDay(tripId, capturedDayId, capturedPlaceId, capturedPos)
})
}
}
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
}, [tripId, toast, updateRouteForDay])
}, [tripId, toast, updateRouteForDay, pushUndo])
const handleReorder = useCallback((dayId, orderedIds) => {
const prevIds = (useTripStore.getState().assignments[String(dayId)] || [])
.slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
try {
tripActions.reorderAssignments(tripId, dayId, orderedIds).catch(() => {})
tripActions.reorderAssignments(tripId, dayId, orderedIds)
.then(() => {
const capturedDayId = dayId
const capturedPrevIds = prevIds
pushUndo(t('undo.reorder'), async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
})
})
.catch(() => {})
// Update route immediately from orderedIds
const dayItems = useTripStore.getState().assignments[String(dayId)] || []
const ordered = orderedIds.map(id => dayItems.find(a => a.id === id)).filter(Boolean)
@@ -312,7 +421,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
setRouteInfo(null)
}
catch { toast.error(t('trip.toast.reorderError')) }
}, [tripId, toast])
}, [tripId, toast, pushUndo])
const handleUpdateDayTitle = useCallback(async (dayId, title) => {
try { await tripActions.updateDayTitle(tripId, dayId, title) }
@@ -397,10 +506,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
background: 'var(--bg-primary)', ...fontStyle,
}}>
<style>{`
@keyframes planeFloat {
0%, 100% { transform: translateY(0px) rotate(-2deg); }
50% { transform: translateY(-12px) rotate(2deg); }
}
@keyframes dotPulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
@@ -410,10 +515,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
to { opacity: 1; transform: translateY(0); }
}
`}</style>
<div style={{ animation: 'planeFloat 2.5s ease-in-out infinite', marginBottom: 28 }}>
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="var(--text-primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.8 }}>
<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z" />
</svg>
<div style={{ marginBottom: 28 }}>
<img
src={document.documentElement.classList.contains('dark') ? '/icons/trek-loading-light.gif' : '/icons/trek-loading-dark.gif'}
alt="Loading"
width={64}
height={64}
/>
</div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', letterSpacing: '-0.3px', marginBottom: 6, animation: 'fadeInUp 0.5s ease-out' }}>
{trip?.title || 'TREK'}
@@ -452,10 +560,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
}}>
{TRIP_TABS.map(tab => {
const isActive = activeTab === tab.id
const TabIcon = tab.icon
return (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
title={tab.label}
style={{
flexShrink: 0,
padding: '5px 14px', borderRadius: 20, border: 'none', cursor: 'pointer',
@@ -463,13 +573,14 @@ export default function TripPlannerPage(): React.ReactElement | null {
background: isActive ? 'var(--accent)' : 'transparent',
color: isActive ? 'var(--accent-text)' : 'var(--text-muted)',
fontFamily: 'inherit', transition: 'all 0.15s',
display: 'flex', alignItems: 'center', gap: 5,
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = isActive ? 'var(--accent-text)' : 'var(--text-primary)' }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = isActive ? 'var(--accent-text)' : 'var(--text-muted)' }}
>{tab.shortLabel
? <><span className="sm:hidden">{tab.shortLabel}</span><span className="hidden sm:inline">{tab.label}</span></>
: tab.label
}</button>
>
{TabIcon && <><TabIcon size={20} className="sm:hidden" /><TabIcon size={15} className="hidden sm:block" /></>}
<span className="hidden sm:inline">{tab.shortLabel || tab.label}</span>
</button>
)
})}
</div>
@@ -496,6 +607,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
leftWidth={leftCollapsed ? 0 : leftWidth}
rightWidth={rightCollapsed ? 0 : rightWidth}
hasInspector={!!selectedPlace}
hasDayDetail={!!showDayDetail && !selectedPlace}
/>
@@ -550,6 +662,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
accommodations={tripAccommodations}
onNavigateToFiles={() => handleTabChange('dateien')}
onExpandedDaysChange={setExpandedDayIds}
pushUndo={pushUndo}
canUndo={canUndo}
lastActionLabel={lastActionLabel}
onUndo={handleUndo}
/>
{!leftCollapsed && (
<div
@@ -610,6 +726,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
onCategoryFilterChange={setMapCategoryFilter}
pushUndo={pushUndo}
/>
</div>
</div>
@@ -645,7 +762,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
reservations={reservations}
lat={geoPlace?.lat}
lng={geoPlace?.lng}
onClose={() => setShowDayDetail(null)}
onClose={() => { setShowDayDetail(null); handleSelectDay(null) }}
onAccommodationChange={loadAccommodations}
leftWidth={isMobile ? 0 : (leftCollapsed ? 0 : leftWidth)}
rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
@@ -762,8 +879,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} />
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} pushUndo={pushUndo} />
}
</div>
</div>
@@ -789,9 +906,9 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div>
)}
{activeTab === 'packliste' && (
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1200, margin: '0 auto', width: '100%', padding: '8px 0' }}>
<PackingListPanel tripId={tripId} items={packingItems} />
{activeTab === 'listen' && (
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1800, margin: '0 auto', width: '100%', padding: '8px 0' }}>
<ListsContainer tripId={tripId} packingItems={packingItems} todoItems={todoItems} />
</div>
)}
+23 -11
View File
@@ -41,11 +41,16 @@ export default function VacayPage(): React.ReactElement {
if (selectedYear) { loadEntries(selectedYear); loadStats(selectedYear); loadHolidays(selectedYear) }
}, [selectedYear])
const handleAddYear = () => {
const handleAddNextYear = () => {
const nextYear = years.length > 0 ? Math.max(...years) + 1 : new Date().getFullYear()
addYear(nextYear)
}
const handleAddPrevYear = () => {
const prevYear = years.length > 0 ? Math.min(...years) - 1 : new Date().getFullYear()
addYear(prevYear)
}
if (loading) {
return (
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
@@ -62,20 +67,27 @@ export default function VacayPage(): React.ReactElement {
<>
{/* Year Selector */}
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center mb-2">
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>{t('vacay.year')}</span>
<button onClick={handleAddYear} className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }} title={t('vacay.addYear')}>
<Plus size={14} />
</button>
</div>
<div className="flex items-center justify-between mb-2">
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx > 0) setSelectedYear(years[idx - 1]) }} disabled={years.indexOf(selectedYear) <= 0} className="p-1 lg:p-1 p-2 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
<ChevronLeft size={16} style={{ color: 'var(--text-muted)' }} />
</button>
<div className="flex items-center gap-1">
<button onClick={handleAddPrevYear} className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }} title={t('vacay.addPrevYear')}>
<Plus size={14} />
</button>
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx > 0) setSelectedYear(years[idx - 1]) }} disabled={years.indexOf(selectedYear) <= 0} className="p-1 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
<ChevronLeft size={16} style={{ color: 'var(--text-muted)' }} />
</button>
</div>
<span className="text-xl font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{selectedYear}</span>
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx < years.length - 1) setSelectedYear(years[idx + 1]) }} disabled={years.indexOf(selectedYear) >= years.length - 1} className="p-1 lg:p-1 p-2 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
<ChevronRight size={16} style={{ color: 'var(--text-muted)' }} />
</button>
<div className="flex items-center gap-1">
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx < years.length - 1) setSelectedYear(years[idx + 1]) }} disabled={years.indexOf(selectedYear) >= years.length - 1} className="p-1 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
<ChevronRight size={16} style={{ color: 'var(--text-muted)' }} />
</button>
<button onClick={handleAddNextYear} className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }} title={t('vacay.addYear')}>
<Plus size={14} />
</button>
</div>
</div>
<div className="grid grid-cols-4 gap-1">
{years.map(y => (
+16
View File
@@ -4,9 +4,22 @@ import { addonsApi } from '../api/client'
interface Addon {
id: string
name: string
description?: string
type: string
icon: string
enabled: boolean
config?: Record<string, unknown>
fields?: Array<{
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 AddonState {
@@ -30,6 +43,9 @@ export const useAddonStore = create<AddonState>((set, get) => ({
},
isEnabled: (id: string) => {
if (id === 'memories') {
return get().addons.some(a => a.type === 'photo_provider' && a.enabled)
}
return get().addons.some(a => a.id === id && a.enabled)
},
}))
+14
View File
@@ -21,6 +21,7 @@ interface AuthState {
isLoading: boolean
error: string | null
demoMode: boolean
devMode: boolean
hasMapsKey: boolean
serverTimezone: string
/** Server policy: all users must enable MFA */
@@ -39,6 +40,7 @@ interface AuthState {
uploadAvatar: (file: File) => Promise<AvatarResponse>
deleteAvatar: () => Promise<void>
setDemoMode: (val: boolean) => void
setDevMode: (val: boolean) => void
setHasMapsKey: (val: boolean) => void
setServerTimezone: (tz: string) => void
setAppRequireMfa: (val: boolean) => void
@@ -46,18 +48,23 @@ interface AuthState {
demoLogin: () => Promise<AuthResponse>
}
// Sequence counter to prevent stale loadUser responses from overwriting fresh auth state
let authSequence = 0
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
isAuthenticated: false,
isLoading: true,
error: null,
demoMode: localStorage.getItem('demo_mode') === 'true',
devMode: false,
hasMapsKey: false,
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
appRequireMfa: false,
tripRemindersEnabled: false,
login: async (email: string, password: string) => {
authSequence++
set({ isLoading: true, error: null })
try {
const data = await authApi.login({ email, password }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
@@ -81,6 +88,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
completeMfaLogin: async (mfaToken: string, code: string) => {
authSequence++
set({ isLoading: true, error: null })
try {
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') })
@@ -100,6 +108,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
register: async (username: string, email: string, password: string, invite_token?: string) => {
authSequence++
set({ isLoading: true, error: null })
try {
const data = await authApi.register({ username, email, password, invite_token })
@@ -135,10 +144,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
loadUser: async (opts?: { silent?: boolean }) => {
const seq = authSequence
const silent = !!opts?.silent
if (!silent) set({ isLoading: true })
try {
const data = await authApi.me()
if (seq !== authSequence) return // stale response — a login/register happened meanwhile
set({
user: data.user,
isAuthenticated: true,
@@ -146,6 +157,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
})
connect()
} catch (err: unknown) {
if (seq !== authSequence) return // stale response — ignore
// Only clear auth state on 401 (invalid/expired token), not on network errors
const isAuthError = err && typeof err === 'object' && 'response' in err &&
(err as { response?: { status?: number } }).response?.status === 401
@@ -209,12 +221,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({ demoMode: val })
},
setDevMode: (val: boolean) => set({ devMode: val }),
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
demoLogin: async () => {
authSequence++
set({ isLoading: true, error: null })
try {
const data = await authApi.demoLogin()
+192
View File
@@ -0,0 +1,192 @@
import { create } from 'zustand'
import { inAppNotificationsApi } from '../api/client'
export interface InAppNotification {
id: number
type: 'simple' | 'boolean' | 'navigate'
scope: 'trip' | 'user' | 'admin'
target: number
sender_id: number | null
sender_username: string | null
sender_avatar: string | null
recipient_id: number
title_key: string
title_params: Record<string, string>
text_key: string
text_params: Record<string, string>
positive_text_key: string | null
negative_text_key: string | null
response: 'positive' | 'negative' | null
navigate_text_key: string | null
navigate_target: string | null
is_read: boolean
created_at: string
}
interface RawNotification extends Omit<InAppNotification, 'title_params' | 'text_params' | 'is_read'> {
title_params: string | Record<string, string>
text_params: string | Record<string, string>
is_read: number | boolean
}
function normalizeNotification(raw: RawNotification): InAppNotification {
return {
...raw,
title_params: typeof raw.title_params === 'string' ? JSON.parse(raw.title_params || '{}') : raw.title_params,
text_params: typeof raw.text_params === 'string' ? JSON.parse(raw.text_params || '{}') : raw.text_params,
is_read: Boolean(raw.is_read),
}
}
interface NotificationState {
notifications: InAppNotification[]
unreadCount: number
total: number
isLoading: boolean
hasMore: boolean
fetchNotifications: (reset?: boolean) => Promise<void>
fetchUnreadCount: () => Promise<void>
markRead: (id: number) => Promise<void>
markUnread: (id: number) => Promise<void>
markAllRead: () => Promise<void>
deleteNotification: (id: number) => Promise<void>
deleteAll: () => Promise<void>
respondToBoolean: (id: number, response: 'positive' | 'negative') => Promise<void>
handleNewNotification: (notification: RawNotification) => void
handleUpdatedNotification: (notification: RawNotification) => void
}
const PAGE_SIZE = 20
export const useInAppNotificationStore = create<NotificationState>((set, get) => ({
notifications: [],
unreadCount: 0,
total: 0,
isLoading: false,
hasMore: false,
fetchNotifications: async (reset = false) => {
const { notifications, isLoading } = get()
if (isLoading) return
set({ isLoading: true })
try {
const offset = reset ? 0 : notifications.length
const data = await inAppNotificationsApi.list({ limit: PAGE_SIZE, offset })
const normalized = (data.notifications as RawNotification[]).map(normalizeNotification)
set({
notifications: reset ? normalized : [...notifications, ...normalized],
total: data.total,
unreadCount: data.unread_count,
hasMore: (reset ? normalized.length : notifications.length + normalized.length) < data.total,
isLoading: false,
})
} catch {
set({ isLoading: false })
}
},
fetchUnreadCount: async () => {
try {
const data = await inAppNotificationsApi.unreadCount()
set({ unreadCount: data.count })
} catch {
// best-effort
}
},
markRead: async (id: number) => {
try {
await inAppNotificationsApi.markRead(id)
set(state => ({
notifications: state.notifications.map(n => n.id === id ? { ...n, is_read: true } : n),
unreadCount: Math.max(0, state.unreadCount - (state.notifications.find(n => n.id === id)?.is_read ? 0 : 1)),
}))
} catch {
// best-effort
}
},
markUnread: async (id: number) => {
try {
await inAppNotificationsApi.markUnread(id)
set(state => ({
notifications: state.notifications.map(n => n.id === id ? { ...n, is_read: false } : n),
unreadCount: state.unreadCount + (state.notifications.find(n => n.id === id)?.is_read ? 1 : 0),
}))
} catch {
// best-effort
}
},
markAllRead: async () => {
try {
await inAppNotificationsApi.markAllRead()
set(state => ({
notifications: state.notifications.map(n => ({ ...n, is_read: true })),
unreadCount: 0,
}))
} catch {
// best-effort
}
},
deleteNotification: async (id: number) => {
const notification = get().notifications.find(n => n.id === id)
try {
await inAppNotificationsApi.delete(id)
set(state => ({
notifications: state.notifications.filter(n => n.id !== id),
total: Math.max(0, state.total - 1),
unreadCount: notification && !notification.is_read ? Math.max(0, state.unreadCount - 1) : state.unreadCount,
}))
} catch {
// best-effort
}
},
deleteAll: async () => {
try {
await inAppNotificationsApi.deleteAll()
set({ notifications: [], total: 0, unreadCount: 0, hasMore: false })
} catch {
// best-effort
}
},
respondToBoolean: async (id: number, response: 'positive' | 'negative') => {
try {
const data = await inAppNotificationsApi.respond(id, response)
if (data.notification) {
const normalized = normalizeNotification(data.notification as RawNotification)
set(state => ({
notifications: state.notifications.map(n => n.id === id ? normalized : n),
unreadCount: !state.notifications.find(n => n.id === id)?.is_read
? Math.max(0, state.unreadCount - 1)
: state.unreadCount,
}))
}
} catch {
// best-effort
}
},
handleNewNotification: (raw: RawNotification) => {
const notification = normalizeNotification(raw)
set(state => ({
notifications: [notification, ...state.notifications],
total: state.total + 1,
unreadCount: state.unreadCount + 1,
}))
},
handleUpdatedNotification: (raw: RawNotification) => {
const notification = normalizeNotification(raw)
set(state => ({
notifications: state.notifications.map(n => n.id === notification.id ? notification : n),
}))
},
}))
+3
View File
@@ -42,6 +42,9 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
set(state => ({
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
}))
if (result.item.reservation_id && data.total_price !== undefined) {
get().loadReservations(tripId)
}
return result.item
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating budget item'))
+14 -1
View File
@@ -1,6 +1,6 @@
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { Assignment, Place, Day, DayNote, PackingItem, BudgetItem, BudgetMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types'
import type { Assignment, Place, Day, DayNote, PackingItem, TodoItem, BudgetItem, BudgetMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
@@ -175,6 +175,19 @@ export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void {
packingItems: state.packingItems.filter(i => i.id !== payload.itemId),
}
// Todo
case 'todo:created':
if (state.todoItems.some(i => i.id === (payload.item as TodoItem).id)) return {}
return { todoItems: [...state.todoItems, payload.item as TodoItem] }
case 'todo:updated':
return {
todoItems: state.todoItems.map(i => i.id === (payload.item as TodoItem).id ? payload.item as TodoItem : i),
}
case 'todo:deleted':
return {
todoItems: state.todoItems.filter(i => i.id !== payload.itemId),
}
// Budget
case 'budget:created':
if (state.budgetItems.some(i => i.id === (payload.item as BudgetItem).id)) return {}
+67
View File
@@ -0,0 +1,67 @@
import { todoApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { TodoItem } from '../../types'
import { getApiErrorMessage } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface TodoSlice {
addTodoItem: (tripId: number | string, data: Partial<TodoItem>) => Promise<TodoItem>
updateTodoItem: (tripId: number | string, id: number, data: Partial<TodoItem>) => Promise<TodoItem>
deleteTodoItem: (tripId: number | string, id: number) => Promise<void>
toggleTodoItem: (tripId: number | string, id: number, checked: boolean) => Promise<void>
}
export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
addTodoItem: async (tripId, data) => {
try {
const result = await todoApi.create(tripId, data)
set(state => ({ todoItems: [...state.todoItems, result.item] }))
return result.item
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error adding todo'))
}
},
updateTodoItem: async (tripId, id, data) => {
try {
const result = await todoApi.update(tripId, id, data)
set(state => ({
todoItems: state.todoItems.map(item => item.id === id ? result.item : item)
}))
return result.item
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating todo'))
}
},
deleteTodoItem: async (tripId, id) => {
const prev = get().todoItems
set(state => ({ todoItems: state.todoItems.filter(item => item.id !== id) }))
try {
await todoApi.delete(tripId, id)
} catch (err: unknown) {
set({ todoItems: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting todo'))
}
},
toggleTodoItem: async (tripId, id, checked) => {
set(state => ({
todoItems: state.todoItems.map(item =>
item.id === id ? { ...item, checked: checked ? 1 : 0 } : item
)
}))
try {
await todoApi.update(tripId, id, { checked })
} catch {
set(state => ({
todoItems: state.todoItems.map(item =>
item.id === id ? { ...item, checked: checked ? 0 : 1 } : item
)
}))
}
},
})
+11 -3
View File
@@ -1,16 +1,17 @@
import { create } from 'zustand'
import type { StoreApi } from 'zustand'
import { tripsApi, daysApi, placesApi, packingApi, tagsApi, categoriesApi } from '../api/client'
import { tripsApi, daysApi, placesApi, packingApi, todoApi, tagsApi, categoriesApi } from '../api/client'
import { createPlacesSlice } from './slices/placesSlice'
import { createAssignmentsSlice } from './slices/assignmentsSlice'
import { createDayNotesSlice } from './slices/dayNotesSlice'
import { createPackingSlice } from './slices/packingSlice'
import { createTodoSlice } from './slices/todoSlice'
import { createBudgetSlice } from './slices/budgetSlice'
import { createReservationsSlice } from './slices/reservationsSlice'
import { createFilesSlice } from './slices/filesSlice'
import { handleRemoteEvent } from './slices/remoteEventHandler'
import type {
Trip, Day, Place, Assignment, DayNote, PackingItem,
Trip, Day, Place, Assignment, DayNote, PackingItem, TodoItem,
Tag, Category, BudgetItem, TripFile, Reservation,
AssignmentsMap, DayNotesMap, WebSocketEvent,
} from '../types'
@@ -19,6 +20,7 @@ import type { PlacesSlice } from './slices/placesSlice'
import type { AssignmentsSlice } from './slices/assignmentsSlice'
import type { DayNotesSlice } from './slices/dayNotesSlice'
import type { PackingSlice } from './slices/packingSlice'
import type { TodoSlice } from './slices/todoSlice'
import type { BudgetSlice } from './slices/budgetSlice'
import type { ReservationsSlice } from './slices/reservationsSlice'
import type { FilesSlice } from './slices/filesSlice'
@@ -28,6 +30,7 @@ export interface TripStoreState
AssignmentsSlice,
DayNotesSlice,
PackingSlice,
TodoSlice,
BudgetSlice,
ReservationsSlice,
FilesSlice {
@@ -37,6 +40,7 @@ export interface TripStoreState
assignments: AssignmentsMap
dayNotes: DayNotesMap
packingItems: PackingItem[]
todoItems: TodoItem[]
tags: Tag[]
categories: Category[]
budgetItems: BudgetItem[]
@@ -62,6 +66,7 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
assignments: {},
dayNotes: {},
packingItems: [],
todoItems: [],
tags: [],
categories: [],
budgetItems: [],
@@ -78,11 +83,12 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
loadTrip: async (tripId: number | string) => {
set({ isLoading: true, error: null })
try {
const [tripData, daysData, placesData, packingData, tagsData, categoriesData] = await Promise.all([
const [tripData, daysData, placesData, packingData, todoData, tagsData, categoriesData] = await Promise.all([
tripsApi.get(tripId),
daysApi.list(tripId),
placesApi.list(tripId),
packingApi.list(tripId),
todoApi.list(tripId),
tagsApi.list(),
categoriesApi.list(),
])
@@ -101,6 +107,7 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
assignments: assignmentsMap,
dayNotes: dayNotesMap,
packingItems: packingData.items,
todoItems: todoData.items,
tags: tagsData.tags,
categories: categoriesData.categories,
isLoading: false,
@@ -169,6 +176,7 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
...createAssignmentsSlice(set, get),
...createDayNotesSlice(set, get),
...createPackingSlice(set, get),
...createTodoSlice(set, get),
...createBudgetSlice(set, get),
...createReservationsSlice(set, get),
...createFilesSlice(set, get),
+9 -1
View File
@@ -222,7 +222,14 @@ export const useVacayStore = create<VacayState>((set, get) => ({
removeYear: async (year: number) => {
const data = await api.removeYear(year)
set({ years: data.years })
const updates: Partial<VacayState> = { years: data.years }
if (get().selectedYear === year) {
updates.selectedYear = data.years.length > 0
? data.years[data.years.length - 1]
: new Date().getFullYear()
}
set(updates)
await get().loadStats()
},
loadEntries: async (year?: number) => {
@@ -240,6 +247,7 @@ export const useVacayStore = create<VacayState>((set, get) => ({
toggleCompanyHoliday: async (date: string) => {
await api.toggleCompanyHoliday(date)
await get().loadEntries()
await get().loadStats()
},
loadStats: async (year?: number) => {
+13
View File
@@ -86,6 +86,19 @@ export interface PackingItem {
quantity: number
}
export interface TodoItem {
id: number
trip_id: number
name: string
category: string | null
checked: number
sort_order: number
due_date: string | null
description: string | null
assigned_user_id: number | null
priority: number
}
export interface Tag {
id: number
name: string
+2 -2
View File
@@ -10,9 +10,9 @@ export function formatDate(dateStr: string | null | undefined, locale: string, t
if (!dateStr) return null
const opts: Intl.DateTimeFormatOptions = {
weekday: 'short', day: 'numeric', month: 'short',
timeZone: timeZone || 'UTC',
}
if (timeZone) opts.timeZone = timeZone
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, opts)
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, opts)
}
export function formatTime(timeStr: string | null | undefined, locale: string, timeFormat: string): string {
+14 -5
View File
@@ -23,13 +23,22 @@ services:
- 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 / real client IP)
- ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
- 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 true to disable local password auth entirely (SSO only)
# - APP_URL=https://trek.example.com # Public base URL — required when OIDC is enabled (must match the redirect URI registered with your IdP); also used as base URL for links in email notifications
# - 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 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)
# - 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)
volumes:
- ./data:/app/data
- ./uploads:/app/uploads

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