Compare commits

..

66 Commits

Author SHA1 Message Date
Maurice 8defc90e95 feat(bookings): show transport routes on map (#384, #587)
Adds from/to endpoints to flight/train/cruise/car reservations with
live map rendering. Flights use geodesic arcs and a curved duration +
distance badge; train/car/cruise render as straight or geodesic lines
with endpoint markers. Airports come from an embedded OurAirports
database (~3200 airports, offline-capable); train/cruise/car locations
via Nominatim. Per-trip connection toggle sits in the day plan
sidebar, persisted in localStorage. Clicking a map endpoint opens the
existing transport detail popup. New display setting toggles endpoint
labels on the map. Migration 105 adds the reservation_endpoints table
plus needs_review flag; existing flights are backfilled from their
IATA metadata on server startup.
2026-04-17 14:04:40 +02:00
Maurice 21511c2f68 Merge pull request #700 from mauriceboe/feat/v3-thankyou-notice
feat: v3 thank-you notice, mobile map+timeline, modal UX improvements
2026-04-16 23:51:13 +02:00
Maurice 0e5c819f7c fix: adapt tests for last-page-only dismiss and fix editor z-index
- SystemNoticeModal tests: navigate to last page before testing
  X button, ESC, and CTA dismiss (matches new last-page-only behavior)
- EntryEditor: use z-[9999] instead of portal (fixes iOS stacking
  without breaking test DOM queries)
- Pros/cons inputs: remove colored backgrounds in dark mode
2026-04-16 23:46:07 +02:00
Maurice 0f44d7d264 feat(journey): combined map+timeline view on mobile (Polarsteps-style)
Merge the separate Timeline and Map tabs into a single fullscreen
combined view on mobile (<1024px). A Leaflet map fills the background
while a horizontal snap-scroll carousel of entry cards sits at the
bottom. Scrolling the carousel auto-focuses the corresponding map
marker; tapping a marker scrolls to the card. Tapping a card opens
a new fullscreen entry view with edit/delete actions.

- New: MobileMapTimeline, MobileEntryCard, MobileEntryView components
- New: useIsMobile hook (matchMedia < 1024px)
- JourneyMap: fullScreen + paddingBottom props, focusMarker guard
- Desktop layout completely unchanged
- Public share page gets the same combined view (read-only)
- Fix: entry editor now portaled to body (iOS stacking context)
- Fix: pros/cons dark mode input backgrounds
- Fix: mood button borders in dark mode
- Fix: location icon color (neutral instead of green/indigo)
2026-04-16 23:37:09 +02:00
Maurice df075630fb feat(system-notices): add personal thank-you notice for v3.0.0
Personal note from the creator shown as the first page in the 3.0
upgrade modal. Includes community links (Discord, Ko-fi) and a
special shout-out to jubnl. Modal UX improved: users must click
through all pages before dismissing, wider layout, enhanced
markdown rendering with styled links, signature, and HR separator.
i18n coverage across all 15 languages.
2026-04-16 22:25:03 +02:00
Julien G. bffb55d8c0 Merge pull request #699 from mauriceboe/fix/journey-gallery-lightbox-grouping
fix(journey): gallery lightbox navigates all photos, not just same-day entry
2026-04-16 21:43:07 +02:00
jubnl 5c24213b0e fix(journey): gallery lightbox navigates all photos, not just same-day entry 2026-04-16 21:35:52 +02:00
Julien G. 12a457801a Merge pull request #697 from mauriceboe/fix/journey-photo-thumbnail-cache
fix(journey): serve local file when uploading photos with Immich syncenabled
2026-04-16 21:29:59 +02:00
jubnl ae4d317dc3 fix(journey): serve local file when uploading photos with Immich sync enabled
After upload, trek_photos.provider is immediately flipped to 'immich' even
though Immich's thumbnail generation is async. streamPhoto then routed to
Immich, which returned an error for the not-yet-processed asset. Because
Cache-Control was set before the proxy attempt, the error response was cached
by the browser for 24h — breaking thumbnails until a hard refresh bypassed
the cache and Immich had finished processing.

- streamPhoto now prefers the local file_path when it exists on disk,
  regardless of provider; Immich/Synology are only used when no local
  file is available (fixes the immediate broken-thumbnail symptom)
- pipeAsset sets Cache-Control: no-store on upstream errors and uses the
  caller-supplied default only on success (prevents cache poisoning)
- streamImmichAsset no longer pre-sets Cache-Control before the proxy
- streamSynologyAsset passes the same defaultCacheControl through pipeAsset

Closes #691
2026-04-16 21:20:38 +02:00
Julien G. f7c6854059 Merge pull request #693 from mauriceboe/fix/synology-shared-albums-pagination
Fix/synology shared albums pagination
2026-04-16 21:06:28 +02:00
jubnl bdb6b01765 fix(synology): paginate all three album sources past 100 albums and tighten targetUserId type
- Extract _fetchAllSynologyAlbums helper that loops until the source is
  exhausted; listSynologyAlbums now uses it for personal, shared-out,
  and shared-with-me instead of a hard-capped single request of 100
- Make getSynologyAssetInfo targetUserId required (number, not number|undefined)
  to match every call site and eliminate an implicit any at the _requestSynologyApi
  boundary
2026-04-16 20:54:35 +02:00
jubnl 129dfabaa3 feat(synology): persist and use passphrase for shared album photo streaming (#689-4)
- syncSynologyAlbumLink now uses getAlbumLinkForSync to read the stored
  passphrase and passes it in the SYNO.Foto.Browse.Item call when present,
  falling back to album_id for links without a passphrase.
- Selection type gains optional passphrase field; addTripPhotos and
  _addTripPhoto thread it through to getOrCreateTrekPhoto.
- getOrCreateTrekPhoto accepts an optional passphrase (4th param) and
  encrypts it when inserting a new trek_photos row; backfills existing
  rows that lack a passphrase.
- streamPhoto and getPhotoInfo decrypt the stored passphrase from
  trek_photos and forward it to streamSynologyAsset / getSynologyAssetInfo
  so shared-album photos resolve correctly at access time.
- Add SYNO-054 integration test covering the passphrase sync-and-persist
  path end-to-end.
2026-04-16 20:05:18 +02:00
jubnl 8a6d1b2aaf feat(synology): merge personal, shared-out, and shared-with-me albums in listSynologyAlbums
Fire all three Synology album sources in parallel via Promise.allSettled so a
permissions failure on one source (e.g. SYNO.Foto.Sharing.Misc) never blocks
personal album display. Deduplicate by album id (last-write-wins), propagate
passphrase from shared/shared-with-me entries, and return the merged list sorted
by albumName. Extends AlbumsList type to carry optional passphrase.

Adds SYNO-027/028/029 integration tests; updates SYNO-060/061/081 to match
the new multi-source call pattern.
2026-04-16 19:56:10 +02:00
jubnl 465b78411a fix(synology): resolve pagination offset using correct size before computing page offset
The `size` → `limit` assignment was evaluated after `page * limit`, causing
the offset to be computed using the hardcoded default (100) instead of the
caller-supplied page size. Swapping the two `if` blocks ensures `limit` is
resolved from `size` first so the offset is always `(page-1) * size`.

Adds SYNO-025 and SYNO-026 integration tests that capture the raw Synology
API body and assert `offset` and `limit` are forwarded correctly.
2026-04-16 19:49:08 +02:00
Julien G. 272b32b410 Merge pull request #685 from mauriceboe/fix/hide-mobile-scrollbars
fix(ui): hide scrollbars on mobile, keep styled bars on desktop
2026-04-16 16:50:20 +02:00
jubnl 7945e752d6 fix(ui): restore scrollbar-width: thin on .scroll-container 2026-04-16 16:44:27 +02:00
jubnl 6eb3ab38fb fix(ui): hide scrollbars on mobile, keep styled bars on desktop
Scrollbars on mobile caused layout shift (content pushed left).
Hidden via media query on mobile; desktop retains thin styled scrollbars.
Also removes inline scrollbarWidth override in DayPlanSidebar that bypassed the CSS rule.
2026-04-16 16:42:36 +02:00
Julien G. c7a9210215 Merge pull request #684 from mauriceboe/fix/batch-673-674-675-678-679-680
fix(journey): batch bug fixes #673 #674 #675 #678 #679 #680
2026-04-16 16:06:52 +02:00
jubnl d5d63aa979 test(journey): fix FE-PAGE-JOURNEYDETAIL-027 flaky spinner assertion
Pre-seed the store into loading state before render instead of relying on
timing. RTL's render() flushes all microtasks via act(), so the MSW response
lands before render() returns, leaving no observable loading window.
2026-04-16 16:01:06 +02:00
jubnl 84574020f2 fix(journey): increase PDF preview button touch targets for mobile
Raises button min-height to 44px and bumps padding/font-size to meet Apple HIG
minimum touch-target guidelines on iOS PWA. Fixes #680.
2026-04-16 15:55:20 +02:00
jubnl 1b7ea2c87d fix(journey): replace window.open with srcdoc iframe overlay for PDF preview
Rewrites downloadJourneyBookPDF to render the preview in an in-page srcdoc
iframe overlay instead of calling window.open(), which Safari iOS PWA blocks
in async callbacks. Matches the existing TripPDF pattern. Fixes #679.
2026-04-16 15:54:07 +02:00
jubnl 47b7678975 fix(journey): remove backdropFilter from modal overlays to fix iOS Safari PWA white screen
backdrop-filter: blur() on position:fixed elements is a known Safari iOS
compositing failure in standalone (PWA) mode. When the GPU layer behind
a fixed overlay is uninitialized, the blur samples white instead of the
actual content, overriding the semi-transparent background and rendering
a fully white screen that requires a force-close to escape.

The JourneySettingsDialog (bottom-sheet on mobile) was most affected due
to its items-end layout, but all five modal overlays in JourneyDetailPage
had the same pattern. Removed backdropFilter from all five and bumped
opacity from 0.6 to 0.75 to maintain visual separation. Closes #678.
2026-04-16 15:45:37 +02:00
jubnl da70388f4b fix(journey): resolve Immich photos on public share by matching trek_photos.id
validateShareTokenForPhoto was querying journey_photos by jp.id but the
public page sends p.photo_id (trek_photos.id) in the URL. In a fresh
database the IDs coincidentally match, masking the bug. In production
instances with many Immich-synced photos the trek_photos autoincrement
is far ahead of journey_photos, causing a 404 for every Immich photo
on the public share page.

Fix: change the lookup to jp.photo_id = ? so validation is keyed on
trek_photos.id, which is what the client sends and what streamPhoto
needs. Updated the test helper to return trekId and added a regression
test that pre-populates trek_photos to produce diverging IDs. Closes #675.
2026-04-16 15:37:24 +02:00
jubnl 6c1a795460 fix(journey): paginate Immich picker and group photos by date
The /search route was looping up to 20 pages server-side, returning a
blob of up to 1000 photos with no hasMore flag, which prevented the
client's existing ScrollTrigger infinite scroll from ever firing.

Now the route proxies the client's page param directly to Immich and
returns a single page plus hasMore, enabling full library browsing.

The photo picker grid now groups photos by takenAt date (already
present in every asset response) with a date label above each group,
restoring the date-oriented browsing from V2. Closes #674.
2026-04-16 15:32:56 +02:00
jubnl 75d23eb6aa fix(journey): keep page mounted during in-place journey refetch
loadJourney previously set loading=true unconditionally, causing the
JourneyDetailPage guard (if loading || !current) to unmount the entire
page tree on every background refetch — entry saves, settings saves,
trip link/unlink, contributor invite, delete, and WS realtime events
all triggered the full-page spinner flash.

Now loading is only toggled on cold loads (current?.id !== id).
Warm refreshes replace current silently so the hero, sidebar, map,
and timeline stay mounted throughout. Closes #673.
2026-04-16 15:27:13 +02:00
Julien G. 0c4de72356 Merge pull request #683 from mauriceboe/feat/system-notices
Feat/system notices
2026-04-16 15:14:05 +02:00
jubnl 5e8602c50a fix(system-notices): fix FE-SN-BANNER-004 to reflect highest-priority-first array order 2026-04-16 15:08:52 +02:00
jubnl 61b8070626 fix(system-notices): coerce prerelease app version before semver comparison 2026-04-16 14:58:38 +02:00
jubnl 5caaeff67c fix: syntax 2026-04-16 14:55:35 +02:00
jubnl 92a1f9c448 fix(system-notices): reset notice store on logout so addon-gated notices show after re-login 2026-04-16 14:53:33 +02:00
jubnl 58a8e97f94 feat(system-notices): add v3-mcp notice for OAuth 2.1 upgrade
Adds a warn-severity modal notice targeting existing users who have the
MCP addon enabled. Communicates that OAuth 2.1 is now the recommended
auth method, static trek_ tokens are deprecated, and the toolset has
been significantly expanded. Priority 75 — slots between v3-journey and
v3-features in the upgrade modal sequence. Translations for all 15 languages.
2026-04-16 14:48:13 +02:00
Julien G. 815b725f87 Merge pull request #682 from mauriceboe/dev
Dev
2026-04-16 14:38:24 +02:00
Julien G. d80bbd5bed Merge branch 'feat/system-notices' into dev 2026-04-16 14:38:14 +02:00
jubnl 293506217e feat(notices): add system notice infrastructure
Server-side notice registry with per-user condition evaluation (firstLogin,
existingUserBeforeVersion, addonEnabled, dateWindow, role, custom).
Notices are sorted by priority then severity, filtered against dismissals
stored in a new user_notice_dismissals table, and served via
GET /api/system-notices/active + POST /api/system-notices/:id/dismiss.

Client renders notices through a host component that partitions by
display type (modal / banner / toast). The modal renderer supports
multi-page pagination with directional slide transitions, keyboard
navigation, and correct dismiss-all semantics on CTA / X / ESC.
Dismissals are optimistic with a single background retry.

Includes 3.0.0 upgrade notices (v3-photos, v3-journey, v3-features),
onboarding welcome modal, and full i18n coverage across 15 languages.
The /journey route is addon-gated on both client and server.

Also includes: unit + integration test suites, registry integrity test
that validates action CTA IDs against client source, and technical
documentation in docs/system-notices.md.
2026-04-16 14:36:33 +02:00
Maurice 9739542a3a Merge pull request #672 from mauriceboe/feature/uncategorized-filter
feat: add uncategorized filter to category dropdown and more
2026-04-16 00:34:28 +02:00
Maurice 9f3a88223d fix: update ReservationModal test for check-in time range fields
Use getAllByText for check-in labels since both "Check-in" and
"Check-in until" now match the /Check-in/i pattern.
2026-04-16 00:29:25 +02:00
Maurice 409a63633c feat: support check-in time ranges for hotel accommodations
- Add check_in_end column to day_accommodations (Migration 102)
- Server: create/update accommodation accepts check_in_end
- Bidirectional sync: check_in_end synced between accommodation
  and linked reservation metadata (check_in_end_time)
- DayDetailPanel: shows check-in range (e.g. "14:00 – 22:00"),
  new "Until" time picker in hotel form
- ReservationModal: new check-in-until field for hotel bookings
- ReservationsPanel: displays check-in range in metadata cells
- i18n: checkInUntil keys in all 15 languages

Closes #366
2026-04-16 00:23:00 +02:00
Maurice 125436fa87 fix: correct test matchers for list import and reservations
- PlacesSidebar: match "List Import" (actual i18n value) not "Import List"
- ReservationsPanel: use unique titles to avoid matching filter buttons
2026-04-16 00:12:06 +02:00
Maurice 975846c236 fix: update tests for naver always-on and reservations redesign
- Remove server test for naver addon disabled (addon check removed)
- Update PlacesSidebar tests: "Google List" → "Import List" (both
  providers always shown)
- Update ReservationsPanel tests: status is always a span (no toggle),
  remove click-to-toggle test, update summary test
2026-04-16 00:04:14 +02:00
Maurice 7befb7d555 feat: enable naver list import by default, remove addon toggle
- Remove addon check from naver import endpoint
- Naver import always available alongside Google list import
- Migration 101: auto-enable naver_list_import for existing installs
- Remove unused isAddonEnabled import from places route
- Remove unused useAddonStore import from PlacesSidebar
2026-04-15 23:57:09 +02:00
Maurice 099255761c feat: collab sub-feature toggles and provider icons
- Add admin toggles for individual collab sections (Chat, Notes,
  Polls, What's Next) stored in app_settings
- CollabPanel adapts layout dynamically: chat always fixed 380px,
  remaining panels share space equally
- Mobile: disabled tabs are hidden
- Add Immich and Synology Photos SVG icons to photo provider toggles
- Add Luggage icon to bag tracking sub-toggle
- API: GET/PUT /admin/collab-features endpoints
- i18n: all 15 languages updated

Closes #604
2026-04-15 23:53:16 +02:00
Maurice c8fc21b8bd fix: reservations panel mobile responsiveness
- Hide type filter pills on mobile (< md breakpoint)
- Move add button right-aligned on mobile
- Separate booking code into its own row below date/time
- Hide weekday in date on mobile for space
- Reduce padding on mobile
2026-04-15 23:26:49 +02:00
Maurice 9186b8c850 feat: redesign reservations panel with unified toolbar and responsive grid
- Unified toolbar with title, type filter pills (with count badges),
  and add button in one row
- Cards redesigned: labeled fields in rounded boxes, status/type in
  header, edit/delete actions right-aligned
- Responsive grid with max 3 columns, auto-filling full width
- Type filters persist in sessionStorage per trip
- Widen reservations tab container to match other tabs (1800px)
2026-04-15 23:21:51 +02:00
Maurice e38c5fed44 feat: add uncategorized filter option to category dropdown
Add a "No Category" option to the category filter dropdown in the
places sidebar, allowing users to filter for places without an
assigned category. The filter is synced with the map view.

Closes #607
2026-04-15 22:54:23 +02:00
Julien G. 3b069bc543 Merge pull request #671 from mauriceboe/feat/admin-default-user-settings
feat(admin): add admin-configurable default user settings
2026-04-15 22:47:08 +02:00
jubnl 618b1b8697 feat(admin): add map preview and auto-save to default user settings tab 2026-04-15 22:41:33 +02:00
jubnl e45a0efce3 feat(admin): add admin-configurable default user settings
Allow admins to set instance-wide defaults for temperature unit, color
mode, time format, route calculation, blur booking codes, and map tile
URL via a new Admin > User Defaults tab. Defaults are stored in
app_settings (prefixed default_user_setting_*) and applied at read time
as a fallback — user's own explicit values always take priority.
Translations added for all 16 supported languages.
2026-04-15 22:31:41 +02:00
Julien G. 597a5f7a1d Merge pull request #670 from mauriceboe/fix/immich-heic-rendering
fix(immich): serve fullsize thumbnail for original to fix HEIC rendering
2026-04-15 22:07:28 +02:00
jubnl 42c216b00b fix(immich): serve fullsize thumbnail for original to fix HEIC rendering
Raw /assets/{id}/original returns HEIC bytes which only Safari can
render natively. Switch to /assets/{id}/thumbnail?size=fullsize which
Immich transcodes to a browser-compatible format.

Closes #668
2026-04-15 22:02:48 +02:00
jubnl f3751ab9aa ci: manual trigger for prerelease 2026-04-15 21:35:53 +02:00
jubnl 9e8d101d63 fix(ntfy): improve admin ntfy UX and add clear token button
- Add missing admin.ntfy.hint translation key in all 15 languages
- Add admin ntfy server hint clarifying it is the default for users
- Expose admin_ntfy_server via PreferencesMatrix so user settings
  placeholder reflects the admin-configured default
- Add clear token button to admin ntfy panel (same pattern as user settings)
- Extract common.clear from settings.ntfyUrl.clearToken across all 15 languages
2026-04-15 20:23:31 +02:00
Julien G. 5656731850 Merge pull request #669 from mauriceboe/feat/ntfy-notification-channel
feat(notifications): add ntfy as a first-class notification channel
2026-04-15 14:13:18 +02:00
jubnl 7c4ac70db3 feat(i18n): translate ntfy notification strings into 14 languages
Properly translate all ntfy-related UI strings added in the previous
commit for ar, br, cs, de, es, fr, hu, id, it, nl, pl, ru, zh, zhTw.
Product name 'Ntfy' and placeholder values kept as-is.
2026-04-15 14:08:04 +02:00
jubnl bfe84b3016 feat(notifications): add ntfy as a first-class notification channel
Adds ntfy.sh (and self-hosted instances) as a new push notification
channel with full parity to the existing webhook channel.

- Backend: NtfyConfig type, getUserNtfyConfig, getAdminNtfyConfig,
  resolveNtfyUrl, sendNtfy (header-based API with Title/Priority/Tags/
  Click headers), testNtfy, NTFY_EVENT_META (priority + emoji tags per
  event), SSRF guard via existing checkSsrf + createPinnedDispatcher
- notificationPreferencesService: ntfy added to NotifChannel union,
  IMPLEMENTED_COMBOS, getActiveChannels parser, getAvailableChannels,
  ADMIN_GLOBAL_CHANNELS, and AvailableChannels interface
- notificationService: per-user ntfy dispatch after webhook block;
  admin-scoped ntfy via getAdminGlobalPref for version_available events
- Routes: POST /api/notifications/test-ntfy with saved-token fallback
- authService: admin_ntfy_server/topic/token in ADMIN_SETTINGS_KEYS,
  masked + encrypted on read/write
- settingsService: ntfy_token added to ENCRYPTED_SETTING_KEYS
- Frontend: ntfy topic/server/token inputs + Save/Test/Clear buttons in
  NotificationsTab; admin Ntfy panel in AdminPage; testNtfy API method
- i18n: full English strings; English placeholders in 14 other locales
- Tests: resolveNtfyUrl, sendNtfy, dispatch integration, UI tests,
  MSW handler for test-ntfy endpoint
2026-04-15 13:59:25 +02:00
Julien G. f349e567f8 Merge pull request #665 from mauriceboe/feat/indonesian-translation
Feat/indonesian translation
2026-04-15 08:17:55 +02:00
jubnl ff434f4515 fix: discord links in tests 2026-04-15 08:12:22 +02:00
jubnl 0c2e0cad5c feat(i18n): complete Indonesian translation with full parity to en.ts
- Translate all 1941 keys to Bahasa Indonesia (up from ~426)
- Add 437 keys missing since PR was opened (journey.*, oauth.scope.*,
  dashboard.mobile.*, settings.oauth.*, admin.oauthSessions.*, etc.)
- Remove 2 stale keys superseded by unified file-import flow
- Fix duplicate packing.assignUser entry
- Rename const en → const id, update export default
- Update SUPPORTED_LANGUAGES length assertion in i18n unit test (14→15)
2026-04-15 08:05:04 +02:00
Julien G. 326f9c0823 Merge pull request #664 from mauriceboe/main
Align dev
2026-04-15 07:38:11 +02:00
github-actions[bot] 6df5edfbdb chore: bump version to 2.9.14 [skip ci] 2026-04-15 05:33:46 +00:00
jubnl 5023406717 Update discord link to a permanent link 2026-04-15 07:33:26 +02:00
Julien G. 5be805910c Update Discord link in README.md 2026-04-15 07:29:06 +02:00
jubnl 191d59166c Merge remote-tracking branch 'origin/dev' into feat/indonesian-translation 2026-04-15 06:28:35 +02:00
Julien G. b0f3440221 Update Discord link in README.md 2026-04-13 23:29:43 +02:00
Julien G. 707b3f227c Update discord link 2026-04-13 23:27:09 +02:00
xenocent a4727c4c53 docs: add Indonesian to supported languages 2026-04-11 15:35:08 +07:00
xenocent 577f2b05ca feat(i18n): add Indonesian translation 2026-04-11 15:26:16 +07:00
132 changed files with 11613 additions and 782 deletions
-5
View File
@@ -1,11 +1,6 @@
name: Build & Push Docker Image (Prerelease)
on:
push:
branches: [dev]
paths-ignore:
- 'docs/**'
- '**/*.md'
workflow_dispatch:
inputs:
bump:
+2 -1
View File
@@ -16,7 +16,8 @@ client/public/icons/*.png
*.sqlite-wal
# User data
server/data/
server/data/*
!server/data/airports.json
server/uploads/
# Environment
+1 -1
View File
@@ -86,7 +86,7 @@
### Customization & Admin
- **Dashboard Views** — Toggle between card grid and compact list view on the My Trips page
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
- **Multilingual** — English, German, Spanish, French, Russian, Chinese (Simplified), Dutch, Arabic (with RTL support)
- **Multilingual** — English, German, Spanish, French, Russian, Chinese (Simplified), Dutch, Indonesian, Arabic (with RTL support)
- **Admin Panel** — User management, invite links, packing templates, global categories, addon management, API keys, backups, and GitHub release history
- **Auto-Backups** — Scheduled backups with configurable interval and retention
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 2.9.13
version: 2.9.14
description: Minimal Helm chart for TREK app
appVersion: "2.9.13"
appVersion: "2.9.14"
+42 -26
View File
@@ -1,12 +1,12 @@
{
"name": "trek-client",
"version": "2.9.13",
"version": "2.9.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-client",
"version": "2.9.13",
"version": "2.9.14",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
@@ -22,6 +22,7 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.2",
"react-window": "^2.2.7",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
@@ -171,7 +172,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1807,7 +1807,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -1856,7 +1855,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -3825,7 +3823,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -3966,7 +3965,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -3978,7 +3976,6 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@@ -4221,7 +4218,6 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -4249,6 +4245,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -4649,7 +4646,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -5397,7 +5393,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/dunder-proto": {
"version": "1.0.1",
@@ -6337,6 +6334,21 @@
"node": ">= 0.4"
}
},
"node_modules/hast-util-sanitize": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz",
"integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@ungap/structured-clone": "^1.0.0",
"unist-util-position": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -7133,7 +7145,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -7261,8 +7272,7 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause",
"peer": true
"license": "BSD-2-Clause"
},
"node_modules/leaflet.markercluster": {
"version": "1.5.3",
@@ -7397,6 +7407,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -8437,7 +8448,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@inquirer/confirm": "^5.0.0",
"@mswjs/interceptors": "^0.41.2",
@@ -8823,7 +8833,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8985,6 +8994,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -9085,7 +9095,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -9098,7 +9107,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -9138,14 +9146,14 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-leaflet": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
"license": "Hippocratic-2.1",
"peer": true,
"dependencies": {
"@react-leaflet/core": "^2.1.0"
},
@@ -9388,6 +9396,20 @@
"regjsparser": "bin/parser"
}
},
"node_modules/rehype-sanitize": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz",
"integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-sanitize": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-breaks": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz",
@@ -9540,7 +9562,6 @@
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -10658,7 +10679,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -10908,7 +10928,6 @@
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11227,7 +11246,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -11356,7 +11374,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -11854,7 +11871,6 @@
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "2.9.13",
"version": "2.9.14",
"private": true,
"type": "module",
"scripts": {
@@ -29,6 +29,7 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.2",
"react-window": "^2.2.7",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
+18 -3
View File
@@ -2,6 +2,7 @@ import React, { useEffect, ReactNode } from 'react'
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { useAddonStore } from './store/addonStore'
import LoginPage from './pages/LoginPage'
import DashboardPage from './pages/DashboardPage'
import TripPlannerPage from './pages/TripPlannerPage'
@@ -24,17 +25,22 @@ import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
import OfflineBanner from './components/Layout/OfflineBanner'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
// Notice action registrations (side-effect imports):
import './pages/Trips/noticeActions.js'
interface ProtectedRouteProps {
children: ReactNode
adminRequired?: boolean
addonId?: string
}
function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) {
function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedRouteProps) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const user = useAuthStore((s) => s.user)
const isLoading = useAuthStore((s) => s.isLoading)
const appRequireMfa = useAuthStore((s) => s.appRequireMfa)
const addonStore = useAddonStore()
const { t } = useTranslation()
const location = useLocation()
@@ -67,6 +73,10 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
return <Navigate to="/dashboard" replace />
}
if (addonId && addonStore.loaded && !addonStore.isEnabled(addonId)) {
return <Navigate to="/dashboard" replace />
}
return (
<div className="flex flex-col h-screen md:block md:h-auto">
<div className="flex-1 overflow-y-auto md:overflow-visible">{children}</div>
@@ -92,6 +102,7 @@ function RootRedirect() {
export default function App() {
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
useEffect(() => {
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
@@ -145,6 +156,7 @@ export default function App() {
useEffect(() => {
if (isAuthenticated) {
loadSettings()
loadAddons()
}
}, [isAuthenticated])
@@ -182,8 +194,11 @@ export default function App() {
applyDark(mode === true || mode === 'dark')
}, [settings.dark_mode, isSharedPage])
const isAuthPage = location.pathname.startsWith('/login') || location.pathname.startsWith('/register')
return (
<TranslationProvider>
{!isAuthPage && <SystemNoticeHost />}
<ToastContainer />
<OfflineBanner />
<Routes>
@@ -253,7 +268,7 @@ export default function App() {
<Route
path="/journey"
element={
<ProtectedRoute>
<ProtectedRoute addonId="journey">
<JourneyPage />
</ProtectedRoute>
}
@@ -261,7 +276,7 @@ export default function App() {
<Route
path="/journey/:id"
element={
<ProtectedRoute>
<ProtectedRoute addonId="journey">
<JourneyDetailPage />
</ProtectedRoute>
}
+10
View File
@@ -272,6 +272,8 @@ export const adminApi = {
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
getPackingTemplate: (id: number) => apiClient.get(`/admin/packing-templates/${id}`).then(r => r.data),
createPackingTemplate: (data: { name: string }) => apiClient.post('/admin/packing-templates', data).then(r => r.data),
@@ -299,6 +301,8 @@ export const adminApi = {
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),
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
updateDefaultUserSettings: (settings: Record<string, unknown>) => apiClient.put('/admin/default-user-settings', settings).then(r => r.data),
}
export const addonsApi = {
@@ -361,6 +365,11 @@ export const mapsApi = {
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data),
}
export const airportsApi = {
search: (q: string, signal?: AbortSignal) => apiClient.get('/airports/search', { params: { q }, signal }).then(r => r.data),
byIata: (iata: string) => apiClient.get(`/airports/${encodeURIComponent(iata)}`).then(r => r.data),
}
export const budgetApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
@@ -486,6 +495,7 @@ export const notificationsApi = {
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data),
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => r.data),
}
export const inAppNotificationsApi = {
+69 -4
View File
@@ -4,12 +4,33 @@ import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } from 'lucide-react'
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
}
function ImmichIcon({ size = 14 }: { size?: number }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
<path d="M11.986.27c-2.409 0-5.207 1.09-5.207 3.894v.152c1.343.597 2.935 1.663 4.412 2.971 1.571 1.391 2.838 2.882 3.653 4.287 1.4-2.503 2.336-5.478 2.347-7.373V4.164c0-2.803-2.796-3.894-5.205-3.894m7.512 4.49c-.378-.008-.775.05-1.192.186l-.144.047c-.153 1.461-.676 3.304-1.463 5.113-.837 1.924-1.863 3.59-2.947 4.799 2.813.558 5.93.527 7.736-.047l.035-.01c2.667-.866 2.84-3.863 2.096-6.154-.628-1.933-2.081-3.89-4.121-3.934m-14.996.04c-2.04.043-3.493 1.997-4.121 3.93-.744 2.291-.571 5.288 2.096 6.155l.144.046c.982-1.092 2.488-2.276 4.188-3.277 1.809-1.065 3.619-1.808 5.207-2.148-1.949-2.105-4.489-3.914-6.287-4.51l-.036-.012c-.416-.135-.813-.193-1.191-.185m4.672 6.758c-2.604 1.202-5.109 3.06-6.233 4.586l-.021.029c-1.648 2.268-.027 4.795 1.922 6.211 1.949 1.416 4.852 2.177 6.5-.092.023-.031.054-.07.09-.121-.736-1.272-1.396-3.072-1.822-4.998-.454-2.05-.603-4-.436-5.615m1.072 3.338c.339 2.848 1.332 5.804 2.436 7.344l.021.029c1.648 2.268 4.551 1.508 6.5.092 1.949-1.416 3.57-3.943 1.922-6.211-.023-.031-.052-.073-.088-.123-1.437.307-3.352.38-5.316.19-2.089-.202-3.99-.663-5.475-1.321" fill="currentColor" />
</svg>
)
}
function SynologyIcon({ size = 14 }: { size?: number }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
<path d="M17.895 11.927a3.196 3.196 0 0 1 .394-1.53l-.008.017a2.677 2.677 0 0 1 1.075-1.108l.014-.007a3.181 3.181 0 0 1 1.523-.382h.05-.003q1.346 0 2.2.871.854.871.86 2.203c0 .895-.29 1.635-.867 2.226s-1.306.886-2.183.886c-.566 0-1.1-.137-1.571-.379l.019.009a2.535 2.535 0 0 1-1.115-1.067l-.007-.013q-.38-.708-.381-1.726zm1.593.083c0 .591.138 1.043.42 1.349a1.365 1.365 0 0 0 2.066.002l.001-.002c.275-.307.413-.764.413-1.357s-.138-1.033-.413-1.342a1.371 1.371 0 0 0-2.066-.001l-.001.002c-.281.306-.42.758-.42 1.345zm-1.602 2.941H16.33v-3.015c0-.635-.032-1.044-.101-1.234a.876.876 0 0 0-.328-.435l-.003-.002a.938.938 0 0 0-.521-.156h-.027.001-.012c-.27 0-.521.084-.727.228l.004-.003a1.115 1.115 0 0 0-.444.576l-.002.008c-.083.248-.121.696-.121 1.359v2.673H12.5V9.027h1.439v.867c.518-.656 1.167-.98 1.952-.98h.021c.335 0 .655.067.946.189l-.016-.006c.261.105.48.268.648.475l.002.003c.141.185.247.404.304.643l.002.012c.057.278.089.597.089.924l-.002.135v-.007zM6.413 9.028h1.654l1.412 4.204 1.376-4.204h1.611l-2.067 5.693-.38 1.038a4.158 4.158 0 0 1-.4.807l.01-.017a1.637 1.637 0 0 1-.422.443l-.005.003c-.17.113-.367.203-.578.26l-.014.003c-.232.064-.499.1-.774.1h-.025.001a4.13 4.13 0 0 1-.911-.105l.028.005-.129-1.229c.198.046.426.074.659.077h.002c.36 0 .628-.106.8-.318a2.27 2.27 0 0 0 .395-.807l.004-.016zM0 12.29l1.592-.149q.147.802.586 1.181.439.379 1.192.375c.528 0 .927-.113 1.197-.335.27-.222.4-.486.4-.782v-.024a.751.751 0 0 0-.167-.474l.001.001c-.113-.132-.309-.252-.59-.347-.193-.074-.631-.191-1.312-.365-.882-.216-1.496-.486-1.85-.804A2.147 2.147 0 0 1 .3 8.936v-.019V8.908c0-.431.132-.831.358-1.163l-.005.007a2.226 2.226 0 0 1 1.003-.826l.015-.005c.442-.184.973-.281 1.602-.281q1.529 0 2.304.676c.516.457.785 1.057.811 1.809l-1.649.055c-.073-.413-.219-.714-.452-.899-.233-.185-.579-.276-1.034-.276-.476 0-.85.098-1.118.298a.59.59 0 0 0-.261.49v.011-.001.002c0 .201.095.379.242.493l.001.001c.205.179.709.36 1.507.546.798.186 1.388.387 1.769.59.374.196.678.48.893.825l.006.01c.214.345.326.786.326 1.305 0 .489-.146.944-.396 1.325l.006-.009c-.264.408-.64.724-1.084.908l-.016.006c-.475.194-1.065.298-1.772.298-1.029 0-1.819-.241-2.373-.722-.554-.481-.879-1.177-.986-2.091z" fill="currentColor" />
</svg>
)
}
const PROVIDER_ICONS: Record<string, React.FC<{ size?: number }>> = {
immich: ImmichIcon,
synologyphotos: SynologyIcon,
}
interface Addon {
id: string
name: string
@@ -38,7 +59,16 @@ function AddonIcon({ name, size = 20 }: AddonIconProps) {
return <Icon size={size} />
}
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void }) {
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
const COLLAB_SUB_FEATURES = [
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
] as const
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) {
const { t } = useTranslation()
const dm = useSettingsStore(s => s.settings.dark_mode)
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
@@ -156,6 +186,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<Luggage size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('admin.bagTracking.title')}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.bagTracking.subtitle')}</div>
@@ -173,6 +204,36 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
</div>
</div>
)}
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div className="space-y-2">
{COLLAB_SUB_FEATURES.map(feat => {
const enabled = collabFeatures[feat.key]
const Icon = feat.icon
return (
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t(feat.titleKey)}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t(feat.subtitleKey)}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button onClick={() => onToggleCollabFeature(feat.key)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: 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: enabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
))}
</div>
@@ -194,8 +255,11 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
{addon.id === 'journey' && 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 => (
{providerOptions.map(provider => {
const ProviderIcon = PROVIDER_ICONS[provider.key]
return (
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
{ProviderIcon && <span style={{ color: 'var(--text-faint)' }}><ProviderIcon size={14} /></span>}
<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>
@@ -214,7 +278,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
</button>
</div>
</div>
))}
)
})}
</div>
</div>
)}
@@ -0,0 +1,290 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Settings2 } from 'lucide-react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import Section from '../Settings/Section'
import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView'
import type { Place } from '../../types'
const MAP_PRESETS = [
{ 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' },
]
type Defaults = {
temperature_unit?: string
dark_mode?: string | boolean
time_format?: string
route_calculation?: boolean
blur_booking_codes?: boolean
map_tile_url?: string
}
function OptionRow({
label,
hint,
children,
}: {
label: React.ReactNode
hint?: string
children: React.ReactNode
}) {
return (
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
{label}
</label>
{hint && <p className="text-xs mb-2" style={{ color: 'var(--text-faint)' }}>{hint}</p>}
<div className="flex gap-3 flex-wrap">{children}</div>
</div>
)
}
function OptionButton({
active,
onClick,
children,
}: {
active: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{children}
</button>
)
}
export default function DefaultUserSettingsTab(): React.ReactElement {
const { t } = useTranslation()
const toast = useToast()
const [defaults, setDefaults] = useState<Defaults>({})
const [loaded, setLoaded] = useState(false)
const [mapTileUrl, setMapTileUrl] = useState('')
useEffect(() => {
adminApi.getDefaultUserSettings().then((data: Defaults) => {
setDefaults(data)
setMapTileUrl(data.map_tile_url || '')
setLoaded(true)
}).catch(() => setLoaded(true))
}, [])
const save = async (patch: Partial<Defaults>) => {
try {
const updated = await adminApi.updateDefaultUserSettings(patch as Record<string, unknown>)
setDefaults(updated)
toast.success(t('admin.defaultSettings.saved'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error'))
}
}
const reset = async (key: keyof Defaults) => {
try {
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
setDefaults(updated)
if (key === 'map_tile_url') setMapTileUrl('')
toast.success(t('admin.defaultSettings.reset'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error'))
}
}
const isSet = (key: keyof Defaults) => defaults[key] !== undefined
const ResetButton = ({ field }: { field: keyof Defaults }) =>
isSet(field) ? (
<button
onClick={() => reset(field)}
className="text-xs ml-2"
style={{ color: 'var(--text-faint)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer' }}
>
{t('admin.defaultSettings.resetToBuiltIn')}
</button>
) : null
const mapPreviewPlaces = useMemo((): Place[] => [{
id: 1,
trip_id: 1,
name: 'Preview center',
description: null,
notes: null,
lat: 48.8566,
lng: 2.3522,
address: null,
category_id: null,
icon: null,
price: null,
currency: null,
image_url: null,
google_place_id: null,
osm_id: null,
route_geometry: null,
place_time: null,
end_time: null,
duration_minutes: null,
transport_mode: null,
website: null,
phone: null,
created_at: Date(),
}], [])
if (!loaded) {
return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading</p>
}
const darkMode = defaults.dark_mode
return (
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
<p className="text-sm" style={{ color: 'var(--text-faint)', marginTop: -8 }}>
{t('admin.defaultSettings.description')}
</p>
{/* Color Mode */}
<OptionRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
{([
{ value: 'light', label: t('settings.light') },
{ value: 'dark', label: t('settings.dark') },
{ value: 'auto', label: t('settings.auto') },
] as const).map(opt => (
<OptionButton
key={opt.value}
active={darkMode === opt.value || (opt.value === 'light' && darkMode === false) || (opt.value === 'dark' && darkMode === true)}
onClick={() => save({ dark_mode: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Temperature */}
<OptionRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
{([
{ value: 'celsius', label: '°C Celsius' },
{ value: 'fahrenheit', label: '°F Fahrenheit' },
] as const).map(opt => (
<OptionButton
key={opt.value}
active={defaults.temperature_unit === opt.value}
onClick={() => save({ temperature_unit: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Time Format */}
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
{([
{ value: '24h', label: '24h (14:30)' },
{ value: '12h', label: '12h (2:30 PM)' },
] as const).map(opt => (
<OptionButton
key={opt.value}
active={defaults.time_format === opt.value}
onClick={() => save({ time_format: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Route Calculation */}
<OptionRow label={<>{t('settings.routeCalculation')} <ResetButton field="route_calculation" /></>}>
{([
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
] as const).map(opt => (
<OptionButton
key={String(opt.value)}
active={defaults.route_calculation === opt.value}
onClick={() => save({ route_calculation: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Blur Booking Codes */}
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
{([
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
] as const).map(opt => (
<OptionButton
key={String(opt.value)}
active={defaults.blur_booking_codes === opt.value}
onClick={() => save({ blur_booking_codes: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Map Tile URL */}
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
{t('settings.mapTemplate')}
<ResetButton field="map_tile_url" />
</label>
<CustomSelect
value={mapTileUrl}
onChange={(value: string) => { if (value) { setMapTileUrl(value); save({ map_tile_url: 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)}
onBlur={() => save({ map_tile_url: mapTileUrl })}
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 mt-1" style={{ color: 'var(--text-faint)' }}>{t('settings.mapDefaultHint')}</p>
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{React.createElement(MapView as any, {
places: mapPreviewPlaces,
dayPlaces: [],
route: null,
routeSegments: null,
selectedPlaceId: null,
onMarkerClick: null,
onMapClick: null,
onMapContextMenu: null,
center: [48.8566, 2.3522],
zoom: 10,
tileUrl: mapTileUrl,
fitKey: null,
dayOrderMap: [],
leftWidth: 0,
rightWidth: 0,
hasInspector: false,
})}
</div>
</div>
</Section>
)
}
@@ -68,7 +68,7 @@ describe('GitHubPanel', () => {
expect(bmc).toHaveAttribute('rel', 'noopener noreferrer');
const discord = screen.getByText('Discord').closest('a')!;
expect(discord).toHaveAttribute('href', 'https://discord.gg/nSdKaXgN');
expect(discord).toHaveAttribute('href', 'https://discord.gg/NhZBDSd4qW');
expect(discord).toHaveAttribute('target', '_blank');
expect(discord).toHaveAttribute('rel', 'noopener noreferrer');
});
+1 -1
View File
@@ -163,7 +163,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://discord.gg/nSdKaXgN"
href="https://discord.gg/NhZBDSd4qW"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
+123 -36
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
@@ -29,54 +29,142 @@ interface TripMember {
avatar_url?: string | null
}
interface CollabFeatures {
chat: boolean
notes: boolean
polls: boolean
whatsnext: boolean
}
interface CollabPanelProps {
tripId: number
tripMembers?: TripMember[]
collabFeatures?: CollabFeatures
}
export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelProps) {
const ALL_TABS = [
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles },
]
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
const { user } = useAuthStore()
const { t } = useTranslation()
const [mobileTab, setMobileTab] = useState('chat')
const isDesktop = useIsDesktop()
const tabs = [
{ id: 'chat', label: t('collab.tabs.chat') || 'Chat', icon: MessageCircle },
{ id: 'notes', label: t('collab.tabs.notes') || 'Notes', icon: StickyNote },
{ id: 'polls', label: t('collab.tabs.polls') || 'Polls', icon: BarChart3 },
{ id: 'next', label: t('collab.whatsNext.title') || "What's Next", icon: Sparkles },
]
const features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true }
const tabs = useMemo(() =>
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
...tab,
label: t(tab.labelKey) || tab.fallback,
})),
[features, t])
const [mobileTab, setMobileTab] = useState(() => tabs[0]?.id || 'chat')
// If active tab gets disabled, switch to first available
useEffect(() => {
if (tabs.length > 0 && !tabs.some(t => t.id === mobileTab)) {
setMobileTab(tabs[0].id)
}
}, [tabs, mobileTab])
const chatOn = features.chat
const rightPanels = [
features.notes && 'notes',
features.polls && 'polls',
features.whatsnext && 'whatsnext',
].filter(Boolean) as string[]
if (tabs.length === 0) return null
if (isDesktop) {
// Chat always 380px fixed when on. Right panels share remaining space.
// If chat off, all panels share full width equally.
if (chatOn && rightPanels.length === 0) {
// Only chat
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
<div style={{ ...card, flex: 1 }}>
<CollabChat tripId={tripId} currentUser={user} />
</div>
</div>
)
}
if (chatOn) {
// Chat left (380px) + right panels
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
<div style={{ ...card, flex: '0 0 380px' }}>
<CollabChat tripId={tripId} currentUser={user} />
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
{rightPanels.length === 1 && (
<div style={{ ...card, flex: 1 }}>
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
)}
{rightPanels.length === 2 && rightPanels.map(p => (
<div key={p} style={{ ...card, flex: 1 }}>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
))}
{rightPanels.length === 3 && (
<>
<div style={{ ...card, flex: 1 }}>
<CollabNotes tripId={tripId} currentUser={user} />
</div>
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
<div style={{ ...card, flex: 1 }}>
<CollabPolls tripId={tripId} currentUser={user} />
</div>
<div style={{ ...card, flex: 1 }}>
<WhatsNextWidget tripMembers={tripMembers} />
</div>
</div>
</>
)}
</div>
</div>
)
}
// Chat off — remaining panels share full width
const panels = rightPanels
if (panels.length === 1) {
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
<div style={{ ...card, flex: 1 }}>
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
</div>
)
}
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
{/* Chat — left, fixed width */}
<div style={{ ...card, flex: '0 0 380px' }}>
<CollabChat tripId={tripId} currentUser={user} />
</div>
{/* Right column: Notes top, Polls + What's Next bottom */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
{/* Notes — top */}
<div style={{ ...card, flex: 1 }}>
<CollabNotes tripId={tripId} currentUser={user} />
{panels.map(p => (
<div key={p} style={{ ...card, flex: 1 }}>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
{/* Polls + What's Next — bottom row */}
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
<div style={{ ...card, flex: 1 }}>
<CollabPolls tripId={tripId} currentUser={user} />
</div>
<div style={{ ...card, flex: 1 }}>
<WhatsNextWidget tripMembers={tripMembers} />
</div>
</div>
</div>
))}
</div>
)
}
// Mobile: tab bar + single panel
// Mobile: tab bar + single panel (only enabled tabs)
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'absolute', inset: 0 }}>
<div style={{
@@ -84,7 +172,6 @@ export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelPro
background: 'var(--bg-card)', flexShrink: 0,
}}>
{tabs.map(tab => {
const Icon = tab.icon
const active = mobileTab === tab.id
return (
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
@@ -102,10 +189,10 @@ export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelPro
</div>
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
{mobileTab === 'chat' && <CollabChat tripId={tripId} currentUser={user} />}
{mobileTab === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{mobileTab === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{mobileTab === 'next' && <WhatsNextWidget tripMembers={tripMembers} />}
{mobileTab === 'chat' && features.chat && <CollabChat tripId={tripId} currentUser={user} />}
{mobileTab === 'notes' && features.notes && <CollabNotes tripId={tripId} currentUser={user} />}
{mobileTab === 'polls' && features.polls && <CollabPolls tripId={tripId} currentUser={user} />}
{mobileTab === 'next' && features.whatsnext && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
</div>
)
+21 -11
View File
@@ -33,6 +33,8 @@ interface Props {
dark?: boolean
activeMarkerId?: string | null
onMarkerClick?: (id: string, type?: string) => void
fullScreen?: boolean
paddingBottom?: number
}
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
@@ -57,15 +59,20 @@ const MARKER_W = 28
const MARKER_H = 36
function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
// Highlighted: inverted colors for contrast (black on light, white on dark)
const fill = dark
? (highlighted ? '#FAFAFA' : '#FAFAFA')
: (highlighted ? '#18181B' : '#18181B')
? (highlighted ? '#FAFAFA' : '#A1A1AA')
: (highlighted ? '#18181B' : '#52525B')
const textColor = dark
? (highlighted ? '#18181B' : '#18181B')
: (highlighted ? '#fff' : '#fff')
const stroke = dark ? '#3F3F46' : '#fff'
const stroke = highlighted
? (dark ? '#fff' : '#18181B')
: (dark ? '#3F3F46' : '#fff')
const shadow = highlighted
? 'filter:drop-shadow(0 0 8px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))'
? (dark
? 'filter:drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
: 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
const label = String(index + 1)
const scale = highlighted ? 1.2 : 1
@@ -82,7 +89,7 @@ function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick },
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
ref
) {
const stableTrail = trail || EMPTY_TRAIL
@@ -138,7 +145,9 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
highlightMarker(id)
const marker = markersRef.current.get(id)
if (marker && mapRef.current) {
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
try {
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
} catch { /* map not yet initialized */ }
}
}, [])
@@ -156,7 +165,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
const map = L.map(containerRef.current, {
zoomControl: false,
attributionControl: true,
scrollWheelZoom: false,
scrollWheelZoom: fullScreen ? true : false,
dragging: true,
touchZoom: true,
})
@@ -185,8 +194,8 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
coords.forEach(c => allCoords.push(c))
}
// route polyline — subtle dashed connection
if (items.length > 1) {
// route polyline — only in non-fullscreen (sidebar map) mode
if (!fullScreen && items.length > 1) {
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
L.polyline(routeCoords, {
color: dark ? '#71717A' : '#A1A1AA',
@@ -229,7 +238,8 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
try {
map.invalidateSize()
if (allCoords.length > 0) {
map.fitBounds(L.latLngBounds(allCoords), { padding: [50, 50], maxZoom: 14 })
const pb = paddingBottom || 50
map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 14 })
} else {
map.setView([30, 0], 2)
}
@@ -245,7 +255,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
mapRef.current = null
markersRef.current.clear()
}
}, [entries, stableTrail, dark, mapTileUrl])
}, [entries, stableTrail, dark, mapTileUrl, fullScreen, paddingBottom])
// react to activeMarkerId prop changes — runs after map is built
useEffect(() => {
@@ -0,0 +1,154 @@
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
const MOOD_ICONS: Record<string, typeof Smile> = {
amazing: Laugh,
good: Smile,
neutral: Meh,
rough: Frown,
}
const MOOD_COLORS: Record<string, string> = {
amazing: 'text-pink-500',
good: 'text-amber-500',
neutral: 'text-zinc-400',
rough: 'text-violet-500',
}
const WEATHER_ICONS: Record<string, typeof Sun> = {
sunny: Sun,
partly: CloudSun,
cloudy: Cloud,
rainy: CloudRain,
stormy: CloudLightning,
cold: Snowflake,
}
function photoUrl(p: JourneyPhoto): string {
return `/api/photos/${p.photo_id}/thumbnail`
}
function stripMarkdown(text: string): string {
return text
.replace(/[#*_~`>\[\]()!|-]/g, '')
.replace(/\n+/g, ' ')
.trim()
}
interface Props {
entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null }
index: number
isActive: boolean
onClick: () => void
publicPhotoUrl?: (photoId: number) => string
}
export default function MobileEntryCard({ entry, index, isActive, onClick, publicPhotoUrl }: Props) {
const hasLocation = !!(entry.location_lat && entry.location_lng)
const hasPhotos = entry.photos && entry.photos.length > 0
const firstPhoto = hasPhotos ? entry.photos![0] : null
const MoodIcon = entry.mood ? MOOD_ICONS[entry.mood] : null
const moodColor = entry.mood ? MOOD_COLORS[entry.mood] : ''
const WeatherIcon = entry.weather ? WEATHER_ICONS[entry.weather] : null
const thumbSrc = firstPhoto
? publicPhotoUrl
? publicPhotoUrl((firstPhoto as any).photo_id ?? (firstPhoto as any).id)
: photoUrl(firstPhoto as JourneyPhoto)
: null
const date = new Date(entry.entry_date + 'T00:00:00')
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
const storyPreview = entry.story ? stripMarkdown(entry.story) : ''
return (
<button
onClick={onClick}
className={`flex-shrink-0 rounded-xl overflow-hidden text-left transition-all duration-100 ${
isActive
? 'w-[320px] sm:w-[340px] bg-white dark:bg-zinc-800 shadow-lg ring-2 ring-zinc-900/70 dark:ring-white/60'
: 'w-[240px] sm:w-[260px] bg-white/90 dark:bg-zinc-800/90 shadow-md'
} backdrop-blur-lg`}
>
<div className={`flex ${isActive ? 'h-[140px]' : 'h-[110px]'} transition-all duration-100`}>
{/* Photo thumbnail */}
{thumbSrc ? (
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 relative overflow-hidden transition-all duration-100`}>
<img
src={thumbSrc}
alt=""
className="w-full h-full object-cover"
loading="lazy"
/>
{hasPhotos && entry.photos!.length > 1 && (
<div className="absolute bottom-1 right-1 flex items-center gap-0.5 bg-black/60 text-white rounded px-1 py-0.5 text-[10px] font-medium">
<Camera size={10} />
{entry.photos!.length}
</div>
)}
</div>
) : (
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 bg-zinc-100 dark:bg-zinc-700 flex items-center justify-center transition-all duration-100`}>
<MapPin size={20} className="text-zinc-300 dark:text-zinc-500" />
</div>
)}
{/* Content */}
<div className="flex-1 p-3 flex flex-col min-w-0">
{/* Day number + date + mood/weather */}
<div className="flex items-center gap-1.5 mb-1">
<span className="w-5 h-5 rounded bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] font-bold flex items-center justify-center flex-shrink-0">
{index + 1}
</span>
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
{entry.entry_time && (
<span className="text-[11px] text-zinc-400">· {entry.entry_time.slice(0, 5)}</span>
)}
<div className="flex items-center gap-1.5 ml-auto flex-shrink-0">
{MoodIcon && (
<span className={`inline-flex items-center justify-center w-5 h-5 rounded-full ${
entry.mood === 'amazing' ? 'bg-pink-100 dark:bg-pink-900/30' :
entry.mood === 'good' ? 'bg-amber-100 dark:bg-amber-900/30' :
entry.mood === 'rough' ? 'bg-violet-100 dark:bg-violet-900/30' :
'bg-zinc-100 dark:bg-zinc-700'
}`}>
<MoodIcon size={11} className={moodColor} />
</span>
)}
{WeatherIcon && (
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-zinc-100 dark:bg-zinc-700">
<WeatherIcon size={11} className="text-zinc-500 dark:text-zinc-400" />
</span>
)}
</div>
</div>
{/* Title */}
<h4 className="text-[13px] font-semibold text-zinc-900 dark:text-white leading-tight truncate">
{entry.title || (entry.type === 'checkin' ? 'Check-in' : entry.type === 'skeleton' ? 'Add your story…' : 'Untitled')}
</h4>
{/* Story preview (1-2 lines, only on active card) */}
{isActive && storyPreview && (
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 leading-snug mt-0.5 line-clamp-2">
{storyPreview}
</p>
)}
{/* Location badge */}
<div className="flex items-center gap-1 mt-auto">
{hasLocation ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" />
<span className="truncate">{entry.location_name || 'On the map'}</span>
</span>
) : (
<span className="text-[10px] text-zinc-400 italic">No location</span>
)}
</div>
</div>
</div>
</button>
)
}
@@ -0,0 +1,218 @@
import { useState } from 'react'
import {
X, Pencil, Trash2, MapPin, Clock, Camera,
Laugh, Smile, Meh, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
ThumbsUp, ThumbsDown, ChevronDown,
} from 'lucide-react'
import JournalBody from './JournalBody'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
neutral: { icon: Meh, label: 'Neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' },
rough: { icon: Frown, label: 'Rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' },
}
const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
sunny: { icon: Sun, label: 'Sunny' },
partly: { icon: CloudSun, label: 'Partly cloudy' },
cloudy: { icon: Cloud, label: 'Cloudy' },
rainy: { icon: CloudRain, label: 'Rainy' },
stormy: { icon: CloudLightning, label: 'Stormy' },
cold: { icon: Snowflake, label: 'Cold' },
}
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string {
return `/api/photos/${p.photo_id}/${size}`
}
interface Props {
entry: JourneyEntry
onClose: () => void
onEdit: () => void
onDelete: () => void
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
}
export default function MobileEntryView({ entry, onClose, onEdit, onDelete, onPhotoClick }: Props) {
const photos = entry.photos || []
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
const prosArr = entry.pros_cons?.pros ?? []
const consArr = entry.pros_cons?.cons ?? []
const hasProscons = prosArr.length > 0 || consArr.length > 0
const date = new Date(entry.entry_date + 'T00:00:00')
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
return (
<div className="fixed inset-0 z-50 bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
{/* Top bar */}
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
<button
onClick={onClose}
className="w-9 h-9 rounded-lg flex items-center justify-center text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
>
<X size={20} />
</button>
<div className="flex items-center gap-1.5">
<button
onClick={() => { onClose(); onEdit(); }}
className="h-8 px-3 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[12px] font-medium flex items-center gap-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
>
<Pencil size={13} />
Edit
</button>
<button
onClick={() => { onClose(); onDelete(); }}
className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 transition-colors"
>
<Trash2 size={15} />
</button>
</div>
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain" style={{ WebkitOverflowScrolling: 'touch' }}>
{/* Hero photo(s) */}
{photos.length > 0 && (
<div className="relative">
<img
src={photoUrl(photos[0])}
alt=""
className="w-full max-h-[50vh] object-cover cursor-pointer"
onClick={() => onPhotoClick(photos, 0)}
/>
{photos.length > 1 && (
<div className="absolute bottom-3 right-3 flex items-center gap-1 bg-black/60 backdrop-blur-sm text-white rounded-full px-2.5 py-1 text-[11px] font-medium">
<Camera size={12} />
{photos.length} photos
</div>
)}
{/* Photo strip for multiple photos */}
{photos.length > 1 && (
<div className="flex gap-1 px-4 py-2 overflow-x-auto bg-zinc-50 dark:bg-zinc-900">
{photos.map((p, i) => (
<img
key={p.id || i}
src={photoUrl(p, 'thumbnail')}
alt=""
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
onClick={() => onPhotoClick(photos, i)}
/>
))}
</div>
)}
</div>
)}
{/* Content */}
<div className="px-5 py-5 pb-32">
{/* Date + time + location header */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<span className="text-[12px] font-medium text-zinc-500">{dateStr}</span>
{entry.entry_time && (
<span className="flex items-center gap-1 text-[12px] text-zinc-400">
<Clock size={11} />
{entry.entry_time.slice(0, 5)}
</span>
)}
</div>
{entry.location_name && (
<div className="mb-3">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300">
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
{entry.location_name}
</span>
</div>
)}
{/* Title */}
{entry.title && (
<h1 className="text-[22px] font-bold text-zinc-900 dark:text-white tracking-tight leading-tight mb-4">
{entry.title}
</h1>
)}
{/* Mood + Weather chips */}
{(mood || weather) && (
<div className="flex items-center gap-2 mb-4">
{mood && (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold ${mood.bg} ${mood.text}`}>
<mood.icon size={13} />
{mood.label}
</span>
)}
{weather && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
<weather.icon size={13} />
{weather.label}
</span>
)}
</div>
)}
{/* Story */}
{entry.story && (
<div className="text-[14px] leading-relaxed text-zinc-700 dark:text-zinc-300 mb-5">
<JournalBody text={entry.story} />
</div>
)}
{/* Tags */}
{entry.tags && entry.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-5">
{entry.tags.map((tag, i) => (
<span key={i} className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400">
{tag}
</span>
))}
</div>
)}
{/* Pros & Cons */}
{hasProscons && (
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-5">
{prosArr.length > 0 && (
<div className="px-4 py-3">
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 uppercase tracking-wide mb-2">
<ThumbsUp size={12} /> Pros
</div>
<ul className="space-y-1">
{prosArr.map((p, i) => (
<li key={i} className="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
<span className="text-emerald-500 mt-0.5">+</span> {p}
</li>
))}
</ul>
</div>
)}
{prosArr.length > 0 && consArr.length > 0 && (
<div className="border-t border-zinc-200 dark:border-zinc-700" />
)}
{consArr.length > 0 && (
<div className="px-4 py-3">
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-red-500 dark:text-red-400 uppercase tracking-wide mb-2">
<ThumbsDown size={12} /> Cons
</div>
<ul className="space-y-1">
{consArr.map((c, i) => (
<li key={i} className="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span> {c}
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,194 @@
import { useRef, useState, useEffect, useCallback } from 'react'
import { Plus } from 'lucide-react'
import JourneyMap from './JourneyMap'
import MobileEntryCard from './MobileEntryCard'
import type { JourneyMapHandle } from './JourneyMap'
import type { JourneyEntry } from '../../store/journeyStore'
interface MapEntry {
id: string
lat: number
lng: number
title?: string | null
mood?: string | null
entry_date: string
}
interface Props {
entries: JourneyEntry[] | any[]
mapEntries: MapEntry[]
trail?: { lat: number; lng: number }[]
dark?: boolean
readOnly?: boolean
onEntryClick: (entry: any) => void
onAddEntry?: () => void
publicPhotoUrl?: (photoId: number) => string
}
export default function MobileMapTimeline({
entries,
mapEntries,
trail,
dark,
readOnly,
onEntryClick,
onAddEntry,
publicPhotoUrl,
}: Props) {
const mapRef = useRef<JourneyMapHandle>(null)
const carouselRef = useRef<HTMLDivElement>(null)
const [activeIndex, setActiveIndex] = useState(0)
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
// Sync map focus when carousel scrolls (with guard for uninitialized map)
const syncMapToCarousel = useCallback((index: number) => {
const entry = entries[index]
if (!entry) return
const mapEntry = mapEntries.find(m => String(m.id) === String(entry.id))
if (mapEntry) {
try { mapRef.current?.focusMarker(String(mapEntry.id)) } catch {}
} else {
try { mapRef.current?.highlightMarker(null) } catch {}
}
}, [entries, mapEntries])
// IntersectionObserver for instant snap detection
useEffect(() => {
const el = carouselRef.current
if (!el || entries.length === 0) return
const observer = new IntersectionObserver(
(observed) => {
for (const o of observed) {
if (o.isIntersecting) {
const idx = Number(o.target.getAttribute('data-idx'))
if (!isNaN(idx)) {
setActiveIndex(idx)
syncMapToCarousel(idx)
}
}
}
},
{ root: el, threshold: 0.6 },
)
cardRefs.current.forEach(node => observer.observe(node))
return () => observer.disconnect()
}, [entries.length, syncMapToCarousel])
// Scroll carousel to entry when map marker is clicked
const handleMarkerClick = useCallback((id: string) => {
const idx = entries.findIndex((e: any) => String(e.id) === id)
if (idx === -1) return
setActiveIndex(idx)
const el = carouselRef.current
if (!el) return
const cardWidth = 272
el.scrollTo({ left: idx * cardWidth, behavior: 'smooth' })
}, [entries])
// Initial map focus — delay to let Leaflet initialize and fitBounds
useEffect(() => {
if (entries.length > 0) {
const timer = setTimeout(() => syncMapToCarousel(0), 500)
return () => clearTimeout(timer)
}
}, [entries.length])
const activeEntryId = entries[activeIndex]
? String(entries[activeIndex].id)
: null
if (entries.length === 0) {
return (
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
<JourneyMap
ref={mapRef}
entries={mapEntries}
checkins={[]}
trail={trail}
height={9999}
dark={dark}
onMarkerClick={handleMarkerClick}
fullScreen
/>
{!readOnly && onAddEntry && (
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] right-4 z-30">
<button
onClick={onAddEntry}
className="w-10 h-10 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
>
<Plus size={18} />
</button>
</div>
)}
</div>
)
}
return (
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
{/* Full-screen map */}
<JourneyMap
ref={mapRef}
entries={mapEntries}
checkins={[]}
trail={trail}
height={9999}
dark={dark}
activeMarkerId={activeEntryId}
onMarkerClick={handleMarkerClick}
fullScreen
paddingBottom={200}
/>
{/* Bottom carousel */}
<div
className="fixed bottom-20 left-0 right-0 z-40"
style={{ touchAction: 'pan-x' }}
>
<div
ref={carouselRef}
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
style={{
scrollSnapType: 'x mandatory',
WebkitOverflowScrolling: 'touch',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
>
{entries.map((entry: any, i: number) => (
<div
key={entry.id}
data-idx={i}
ref={node => { if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i); }}
style={{ scrollSnapAlign: 'center' }}
>
<MobileEntryCard
entry={entry}
index={i}
isActive={i === activeIndex}
onClick={() => onEntryClick(entry)}
publicPhotoUrl={publicPhotoUrl}
/>
</div>
))}
</div>
</div>
{/* FAB: add entry — top right */}
{!readOnly && onAddEntry && (
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] right-4 z-30">
<button
onClick={onAddEntry}
className="w-10 h-10 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
>
<Plus size={18} />
</button>
</div>
)}
</div>
)
}
@@ -214,6 +214,38 @@ const texts: Record<string, DemoTexts> = {
selfHostLink: 'استضفه بنفسك',
close: 'فهمت',
},
id: {
titleBefore: 'Selamat datang di ',
titleAfter: '',
title: 'Selamat datang di Demo TREK',
description: 'Anda dapat melihat, mengedit, dan membuat perjalanan. Semua perubahan akan diatur ulang secara otomatis setiap jam.',
resetIn: 'Atur ulang berikutnya dalam',
minutes: 'menit',
uploadNote: 'Unggah file (foto, dokumen, sampul) dinonaktifkan dalam mode demo.',
fullVersionTitle: 'Selain itu dalam versi lengkap:',
features: [
'Unggah file (foto, dokumen, sampul)',
'Manajemen kunci API (Google Maps, Cuaca)',
'Manajemen pengguna & izin',
'Pencadangan otomatis',
'Manajemen Addon (aktifkan/nonaktifkan)',
'OIDC / SSO single sign-on',
],
addonsTitle: 'Addon Modular (dapat dinonaktifkan di versi lengkap)',
addons: [
['Vacay', 'Perencana liburan dengan kalender, hari libur & penggabungan pengguna'],
['Atlas', 'Peta dunia dengan negara yang dikunjungi & statistik perjalanan'],
['Pengepakan', 'Daftar periksa per perjalanan'],
['Anggaran', 'Pelacakan pengeluaran dengan pemisahan tagihan'],
['Dokumen', 'Lampirkan file ke perjalanan'],
['Widget', 'Konverter mata uang & zona waktu'],
],
whatIs: 'Apa itu TREK?',
whatIsDesc: 'Perencana perjalanan yang di-host sendiri dengan kolaborasi real-time, peta interaktif, login OIDC, dan mode gelap.',
selfHost: 'Buka sumber — ',
selfHostLink: 'host mandiri',
close: 'Mengerti',
},
}
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
+1 -1
View File
@@ -242,7 +242,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
<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"
<a href="https://discord.gg/NhZBDSd4qW" 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)'}
+19 -1
View File
@@ -8,6 +8,8 @@ import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
import { mapsApi } from '../../api/client'
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import ReservationOverlay from './ReservationOverlay'
import type { Reservation } from '../../types'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -384,7 +386,16 @@ export const MapView = memo(function MapView({
rightWidth = 0,
hasInspector = false,
hasDayDetail = false,
}) {
reservations = [] as Reservation[],
showReservationStats = false,
visibleConnectionIds = [] as number[],
onReservationClick,
}: any) {
const visibleReservations = useMemo(() => {
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
const set = new Set(visibleConnectionIds)
return reservations.filter((r: Reservation) => set.has(r.id))
}, [reservations, visibleConnectionIds])
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
const paddingOpts = useMemo(() => {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
@@ -569,6 +580,13 @@ export const MapView = memo(function MapView({
)
} catch { return null }
})}
<ReservationOverlay
reservations={visibleReservations}
showConnections
showStats={showReservationStats}
onEndpointClick={onReservationClick}
/>
</MapContainer>
)
})
@@ -0,0 +1,446 @@
import { createElement, useEffect, useMemo, useRef, useState } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
import L from 'leaflet'
import { Plane, Train, Ship, Car } from 'lucide-react'
import { useSettingsStore } from '../../store/settingsStore'
import type { Reservation, ReservationEndpoint } from '../../types'
const ENDPOINT_PANE = 'reservation-endpoints'
const AIRPORT_BADGE_HALF_PX = 16
const BADGE_GAP_PX = 5
type TransportType = 'flight' | 'train' | 'cruise' | 'car'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car']
const TRANSPORT_COLOR = '#3b82f6'
const TYPE_META: Record<TransportType, { color: string; icon: typeof Plane; geodesic: boolean }> = {
flight: { color: TRANSPORT_COLOR, icon: Plane, geodesic: true },
train: { color: TRANSPORT_COLOR, icon: Train, geodesic: false },
cruise: { color: TRANSPORT_COLOR, icon: Ship, geodesic: true },
car: { color: TRANSPORT_COLOR, icon: Car, geodesic: false },
}
function useEndpointPane() {
const map = useMap()
useMemo(() => {
if (!map.getPane(ENDPOINT_PANE)) {
const pane = map.createPane(ENDPOINT_PANE)
pane.style.zIndex = '650'
pane.style.pointerEvents = 'auto'
}
}, [map])
}
function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
const { icon: IconCmp, color } = TYPE_META[type]
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
const labelHtml = label ? `<span>${label}</span>` : ''
const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26
return L.divIcon({
className: 'trek-endpoint-marker',
html: `<div style="
display:inline-flex;align-items:center;justify-content:center;gap:4px;
padding:0 8px;border-radius:999px;
background:${color};box-shadow:0 2px 6px rgba(0,0,0,0.25);
border:1.5px solid #fff;color:#fff;
font-family:-apple-system,system-ui,sans-serif;font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
box-sizing:border-box;height:22px;white-space:nowrap;
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml ? `<span style="display:inline-flex;align-items:center;line-height:1">${label}</span>` : ''}</div>`,
iconSize: [estWidth, 22],
iconAnchor: [estWidth / 2, 11],
popupAnchor: [0, -11],
})
}
function toRad(d: number) { return d * Math.PI / 180 }
function toDeg(r: number) { return r * 180 / Math.PI }
function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
if (d === 0) return [a, b]
const pts: [number, number][] = []
for (let i = 0; i <= steps; i++) {
const f = i / steps
const A = Math.sin((1 - f) * d) / Math.sin(d)
const B = Math.sin(f * d) / Math.sin(d)
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
const lng = Math.atan2(y, x)
pts.push([toDeg(lat), toDeg(lng)])
}
return pts
}
function splitAntimeridian(points: [number, number][]): [number, number][][] {
const segments: [number, number][][] = []
let cur: [number, number][] = []
for (let i = 0; i < points.length; i++) {
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
if (cur.length > 1) segments.push(cur)
cur = []
}
cur.push(points[i])
}
if (cur.length > 1) segments.push(cur)
return segments
}
function cleanName(name: string): string {
return name.replace(/\s*\([^)]*\)/g, '').trim()
}
function haversineKm(a: [number, number], b: [number, number]): number {
const R = 6371
const dLat = toRad(b[0] - a[0])
const dLng = toRad(b[1] - a[1])
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(h))
}
function parseInTz(isoLocal: string, tz: string): number {
const [datePart, timePart] = isoLocal.split('T')
const [y, mo, d] = datePart.split('-').map(Number)
const [h, mi] = (timePart || '00:00').split(':').map(Number)
const guess = Date.UTC(y, mo - 1, d, h, mi)
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: tz, hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
const parts = Object.fromEntries(fmt.formatToParts(new Date(guess)).filter(p => p.type !== 'literal').map(p => [p.type, p.value]))
const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) % 24, Number(parts.minute), Number(parts.second))
return guess - (asUtc - guess)
}
function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fallbackStart: string | null, fallbackEnd: string | null): string | null {
let start = from.local_date && from.local_time ? `${from.local_date}T${from.local_time}` : fallbackStart
let end = to.local_date && to.local_time ? `${to.local_date}T${to.local_time}` : fallbackEnd
if (!start || !end) return null
if (!start.includes('T') && end.includes('T')) start = `${end.split('T')[0]}T${start}`
if (!end.includes('T') && start.includes('T')) end = `${start.split('T')[0]}T${end}`
if (!start.includes('T') || !end.includes('T')) return null
const fromTz = from.timezone || to.timezone
const toTz = to.timezone || fromTz
let startMs: number, endMs: number
if (fromTz && toTz) {
startMs = parseInTz(start, fromTz)
endMs = parseInTz(end, toTz)
} else {
startMs = new Date(start).getTime()
endMs = new Date(end).getTime()
}
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null
if (endMs <= startMs) endMs += 24 * 60 * 60000
const minutes = Math.round((endMs - startMs) / 60000)
if (minutes <= 0 || minutes > 48 * 60) return null
const h = Math.floor(minutes / 60)
const m = minutes % 60
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
interface TransportItem {
res: Reservation
from: ReservationEndpoint
to: ReservationEndpoint
type: TransportType
arcs: [number, number][][]
primaryArc: [number, number][]
fallback: [number, number]
mainLabel: string | null
subLabel: string | null
}
function buildStatsHtml(color: string, mainLabel: string | null, subLabel: string | null): { html: string; width: number; height: number } {
const estWidth = Math.max(
mainLabel ? mainLabel.length * 6.5 : 0,
subLabel ? subLabel.length * 5.5 : 0,
) + 22
const hasBoth = !!mainLabel && !!subLabel
const height = hasBoth ? 36 : 22
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${mainLabel}</span>` : ''
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${subLabel}</span>` : ''
const html = `<div class="trek-stats-inner" style="
display:flex;flex-direction:column;align-items:center;justify-content:center;
width:100%;height:100%;
padding:0 11px;border-radius:999px;
background:rgba(17,24,39,0.92);color:#fff;
box-shadow:0 2px 6px rgba(0,0,0,0.25);
border:1px solid ${color}aa;
font-family:-apple-system,system-ui,'SF Pro Text',sans-serif;
white-space:nowrap;box-sizing:border-box;
transform-origin:center;
will-change:transform;
">${main}${sub}</div>`
return { html, width: estWidth, height }
}
function StatsLabel({ item }: { item: TransportItem }) {
const map = useMap()
const markerRef = useRef<L.Marker | null>(null)
const innerRef = useRef<HTMLElement | null>(null)
const arc = item.primaryArc
const color = TYPE_META[item.type].color
const { html, width, height } = useMemo(() => buildStatsHtml(color, item.mainLabel, item.subLabel), [color, item.mainLabel, item.subLabel])
const buffer = AIRPORT_BADGE_HALF_PX + width / 2 + BADGE_GAP_PX
const compute = () => {
if (arc.length < 2) return null
const size = map.getSize()
const pts = arc.map(p => map.latLngToContainerPoint(p as L.LatLngTuple))
const cum: number[] = [0]
let total = 0
for (let i = 1; i < pts.length; i++) {
total += pts[i].distanceTo(pts[i - 1])
cum.push(total)
}
if (total <= 0) return null
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
const isIn = (p: L.Point) => {
if (p.x < -40 || p.x > size.x + 40 || p.y < -40 || p.y > size.y + 40) return false
if (p.distanceTo(fromPx) < buffer) return false
if (p.distanceTo(toPx) < buffer) return false
return true
}
let firstIdx = -1
let lastIdx = -1
for (let i = 0; i < pts.length; i++) {
if (isIn(pts[i])) {
if (firstIdx < 0) firstIdx = i
lastIdx = i
}
}
if (firstIdx < 0) {
const target = total / 2
let sIdx = 0
while (sIdx < cum.length - 2 && cum[sIdx + 1] < target) sIdx++
const span = cum[sIdx + 1] - cum[sIdx]
const tm = span > 0 ? (target - cum[sIdx]) / span : 0
const pA = pts[sIdx]
const pB = pts[sIdx + 1]
const mx = pA.x + (pB.x - pA.x) * tm
const my = pA.y + (pB.y - pA.y) * tm
const latlng = map.containerPointToLatLng([mx, my])
let angle = Math.atan2(pB.y - pA.y, pB.x - pA.x) * 180 / Math.PI
if (angle > 90) angle -= 180
if (angle < -90) angle += 180
return { point: [latlng.lat, latlng.lng] as [number, number], angle }
}
const bisectFraction = (a: L.Point, b: L.Point) => {
let lo = 0, hi = 1
for (let k = 0; k < 10; k++) {
const mid = (lo + hi) / 2
const mp = L.point(a.x + (b.x - a.x) * mid, a.y + (b.y - a.y) * mid)
if (isIn(mp)) hi = mid
else lo = mid
}
return (lo + hi) / 2
}
let lowCum = cum[firstIdx]
if (firstIdx > 0) {
const t = bisectFraction(pts[firstIdx - 1], pts[firstIdx])
lowCum = cum[firstIdx - 1] + (cum[firstIdx] - cum[firstIdx - 1]) * t
}
let highCum = cum[lastIdx]
if (lastIdx < pts.length - 1) {
const t = bisectFraction(pts[lastIdx + 1], pts[lastIdx])
highCum = cum[lastIdx] + (cum[lastIdx + 1] - cum[lastIdx]) * (1 - t)
}
const targetLen = (lowCum + highCum) / 2
let segIdx = 0
while (segIdx < cum.length - 2 && cum[segIdx + 1] < targetLen) segIdx++
const segSpan = cum[segIdx + 1] - cum[segIdx]
const t = segSpan > 0 ? (targetLen - cum[segIdx]) / segSpan : 0
const pA = pts[segIdx]
const pB = pts[segIdx + 1]
const px = pA.x + (pB.x - pA.x) * t
const py = pA.y + (pB.y - pA.y) * t
const latlng = map.containerPointToLatLng([px, py])
let angle = Math.atan2(pB.y - pA.y, pB.x - pA.x) * 180 / Math.PI
if (angle > 90) angle -= 180
if (angle < -90) angle += 180
return { point: [latlng.lat, latlng.lng] as [number, number], angle }
}
const apply = () => {
const pose = compute()
const marker = markerRef.current
if (!marker) return
const el = marker.getElement() as HTMLElement | null
if (!pose) {
if (el) el.style.display = 'none'
return
}
if (el) el.style.display = ''
marker.setLatLng(pose.point as L.LatLngTuple)
if (!innerRef.current && el) innerRef.current = el.querySelector('.trek-stats-inner') as HTMLElement | null
if (innerRef.current) innerRef.current.style.transform = `rotate(${pose.angle}deg)`
}
useEffect(() => {
const icon = L.divIcon({
className: 'trek-endpoint-stats',
html,
iconSize: [width, height],
iconAnchor: [width / 2, height / 2],
})
const marker = L.marker([0, 0], { icon, pane: ENDPOINT_PANE, interactive: false, keyboard: false })
marker.addTo(map)
markerRef.current = marker
innerRef.current = null
apply()
return () => {
marker.remove()
markerRef.current = null
innerRef.current = null
}
}, [map, html, width, height])
useMapEvents({
move: apply,
zoom: apply,
viewreset: apply,
resize: apply,
})
return null
}
interface Props {
reservations: Reservation[]
showConnections: boolean
showStats: boolean
onEndpointClick?: (reservationId: number) => void
}
export default function ReservationOverlay({ reservations, showConnections, showStats, onEndpointClick }: Props) {
useEndpointPane()
const map = useMap()
const [zoom, setZoom] = useState(() => map.getZoom())
useMapEvents({
zoomend: () => setZoom(map.getZoom()),
})
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
const items = useMemo<TransportItem[]>(() => {
const out: TransportItem[] = []
for (const r of reservations) {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
const eps = r.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
if (!from || !to) continue
const type = r.type as TransportType
const isGeo = TYPE_META[type].geodesic
const arcs = isGeo
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
: [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
const primaryArc = arcs[primaryIdx] ?? []
const fallback: [number, number] = primaryArc.length > 0
? (primaryArc[Math.floor(primaryArc.length / 2)] ?? [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2])
: [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2]
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
const mainLabel = from.code && to.code ? `${from.code}${to.code}` : null
const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
out.push({ res: r, from, to, type, arcs, primaryArc, fallback, mainLabel, subLabel })
}
return out
}, [reservations])
const visibleItems = useMemo(() => {
return items.filter(item => {
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
return fromPx.distanceTo(toPx) >= minPx
})
}, [items, zoom, map])
const labelVisibleIds = useMemo(() => {
const set = new Set<number>()
for (const item of visibleItems) {
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : 400
if (fromPx.distanceTo(toPx) >= minPx) set.add(item.res.id)
}
return set
}, [visibleItems, zoom, map])
if (!showConnections) return null
return (
<>
{visibleItems.map(item => item.arcs.map((seg, segIdx) => (
<Polyline
key={`line-${item.res.id}-${segIdx}`}
positions={seg}
pathOptions={{
color: TYPE_META[item.type].color,
weight: 2.5,
opacity: item.res.status === 'confirmed' ? 0.75 : 0.55,
dashArray: item.res.status === 'confirmed' ? undefined : '6, 6',
}}
/>
)))}
{visibleItems.flatMap(item => [
<Marker
key={`from-${item.res.id}`}
position={[item.from.lat, item.from.lng]}
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.from.code || cleanName(item.from.name)) : null)}
pane={ENDPOINT_PANE}
zIndexOffset={1000}
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
>
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.from.name}</div>
{item.res.title && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{item.res.title}</div>}
</Tooltip>
</Marker>,
<Marker
key={`to-${item.res.id}`}
position={[item.to.lat, item.to.lng]}
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.to.code || cleanName(item.to.name)) : null)}
pane={ENDPOINT_PANE}
zIndexOffset={1000}
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
>
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.to.name}</div>
{item.res.title && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{item.res.title}</div>}
</Tooltip>
</Marker>,
])}
{showStats && visibleItems.map(item => item.type === 'flight' && (item.mainLabel || item.subLabel) && labelVisibleIds.has(item.res.id) && (
<StatsLabel key={`stats-${item.res.id}`} item={item} />
))}
</>
)
}
@@ -85,7 +85,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
// Album linking
const [showAlbumPicker, setShowAlbumPicker] = useState(false)
const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number }[]>([])
const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number; passphrase?: string }[]>([])
const [albumsLoading, setAlbumsLoading] = useState(false)
const [albumLinks, setAlbumLinks] = useState<{ id: number; provider: string; album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([])
const [syncing, setSyncing] = useState<number | null>(null)
@@ -141,7 +141,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
await loadAlbums(selectedProvider)
}
const linkAlbum = async (albumId: string, albumName: string) => {
const linkAlbum = async (albumId: string, albumName: string, passphrase?: string) => {
if (!selectedProvider) {
toast.error(t('memories.error.linkAlbum'))
return
@@ -152,6 +152,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
album_id: albumId,
album_name: albumName,
provider: selectedProvider,
...(passphrase ? { passphrase } : {}),
})
setShowAlbumPicker(false)
await loadAlbumLinks()
@@ -489,7 +490,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{albums.map(album => {
const isLinked = linkedIds.has(album.id)
return (
<button key={album.id} onClick={() => !isLinked && linkAlbum(album.id, album.albumName)}
<button key={album.id} onClick={() => !isLinked && linkAlbum(album.id, album.albumName, album.passphrase)}
disabled={isLinked}
style={{
display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px',
@@ -1,8 +1,8 @@
// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-006
//
// JourneyBookPDF.tsx exports an async function `downloadJourneyBookPDF(journey)`
// that opens a new browser window and writes a full HTML document into it.
// It does NOT render a React component. Tests verify window.open behaviour.
// that renders a PDF preview in an srcdoc iframe overlay (Safari-safe pattern).
// Tests verify the overlay DOM structure and HTML content.
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
@@ -77,55 +77,57 @@ function buildJourney(overrides: Partial<JourneyDetail> = {}): JourneyDetail {
} as unknown as JourneyDetail;
}
// ── Mock window.open ─────────────────────────────────────────────────────────
// ── Helpers to inspect the overlay ───────────────────────────────────────────
let mockWindow: {
document: { write: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> };
focus: ReturnType<typeof vi.fn>;
};
function getOverlay(): HTMLElement | null {
return document.getElementById('journey-pdf-overlay');
}
beforeEach(() => {
mockWindow = {
document: { write: vi.fn(), close: vi.fn() },
focus: vi.fn(),
};
vi.spyOn(window, 'open').mockReturnValue(mockWindow as any);
});
function getIframe(): HTMLIFrameElement | null {
return getOverlay()?.querySelector('iframe') ?? null;
}
// ── Setup ────────────────────────────────────────────────────────────────────
afterEach(() => {
document.getElementById('journey-pdf-overlay')?.remove();
vi.restoreAllMocks();
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe('downloadJourneyBookPDF', () => {
it('FE-COMP-JOURNEYPDF-001: opens a new window', async () => {
it('FE-COMP-JOURNEYPDF-001: appends overlay to document body', async () => {
await downloadJourneyBookPDF(buildJourney());
expect(window.open).toHaveBeenCalledWith('', '_blank');
expect(getOverlay()).not.toBeNull();
expect(document.body.contains(getOverlay())).toBe(true);
});
it('FE-COMP-JOURNEYPDF-002: writes HTML to the new window', async () => {
it('FE-COMP-JOURNEYPDF-002: overlay contains an iframe with srcdoc HTML', async () => {
await downloadJourneyBookPDF(buildJourney());
expect(mockWindow.document.write).toHaveBeenCalledTimes(1);
const html = mockWindow.document.write.mock.calls[0][0] as string;
const iframe = getIframe();
expect(iframe).not.toBeNull();
const html = iframe!.srcdoc;
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
it('FE-COMP-JOURNEYPDF-003: closes the document after writing', async () => {
it('FE-COMP-JOURNEYPDF-003: overlay has close and save buttons', async () => {
await downloadJourneyBookPDF(buildJourney());
expect(mockWindow.document.close).toHaveBeenCalledTimes(1);
const overlay = getOverlay()!;
expect(overlay.querySelector('#journey-pdf-close')).not.toBeNull();
expect(overlay.querySelector('#journey-pdf-save')).not.toBeNull();
});
it('FE-COMP-JOURNEYPDF-004: HTML contains the journey title', async () => {
await downloadJourneyBookPDF(buildJourney());
const html = mockWindow.document.write.mock.calls[0][0] as string;
const html = getIframe()!.srcdoc;
expect(html).toContain('Iceland Ring Road');
});
it('FE-COMP-JOURNEYPDF-005: HTML contains entry content', async () => {
await downloadJourneyBookPDF(buildJourney());
const html = mockWindow.document.write.mock.calls[0][0] as string;
const html = getIframe()!.srcdoc;
expect(html).toContain('Golden Circle');
// Story text is rendered via markdown
expect(html).toContain('An incredible day of geysers and waterfalls.');
@@ -137,8 +139,8 @@ describe('downloadJourneyBookPDF', () => {
it('FE-COMP-JOURNEYPDF-006: handles empty entries gracefully', async () => {
const journey = buildJourney({ entries: [] });
await downloadJourneyBookPDF(journey);
expect(window.open).toHaveBeenCalled();
const html = mockWindow.document.write.mock.calls[0][0] as string;
expect(getOverlay()).not.toBeNull();
const html = getIframe()!.srcdoc;
expect(html).toContain('Iceland Ring Road');
// No entry pages, but cover and closing page are still present
expect(html).toContain('Journey Book');
+33 -18
View File
@@ -249,23 +249,9 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
.entry-photo-single, .entry-photo-duo, .entry-photo-trio { page-break-after: avoid; }
}
.print-bar {
position: fixed; top: 0; left: 0; right: 0; z-index: 9999;
background: rgba(15,23,42,0.95); backdrop-filter: blur(12px);
padding: 12px 24px; display: flex; align-items: center; justify-content: center; gap: 12px;
}
.print-bar button { padding: 8px 24px; border-radius: 10px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border: none; }
.print-bar .btn-save { background: white; color: #0f172a; }
.print-bar .btn-close { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); border: 1px solid rgba(255,255,255,0.15); }
.print-bar .info { font-size: 11px; color: rgba(255,255,255,0.4); }
</style>
</head>
<body>
<div class="print-bar">
<span class="info">${esc(journey.title)} · ${totalPages} pages</span>
<button class="btn-save" onclick="window.print()">Save as PDF</button>
<button class="btn-close" onclick="window.close()">Close</button>
</div>
<!-- Page 1: Cover -->
<div class="cover-page">
@@ -299,8 +285,37 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
</body>
</html>`
const win = window.open('', '_blank')
if (!win) return
win.document.write(html)
win.document.close()
// Render in a fixed overlay + srcdoc iframe — same pattern as TripPDF.
// This avoids window.open() which Safari iOS blocks in async callbacks
// and window.close() which doesn't work reliably in standalone PWA mode.
const overlay = document.createElement('div')
overlay.id = 'journey-pdf-overlay'
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
const card = document.createElement('div')
card.style.cssText = 'width:100%;max-width:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35);'
const header = document.createElement('div')
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:8px 16px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a;'
header.innerHTML = `
<span style="font-size:12px;color:rgba(255,255,255,0.45);font-weight:500;letter-spacing:0.03em">${esc(journey.title)} &middot; ${totalPages} pages</span>
<div style="display:flex;align-items:center;gap:8px">
<button id="journey-pdf-save" style="min-height:44px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;border:none;background:#fff;color:#0f172a;">Save as PDF</button>
<button id="journey-pdf-close" style="min-height:44px;padding:10px 16px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.1);color:rgba(255,255,255,0.7);">Close</button>
</div>
`
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
iframe.sandbox = 'allow-same-origin allow-modals'
iframe.srcdoc = html
card.appendChild(header)
card.appendChild(iframe)
overlay.appendChild(card)
document.body.appendChild(overlay)
header.querySelector<HTMLButtonElement>('#journey-pdf-close')!.onclick = () => overlay.remove()
header.querySelector<HTMLButtonElement>('#journey-pdf-save')!.onclick = () => { iframe.contentWindow?.print() }
}
@@ -0,0 +1,155 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Plane, X } from 'lucide-react'
import { airportsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
export interface Airport {
iata: string
icao: string | null
name: string
city: string
country: string
lat: number
lng: number
tz: string
}
interface Props {
value: Airport | null
onChange: (airport: Airport | null) => void
placeholder?: string
style?: React.CSSProperties
}
function formatLabel(a: Airport) {
return `${a.city || a.name} (${a.iata})`
}
export default function AirportSelect({ value, onChange, placeholder, style }: Props) {
const { t, locale } = useTranslation()
const countryName = useMemo(() => {
try { return new Intl.DisplayNames([locale || 'en'], { type: 'region' }) } catch { return null }
}, [locale])
const displayCountry = (code: string) => {
if (!code) return ''
try { return countryName?.of(code) || code } catch { return code }
}
const [query, setQuery] = useState(value ? formatLabel(value) : '')
const [open, setOpen] = useState(false)
const [results, setResults] = useState<Airport[]>([])
const [highlight, setHighlight] = useState(-1)
const [loading, setLoading] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
const abortRef = useRef<AbortController | null>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
setQuery(value ? formatLabel(value) : '')
}, [value])
useEffect(() => {
const handler = (e: MouseEvent) => {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false)
}
if (open) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
const trimmed = query.trim()
if (trimmed.length < 2 || (value && trimmed === formatLabel(value))) {
setResults([])
return
}
debounceRef.current = setTimeout(async () => {
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
setLoading(true)
try {
const data = await airportsApi.search(trimmed, controller.signal)
setResults(Array.isArray(data) ? data : [])
setHighlight(-1)
} catch (err: any) {
if (err?.name !== 'AbortError' && err?.name !== 'CanceledError') {
setResults([])
}
} finally {
setLoading(false)
}
}, 220)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
}, [query, value])
const pick = (a: Airport) => {
onChange(a)
setQuery(formatLabel(a))
setOpen(false)
setResults([])
}
const clear = () => {
onChange(null)
setQuery('')
setResults([])
}
const onKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open || results.length === 0) return
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, results.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(h - 1, 0)) }
else if (e.key === 'Enter' && highlight >= 0) { e.preventDefault(); pick(results[highlight]) }
else if (e.key === 'Escape') setOpen(false)
}
return (
<div ref={wrapRef} style={{ position: 'relative', ...style }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 10, border: '1px solid var(--border-primary)' }}>
<Plane size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<input
type="text"
value={query}
placeholder={placeholder ?? t('airport.searchPlaceholder')}
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (value) onChange(null) }}
onFocus={() => setOpen(true)}
onKeyDown={onKey}
style={{ flex: 1, minWidth: 0, background: 'transparent', border: 'none', outline: 'none', color: 'var(--text-primary)', fontSize: 13 }}
/>
{value && (
<button type="button" onClick={clear} style={{ background: 'transparent', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }} aria-label="Clear">
<X size={14} />
</button>
)}
</div>
{open && (loading || results.length > 0) && (
<div style={{ position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 8px 24px rgba(0,0,0,0.18)', maxHeight: 260, overflowY: 'auto', zIndex: 1000 }}>
{loading && results.length === 0 && (
<div style={{ padding: 10, fontSize: 12, color: 'var(--text-faint)' }}>{t('common.loading')}</div>
)}
{results.map((a, i) => (
<button
key={a.iata}
type="button"
onClick={() => pick(a)}
onMouseEnter={() => setHighlight(i)}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '8px 12px', border: 'none', cursor: 'pointer', textAlign: 'left',
background: i === highlight ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-primary)', fontFamily: 'inherit',
}}
>
<span style={{ fontFamily: 'ui-monospace, SFMono-Regular, monospace', fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', minWidth: 32 }}>{a.iata}</span>
<span style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{a.city || a.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-faint)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{a.name}{a.country ? ` · ${displayCountry(a.country)}` : ''}</div>
</span>
</button>
))}
</div>
)}
</div>
)
}
@@ -78,7 +78,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const [showHotelPicker, setShowHotelPicker] = useState(false)
const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id })
const [hotelCategoryFilter, setHotelCategoryFilter] = useState('')
const [hotelForm, setHotelForm] = useState({ check_in: '', check_out: '', confirmation: '', place_id: null })
const [hotelForm, setHotelForm] = useState({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
useEffect(() => {
if (!day?.date || !lat || !lng) { setWeather(null); return }
@@ -117,6 +117,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
start_day_id: hotelDayRange.start,
end_day_id: hotelDayRange.end,
check_in: hotelForm.check_in || null,
check_in_end: hotelForm.check_in_end || null,
check_out: hotelForm.check_out || null,
confirmation: hotelForm.confirmation || null,
})
@@ -128,7 +129,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
))
setShowHotelPicker(false)
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
onAccommodationChange?.()
} catch {}
}
@@ -356,7 +357,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</div>
{acc.place_address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_address}</div>}
</div>
{canEditDays && <button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
{canEditDays && <button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_in_end: acc.check_in_end || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
<Pencil size={12} style={{ color: 'var(--text-faint)' }} />
</button>}
@@ -368,7 +369,9 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<div style={{ display: 'flex', gap: 0, margin: '0 12px 8px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}>
{acc.check_in && (
<div style={{ flex: 1, padding: '8px 10px', borderRight: '1px solid var(--border-faint)', textAlign: 'center' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{fmtTime(acc.check_in)}</div>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>
{fmtTime(acc.check_in)}{acc.check_in_end ? ` ${fmtTime(acc.check_in_end)}` : ''}
</div>
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
<LogIn size={8} /> {t('day.checkIn')}
</div>
@@ -488,11 +491,15 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
{/* Check-in / Check-out / Confirmation */}
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: 100 }}>
<div style={{ flex: 1, minWidth: 80 }}>
<label style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.checkIn')}</label>
<CustomTimePicker value={hotelForm.check_in} onChange={v => setHotelForm(f => ({ ...f, check_in: v }))} placeholder="14:00" />
</div>
<div style={{ flex: 1, minWidth: 100 }}>
<div style={{ flex: 1, minWidth: 80 }}>
<label style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.checkInUntil')}</label>
<CustomTimePicker value={hotelForm.check_in_end} onChange={v => setHotelForm(f => ({ ...f, check_in_end: v }))} placeholder="22:00" />
</div>
<div style={{ flex: 1, minWidth: 80 }}>
<label style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.checkOut')}</label>
<CustomTimePicker value={hotelForm.check_out} onChange={v => setHotelForm(f => ({ ...f, check_out: v }))} placeholder="11:00" />
</div>
@@ -570,11 +577,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
start_day_id: hotelDayRange.start,
end_day_id: hotelDayRange.end,
check_in: hotelForm.check_in || null,
check_in_end: hotelForm.check_in_end || null,
check_out: hotelForm.check_out || null,
confirmation: hotelForm.confirmation || null,
})
setShowHotelPicker(false)
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
// Reload
accommodationsApi.list(tripId).then(d => {
const all = d.accommodations || []
@@ -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, Undo2, X } 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, X, Route as RouteIcon } 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'
@@ -170,6 +170,10 @@ interface DayPlanSidebarProps {
onEditPlace: (place: Place) => void
onDeletePlace: (placeId: number) => void
reservations?: Reservation[]
visibleConnectionIds?: number[]
onToggleConnection?: (reservationId: number) => void
externalTransportDetail?: Reservation | null
onExternalTransportDetailHandled?: () => void
onAddReservation: () => void
onNavigateToFiles?: () => void
onAddPlace?: () => void
@@ -189,6 +193,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onReorder, onUpdateDayTitle, onRouteCalculated,
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
reservations = [],
visibleConnectionIds = [],
onToggleConnection,
externalTransportDetail,
onExternalTransportDetailHandled,
onAddReservation,
onAddPlace,
onAddPlaceToDay,
@@ -234,6 +242,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const [hoveredId, setHoveredId] = useState(null)
const [transportDetail, setTransportDetail] = useState(null)
const [transportPosVersion, setTransportPosVersion] = useState(0)
useEffect(() => {
if (externalTransportDetail) {
setTransportDetail(externalTransportDetail)
onExternalTransportDetailHandled?.()
}
}, [externalTransportDetail, onExternalTransportDetailHandled])
const [timeConfirm, setTimeConfirm] = useState<{
dayId: number; fromId: number; time: string;
// For drag & drop reorder
@@ -1023,7 +1038,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div>
{/* Tagesliste */}
<div className="scroll-container" style={{ flex: 1, overflowY: 'auto', minHeight: 0, scrollbarWidth: 'thin', scrollbarColor: 'var(--scrollbar-thumb) transparent' }}>
<div className="scroll-container" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{days.map((day, index) => {
const isSelected = selectedDayId === day.id
const isExpanded = expandedDays.has(day.id)
@@ -1570,6 +1585,29 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div>
)}
</div>
{onToggleConnection && (res.endpoints || []).length >= 2 && (() => {
const active = visibleConnectionIds.includes(res.id)
return (
<button
type="button"
onClick={e => { e.stopPropagation(); onToggleConnection(res.id) }}
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
style={{
flexShrink: 0, appearance: 'none',
width: 26, height: 26, borderRadius: 6,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
background: active ? color : 'transparent',
color: active ? '#fff' : 'var(--text-faint)',
transition: 'all 0.12s',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
>
<RouteIcon size={13} />
</button>
)
})()}
</div>
{showDropLineAfter && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
</React.Fragment>
@@ -0,0 +1,140 @@
import { useEffect, useRef, useState } from 'react'
import { MapPin, X } from 'lucide-react'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
export interface LocationPoint {
name: string
lat: number
lng: number
address?: string | null
}
interface Props {
value: LocationPoint | null
onChange: (loc: LocationPoint | null) => void
placeholder?: string
style?: React.CSSProperties
}
export default function LocationSelect({ value, onChange, placeholder, style }: Props) {
const { t, locale } = useTranslation()
const [query, setQuery] = useState(value?.name || '')
const [open, setOpen] = useState(false)
const [results, setResults] = useState<any[]>([])
const [highlight, setHighlight] = useState(-1)
const [loading, setLoading] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
setQuery(value?.name || '')
}, [value])
useEffect(() => {
const handler = (e: MouseEvent) => {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false)
}
if (open) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
const trimmed = query.trim()
if (trimmed.length < 3 || (value && trimmed === value.name)) {
setResults([])
return
}
debounceRef.current = setTimeout(async () => {
setLoading(true)
try {
const data = await mapsApi.search(trimmed, locale)
setResults(data.places || [])
setHighlight(-1)
} catch {
setResults([])
} finally {
setLoading(false)
}
}, 320)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
}, [query, value, locale])
const pick = (r: any) => {
const lat = Number(r.lat)
const lng = Number(r.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
const loc: LocationPoint = { name: r.name || r.address || 'Location', lat, lng, address: r.address || null }
onChange(loc)
setQuery(loc.name)
setOpen(false)
setResults([])
}
const clear = () => {
onChange(null)
setQuery('')
setResults([])
}
const onKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open || results.length === 0) return
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, results.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(h - 1, 0)) }
else if (e.key === 'Enter' && highlight >= 0) { e.preventDefault(); pick(results[highlight]) }
else if (e.key === 'Escape') setOpen(false)
}
return (
<div ref={wrapRef} style={{ position: 'relative', ...style }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 10, border: '1px solid var(--border-primary)' }}>
<MapPin size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<input
type="text"
value={query}
placeholder={placeholder ?? t('reservations.searchLocation')}
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (value) onChange(null) }}
onFocus={() => setOpen(true)}
onKeyDown={onKey}
style={{ flex: 1, minWidth: 0, background: 'transparent', border: 'none', outline: 'none', color: 'var(--text-primary)', fontSize: 13 }}
/>
{value && (
<button type="button" onClick={clear} style={{ background: 'transparent', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }} aria-label="Clear">
<X size={14} />
</button>
)}
</div>
{open && (loading || results.length > 0) && (
<div style={{ position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 8px 24px rgba(0,0,0,0.18)', maxHeight: 260, overflowY: 'auto', zIndex: 1000 }}>
{loading && results.length === 0 && (
<div style={{ padding: 10, fontSize: 12, color: 'var(--text-faint)' }}>{t('common.loading')}</div>
)}
{results.map((r, i) => (
<button
key={`${r.osm_id || r.google_place_id || i}`}
type="button"
onClick={() => pick(r)}
onMouseEnter={() => setHighlight(i)}
style={{
display: 'flex', alignItems: 'flex-start', gap: 8, width: '100%',
padding: '8px 12px', border: 'none', cursor: 'pointer', textAlign: 'left',
background: i === highlight ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-primary)', fontFamily: 'inherit',
}}
>
<MapPin size={12} style={{ color: 'var(--text-faint)', marginTop: 2, flexShrink: 0 }} />
<span style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.name || r.address}</div>
{r.address && r.name !== r.address && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.address}</div>
)}
</span>
</button>
))}
</div>
)}
</div>
)
}
@@ -473,14 +473,14 @@ describe('Google Maps list import', () => {
it('FE-PLANNER-SIDEBAR-040: "Google List" button opens the URL dialog', async () => {
const user = userEvent.setup();
render(<PlacesSidebar {...defaultProps} />);
await user.click(screen.getByText(/Google List/i));
await user.click(screen.getByText(/List Import/i));
expect(await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i)).toBeInTheDocument();
});
it('FE-PLANNER-SIDEBAR-041: import button disabled when URL input is empty', async () => {
const user = userEvent.setup();
render(<PlacesSidebar {...defaultProps} />);
await user.click(screen.getByText(/Google List/i));
await user.click(screen.getByText(/List Import/i));
await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i);
const importBtn = screen.getByRole('button', { name: /^Import$/i });
expect(importBtn).toBeDisabled();
@@ -498,7 +498,7 @@ describe('Google Maps list import', () => {
(window as any).__addToast = addToast;
const user = userEvent.setup();
render(<PlacesSidebar {...defaultProps} pushUndo={vi.fn()} />);
await user.click(screen.getByText(/Google List/i));
await user.click(screen.getByText(/List Import/i));
const urlInput = await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i);
await user.type(urlInput, 'https://maps.app.goo.gl/abc123');
await user.click(screen.getByRole('button', { name: /^Import$/i }));
@@ -527,7 +527,7 @@ describe('Google Maps list import', () => {
(window as any).__addToast = addToast;
const user = userEvent.setup();
render(<PlacesSidebar {...defaultProps} pushUndo={vi.fn()} />);
await user.click(screen.getByText(/Google List/i));
await user.click(screen.getByText(/List Import/i));
const urlInput = await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i);
await user.type(urlInput, 'https://maps.app.goo.gl/xyz{Enter}');
await waitFor(() => {
@@ -10,7 +10,6 @@ import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import { placesApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useAddonStore } from '../../store/addonStore'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
import FileImportModal from './FileImportModal'
@@ -44,7 +43,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
const loadTrip = useTripStore((s) => s.loadTrip)
const can = useCanDo()
const canEditPlaces = can('place_edit', trip)
const isNaverListImportEnabled = useAddonStore((s) => s.isEnabled('naver_list_import'))
const isNaverListImportEnabled = true
const [fileImportOpen, setFileImportOpen] = useState(false)
const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null)
@@ -147,7 +146,11 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
const filtered = useMemo(() => places.filter(p => {
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
if (categoryFilters.size > 0 && !categoryFilters.has(String(p.category_id))) return false
if (categoryFilters.size > 0) {
if (p.category_id == null) {
if (!categoryFilters.has('uncategorized')) return false
} else if (!categoryFilters.has(String(p.category_id))) return false
}
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
return true
@@ -257,7 +260,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
const label = categoryFilters.size === 0
? t('places.allCategories')
: categoryFilters.size === 1
? categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories')
? (categoryFilters.has('uncategorized') ? t('places.noCategory') : categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories'))
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
return (
<div style={{ marginTop: 6, position: 'relative' }}>
@@ -300,6 +303,29 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
</button>
)
})}
{places.some(p => p.category_id == null) && (() => {
const active = categoryFilters.has('uncategorized')
return (
<button onClick={() => toggleCategoryFilter('uncategorized')} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
background: active ? 'var(--bg-hover)' : 'transparent',
fontFamily: 'inherit', fontSize: 12, color: 'var(--text-muted)',
textAlign: 'left', borderTop: '1px solid var(--border-faint)', marginTop: 2,
}}>
<div style={{
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
border: active ? 'none' : '1.5px solid var(--border-primary)',
background: active ? 'var(--text-faint)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{active && <Check size={10} strokeWidth={3} color="white" />}
</div>
<MapPin size={12} strokeWidth={2} color="var(--text-faint)" />
<span style={{ flex: 1 }}>{t('places.noCategory')}</span>
</button>
)
})()}
{categoryFilters.size > 0 && (
<button onClick={() => { setCategoryFiltersLocal(new Set()); onCategoryFilterChange?.(new Set()) }} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
@@ -134,7 +134,8 @@ describe('ReservationModal', () => {
it('FE-PLANNER-RESMODAL-008: hotel type shows check-in/check-out time fields', async () => {
render(<ReservationModal {...defaultProps} />);
await userEvent.click(screen.getByRole('button', { name: /Accommodation/i }));
expect(screen.getByText(/Check-in/i)).toBeInTheDocument();
const checkInLabels = screen.getAllByText(/Check-in/i);
expect(checkInLabels.length).toBeGreaterThanOrEqual(1);
expect(screen.getByText(/Check-out/i)).toBeInTheDocument();
});
@@ -11,7 +11,58 @@ import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import { openFile } from '../../utils/fileDownload'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
import AirportSelect, { type Airport } from './AirportSelect'
import LocationSelect, { type LocationPoint } from './LocationSelect'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, ReservationEndpoint } from '../../types'
const TRANSPORT_TYPES = ['flight', 'train', 'cruise', 'car'] as const
type TransportType = typeof TRANSPORT_TYPES[number]
const isTransport = (t: string): t is TransportType => (TRANSPORT_TYPES as readonly string[]).includes(t)
interface EndpointPick {
airport?: Airport
location?: LocationPoint
}
function endpointFromAirport(a: Airport, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
return {
role, sequence,
name: a.city ? `${a.city} (${a.iata})` : a.name,
code: a.iata,
lat: a.lat, lng: a.lng,
timezone: a.tz,
local_date: date,
local_time: time,
}
}
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
return {
role, sequence,
name: l.name,
code: null,
lat: l.lat, lng: l.lng,
timezone: null,
local_date: date,
local_time: time,
}
}
function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null {
if (!e || !e.code) return null
return {
iata: e.code, icao: null,
name: e.name, city: e.name.replace(/\s*\([A-Z]{3}\)\s*$/, ''),
country: '',
lat: e.lat, lng: e.lng,
tz: e.timezone || '',
}
}
function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint | null {
if (!e) return null
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
}
const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
@@ -89,7 +140,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
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: '',
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
})
const [isSaving, setIsSaving] = useState(false)
@@ -98,6 +149,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const [showFilePicker, setShowFilePicker] = useState(false)
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
const [unlinkedFileIds, setUnlinkedFileIds] = useState<number[]>([])
const [fromPick, setFromPick] = useState<EndpointPick>({})
const [toPick, setToPick] = useState<EndpointPick>({})
const assignmentOptions = useMemo(
() => buildAssignmentOptions(days, assignments, t, locale),
@@ -140,6 +193,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
meta_platform: meta.platform || '',
meta_seat: meta.seat || '',
meta_check_in_time: meta.check_in_time || '',
meta_check_in_end_time: meta.check_in_end_time || '',
meta_check_out_time: meta.check_out_time || '',
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
@@ -147,6 +201,20 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
price: meta.price || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
})
const eps = reservation.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
if (reservation.type === 'flight') {
setFromPick({ airport: airportFromEndpoint(from) || undefined })
setToPick({ airport: airportFromEndpoint(to) || undefined })
} else if (isTransport(reservation.type)) {
setFromPick({ location: locationFromEndpoint(from) || undefined })
setToPick({ location: locationFromEndpoint(to) || undefined })
} else {
setFromPick({})
setToPick({})
}
} else {
setForm({
title: '', type: 'other', status: 'pending',
@@ -156,9 +224,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
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: '',
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
})
setPendingFiles([])
setFromPick({})
setToPick({})
}
}, [reservation, isOpen, selectedDayId])
@@ -201,12 +271,17 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (form.type === 'flight') {
if (form.meta_airline) metadata.airline = form.meta_airline
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
if (form.meta_departure_airport) metadata.departure_airport = form.meta_departure_airport
if (form.meta_arrival_airport) metadata.arrival_airport = form.meta_arrival_airport
if (form.meta_departure_timezone) metadata.departure_timezone = form.meta_departure_timezone
if (form.meta_arrival_timezone) metadata.arrival_timezone = form.meta_arrival_timezone
if (fromPick.airport) {
metadata.departure_airport = fromPick.airport.iata
metadata.departure_timezone = fromPick.airport.tz
}
if (toPick.airport) {
metadata.arrival_airport = toPick.airport.iata
metadata.arrival_timezone = toPick.airport.tz
}
} else if (form.type === 'hotel') {
if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time
if (form.meta_check_in_end_time) metadata.check_in_end_time = form.meta_check_in_end_time
if (form.meta_check_out_time) metadata.check_out_time = form.meta_check_out_time
} else if (form.type === 'train') {
if (form.meta_train_number) metadata.train_number = form.meta_train_number
@@ -222,6 +297,21 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
if (isTransport(form.type)) {
const startDate = (form.reservation_time || '').split('T')[0] || null
const startTime = (form.reservation_time || '').split('T')[1]?.slice(0, 5) || null
const endDate = form.end_date || null
const endTime = form.reservation_end_time || null
if (form.type === 'flight') {
if (fromPick.airport) endpoints.push(endpointFromAirport(fromPick.airport, 'from', 0, startDate, startTime))
if (toPick.airport) endpoints.push(endpointFromAirport(toPick.airport, 'to', 1, endDate, endTime))
} else {
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, startTime))
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, endTime))
}
}
const saveData: Record<string, any> = {
title: form.title, type: form.type, status: form.status,
reservation_time: form.type === 'hotel' ? null : form.reservation_time,
@@ -231,6 +321,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
assignment_id: form.assignment_id || null,
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
metadata: Object.keys(metadata).length > 0 ? metadata : null,
endpoints: isTransport(form.type) ? endpoints : [],
needs_review: false,
}
// Auto-create/update budget entry if price is set, or signal removal if cleared
if (isBudgetEnabled) {
@@ -245,6 +337,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
start_day_id: form.hotel_start_day,
end_day_id: form.hotel_end_day,
check_in: form.meta_check_in_time || null,
check_in_end: form.meta_check_in_end_time || null,
check_out: form.meta_check_out_time || null,
confirmation: form.confirmation_number || null,
}
@@ -391,11 +484,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
}}
/>
</div>
{form.type === 'flight' && (
{form.type === 'flight' && fromPick.airport && (
<div style={{ flex: 1, minWidth: 0 }}>
<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 style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
{fromPick.airport.tz}
</div>
</div>
)}
</div>
@@ -411,11 +505,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<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' && (
{form.type === 'flight' && toPick.airport && (
<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 style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
{toPick.airport.tz}
</div>
</div>
)}
</div>
@@ -453,9 +548,30 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div>
</div>
{/* Type-specific fields */}
{/* From / To endpoints for transport bookings */}
{isTransport(form.type) && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.from')}</label>
{form.type === 'flight' ? (
<AirportSelect value={fromPick.airport || null} onChange={a => setFromPick({ airport: a || undefined })} />
) : (
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
)}
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.to')}</label>
{form.type === 'flight' ? (
<AirportSelect value={toPick.airport || null} onChange={a => setToPick({ airport: a || undefined })} />
) : (
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
)}
</div>
</div>
)}
{form.type === 'flight' && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.airline') || 'Airline'}</label>
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
@@ -466,16 +582,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
placeholder="LH 123" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.from') || 'From'}</label>
<input type="text" value={form.meta_departure_airport} onChange={e => set('meta_departure_airport', e.target.value)}
placeholder="FRA" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.to') || 'To'}</label>
<input type="text" value={form.meta_arrival_airport} onChange={e => set('meta_arrival_airport', e.target.value)}
placeholder="NRT" style={inputStyle} />
</div>
</div>
)}
@@ -526,11 +632,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div>
</div>
{/* Check-in/out times + Status */}
<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.checkIn')}</label>
<CustomTimePicker value={form.meta_check_in_time} onChange={v => set('meta_check_in_time', v)} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.checkInUntil')}</label>
<CustomTimePicker value={form.meta_check_in_end_time} onChange={v => set('meta_check_in_end_time', v)} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.checkOut')}</label>
<CustomTimePicker value={form.meta_check_out_time} onChange={v => set('meta_check_out_time', v)} />
@@ -91,12 +91,12 @@ describe('ReservationsPanel', () => {
expect(els.length).toBeGreaterThan(0);
});
it('FE-COMP-RES-010: shows summary text with confirmed and pending counts', () => {
const r1 = buildReservation({ title: 'Flight', type: 'flight', status: 'confirmed' });
const r2 = buildReservation({ title: 'Hotel', type: 'hotel', status: 'pending' });
it('FE-COMP-RES-010: shows reservations title and cards', () => {
const r1 = buildReservation({ title: 'My Flight Booking', type: 'flight', status: 'confirmed' });
const r2 = buildReservation({ title: 'Grand Hotel', type: 'hotel', status: 'pending' });
render(<ReservationsPanel {...defaultProps} reservations={[r1, r2]} />);
// reservations.summary = "{confirmed} confirmed, {pending} pending"
expect(screen.getByText(/1 confirmed, 1 pending/i)).toBeInTheDocument();
expect(screen.getByText('My Flight Booking')).toBeInTheDocument();
expect(screen.getByText('Grand Hotel')).toBeInTheDocument();
});
it('FE-COMP-RES-011: hotel reservation renders', () => {
@@ -288,27 +288,14 @@ describe('ReservationsPanel', () => {
// ── Status toggle (canEdit=true) ────────────────────────────────────────────
it('FE-PLANNER-RESP-030: status label is a button when canEdit=true', () => {
// Default: permissions empty → canEdit=true
it('FE-PLANNER-RESP-030: status label is always a span (not clickable)', () => {
const res = buildReservation({ title: 'My Booking', status: 'pending' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
// Status badge in card header is a button
const pendingEls = screen.getAllByText('Pending');
const statusSpan = pendingEls.find(el => el.tagName === 'SPAN');
expect(statusSpan).toBeDefined();
const statusBtn = pendingEls.find(el => el.tagName === 'BUTTON');
expect(statusBtn).toBeDefined();
});
it('FE-PLANNER-RESP-031: clicking status button calls toggleReservationStatus', async () => {
const user = userEvent.setup();
const toggleReservationStatus = vi.fn().mockResolvedValue(undefined);
// Seed the store with a mock toggleReservationStatus function
useTripStore.setState({ toggleReservationStatus } as any);
const res = buildReservation({ id: 42, title: 'Toggle Me', status: 'pending' });
render(<ReservationsPanel {...defaultProps} tripId={1} reservations={[res]} />);
const pendingEls = screen.getAllByText('Pending');
const statusBtn = pendingEls.find(el => el.tagName === 'BUTTON');
await user.click(statusBtn!);
await waitFor(() => expect(toggleReservationStatus).toHaveBeenCalledWith(1, 42));
expect(statusBtn).toBeUndefined();
});
// ── Status (canEdit=false) ──────────────────────────────────────────────────
@@ -8,7 +8,7 @@ import { useTranslation } from '../../i18n'
import {
Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, MapPin,
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle,
} from 'lucide-react'
import { openFile } from '../../utils/fileDownload'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
@@ -50,6 +50,16 @@ function buildAssignmentLookup(days, assignments) {
return map
}
/* ── Shared field label style ── */
const fieldLabelStyle: React.CSSProperties = {
fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em',
color: 'var(--text-faint)', marginBottom: 5,
}
const fieldValueStyle: React.CSSProperties = {
fontSize: 13, fontWeight: 500, color: 'var(--text-primary)',
padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 10,
}
interface ReservationCardProps {
r: Reservation
tripId: number
@@ -84,184 +94,245 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
}
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const fmtDate = (str) => {
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' })
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
}
const fmtTime = (str) => {
const d = new Date(str)
return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
}
const hasDate = !!r.reservation_time
const hasTime = r.reservation_time?.includes('T')
const hasCode = !!r.confirmation_number
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
return (
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
{/* Header bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)' }}>
<div style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
{canEdit ? (
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
</button>
) : (
<span style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', padding: 0 }}>
<div style={{
borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column',
border: `1px solid ${confirmed ? 'rgba(22,163,74,0.25)' : 'rgba(217,119,6,0.25)'}`,
background: 'var(--bg-card)',
transition: 'box-shadow 0.15s ease',
}}
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
padding: '12px 14px',
background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)',
}}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
fontSize: 12, fontWeight: 600, color: confirmed ? '#16a34a' : '#d97706',
}}>
<span style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
</span>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
fontSize: 12, color: 'var(--text-muted)',
padding: '3px 8px', borderRadius: 6,
background: 'var(--bg-secondary)',
}}>
<TypeIcon size={12} style={{ color: typeInfo.color }} />
{t(typeInfo.labelKey)}
</span>
{r.needs_review ? (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 11, fontWeight: 600, color: '#b45309',
padding: '3px 8px', borderRadius: 6,
background: 'rgba(245,158,11,0.12)',
}} title={t('reservations.needsReviewHint')}>
<AlertCircle size={11} />
{t('reservations.needsReview')}
</span>
) : null}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span style={{
fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginRight: 6,
maxWidth: 140, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{r.title}</span>
{canEdit && (
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{
appearance: 'none', border: 'none', background: 'transparent',
width: 26, height: 26, borderRadius: 6, display: 'grid', placeItems: 'center',
cursor: 'pointer', color: 'var(--text-faint)', flexShrink: 0,
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0,0,0,0.05)'; e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}>
<Pencil size={13} />
</button>
)}
{canEdit && (
<button onClick={() => setShowDeleteConfirm(true)} title={t('common.delete')} style={{
appearance: 'none', border: 'none', background: 'transparent',
width: 26, height: 26, borderRadius: 6, display: 'grid', placeItems: 'center',
cursor: 'pointer', color: 'var(--text-faint)', flexShrink: 0,
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(239,68,68,0.08)'; e.currentTarget.style.color = '#ef4444' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}>
<Trash2 size={13} />
</button>
)}
</div>
</div>
{/* Body */}
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}>
{/* Date / Time row */}
{hasDate && (
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasTime ? '1fr 1fr' : '1fr' }}>
<div>
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
{fmtDate(r.reservation_time)}
{r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
<> {fmtDate(r.reservation_end_time)}</>
)}
</div>
</div>
{hasTime && (
<div>
<div style={fieldLabelStyle}>{t('reservations.time')}</div>
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''}
</div>
</div>
)}
</div>
)}
<div style={{ width: 1, height: 10, background: 'var(--border-faint)' }} />
<TypeIcon size={11} style={{ color: typeInfo.color, flexShrink: 0 }} />
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{t(typeInfo.labelKey)}</span>
<span style={{ flex: 1 }} />
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
{canEdit && (
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={11} />
</button>
{/* Booking code */}
{hasCode && (
<div>
<div style={fieldLabelStyle}>{t('reservations.confirmationCode')}</div>
<div
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
onClick={() => blurCodes && setCodeRevealed(v => !v)}
style={{
...fieldValueStyle, textAlign: 'center',
fontFamily: '"SF Mono", "JetBrains Mono", Menlo, monospace', fontSize: 12.5,
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
cursor: blurCodes ? 'pointer' : 'default',
transition: 'filter 0.2s',
}}
>
{r.confirmation_number}
</div>
</div>
)}
{canEdit && (
<button onClick={() => setShowDeleteConfirm(true)} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={11} />
</button>
{(() => {
const eps = r.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
if (!from || !to) return null
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
padding: '8px 12px', borderRadius: 10,
background: 'var(--bg-tertiary)',
fontSize: 12.5, color: 'var(--text-primary)',
}}>
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{from.name}</span>
<TypeIcon size={14} style={{ color: typeInfo.color, flexShrink: 0 }} />
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{to.name}</span>
</div>
)
})()}
{/* Type-specific metadata */}
{(() => {
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
const hasEndpoints = (r.endpoints || []).some(e => e.role === 'from') && (r.endpoints || []).some(e => e.role === 'to')
const cells: { label: string; value: string }[] = []
if (meta.airline) cells.push({ label: t('reservations.meta.airline'), value: meta.airline })
if (meta.flight_number) cells.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number })
if (!hasEndpoints && meta.departure_airport) cells.push({ label: t('reservations.meta.from'), value: meta.departure_airport })
if (!hasEndpoints && meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) + (meta.check_in_end_time ? ` ${fmtTime('2000-01-01T' + meta.check_in_end_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: 'grid', gap: 10, gridTemplateColumns: cells.length > 1 ? `repeat(${Math.min(cells.length, 3)}, 1fr)` : '1fr' }}>
{cells.map((c, i) => (
<div key={i}>
<div style={fieldLabelStyle}>{c.label}</div>
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>{c.value}</div>
</div>
))}
</div>
)
})()}
{/* Location / Accommodation / Assignment */}
{r.location && (
<div>
<div style={fieldLabelStyle}>{t('reservations.locationAddress')}</div>
<div style={{ ...fieldValueStyle, display: 'flex', alignItems: 'center', gap: 6, fontWeight: 400 }}>
<MapPin size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.location}</span>
</div>
</div>
)}
{r.accommodation_name && (
<div>
<div style={fieldLabelStyle}>{t('reservations.meta.linkAccommodation')}</div>
<div style={{ ...fieldValueStyle, display: 'flex', alignItems: 'center', gap: 6, fontWeight: 400 }}>
<Hotel size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.accommodation_name}</span>
</div>
</div>
)}
{linked && (
<div>
<div style={fieldLabelStyle}>{t('reservations.linkAssignment')}</div>
<div style={{ ...fieldValueStyle, display: 'flex', alignItems: 'center', gap: 6, fontWeight: 400 }}>
<Link2 size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{linked.dayTitle || t('dayplan.dayN', { n: linked.dayNumber })} {linked.placeName}
{linked.startTime ? ` · ${linked.startTime}${linked.endTime ? ' ' + linked.endTime : ''}` : ''}
</span>
</div>
</div>
)}
{/* Notes */}
{r.notes && (
<div>
<div style={fieldLabelStyle}>{t('reservations.notes')}</div>
<div style={{ ...fieldValueStyle, fontWeight: 400, lineHeight: 1.5 }}>{r.notes}</div>
</div>
)}
{/* Files */}
{attachedFiles.length > 0 && (
<div>
<div style={fieldLabelStyle}>{t('files.title')}</div>
<div style={{ ...fieldValueStyle, display: 'flex', flexDirection: 'column', gap: 4, padding: '6px 10px' }}>
{attachedFiles.map(f => (
<a key={f.id} href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ display: 'flex', alignItems: 'center', gap: 5, textDecoration: 'none', cursor: 'pointer' }}>
<FileText size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 12, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
</a>
))}
</div>
</div>
)}
</div>
{/* Details */}
{(r.reservation_time || r.confirmation_number || r.location || linked || r.metadata) && (
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{/* Row 1: Date, Time, Code */}
{(r.reservation_time || r.confirmation_number) && (
<div style={{ display: 'flex', gap: 0, borderRadius: 8, overflow: 'hidden', background: 'var(--bg-secondary)', boxShadow: '0 1px 6px rgba(0,0,0,0.08)' }}>
{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)}
{r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
<> {fmtDate(r.reservation_end_time)}</>
)}
</div>
</div>
)}
{r.reservation_time?.includes('T') && (
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''}
</div>
</div>
)}
{r.confirmation_number && (
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center' }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.confirmationCode')}</div>
<div
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
onClick={() => blurCodes && setCodeRevealed(v => !v)}
style={{
fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1,
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
cursor: blurCodes ? 'pointer' : 'default',
transition: 'filter 0.2s',
}}
>
{r.confirmation_number}
</div>
</div>
)}
</div>
)}
{/* Row 1b: Type-specific metadata */}
{(() => {
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
const cells: { label: string; value: string }[] = []
if (meta.airline) cells.push({ label: t('reservations.meta.airline'), value: meta.airline })
if (meta.flight_number) cells.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number })
if (meta.departure_airport) cells.push({ label: t('reservations.meta.from'), value: meta.departure_airport })
if (meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: 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)' }}>
{cells.map((c, i) => (
<div key={i} style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: i < cells.length - 1 ? '1px solid var(--border-faint)' : 'none' }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{c.label}</div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{c.value}</div>
</div>
))}
</div>
)
})()}
{/* Row 2: Location + Assignment */}
{(r.location || linked || r.accommodation_name) && (
<div className={`grid grid-cols-1 ${r.location && linked ? 'sm:grid-cols-2' : ''} gap-2`} style={{ paddingTop: 6, borderTop: '1px solid var(--border-faint)' }}>
{r.location && (
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.locationAddress')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
<MapPin size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.location}</span>
</div>
</div>
)}
{r.accommodation_name && (
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.meta.linkAccommodation')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
<Hotel size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.accommodation_name}</span>
</div>
</div>
)}
{linked && (
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.linkAssignment')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
<Link2 size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{linked.dayTitle || t('dayplan.dayN', { n: linked.dayNumber })} {linked.placeName}
{linked.startTime ? ` · ${linked.startTime}${linked.endTime ? ' ' + linked.endTime : ''}` : ''}
</span>
</div>
</div>
)}
</div>
)}
</div>
)}
{/* Notes */}
{r.notes && (
<div style={{ padding: '0 12px 8px' }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
<div style={{ padding: '5px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.5 }}>
{r.notes}
</div>
</div>
)}
{/* Files */}
{attachedFiles.length > 0 && (
<div style={{ padding: '0 12px 8px' }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('files.title')}</div>
<div style={{ padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', display: 'flex', flexDirection: 'column', gap: 3 }}>
{attachedFiles.map(f => (
<a key={f.id} href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
<FileText size={9} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
</a>
))}
</div>
</div>
)}
{/* Delete confirmation popup */}
{/* Delete confirmation */}
{showDeleteConfirm && ReactDOM.createPortal(
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
@@ -316,20 +387,25 @@ interface SectionProps {
function Section({ title, count, children, defaultOpen = true, accent }: SectionProps) {
const [open, setOpen] = useState(defaultOpen)
return (
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 28 }}>
<button onClick={() => setOpen(o => !o)} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 8, fontFamily: 'inherit',
background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 12, fontFamily: 'inherit',
userSelect: 'none',
}}>
{open ? <ChevronDown size={14} style={{ color: 'var(--text-faint)' }} /> : <ChevronRight size={14} style={{ color: 'var(--text-faint)' }} />}
<span style={{ fontWeight: 700, fontSize: 12, color: 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{title}</span>
<span style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>{title}</span>
<span style={{
fontSize: 10, fontWeight: 700, padding: '1px 7px', borderRadius: 99,
background: accent === 'green' ? 'rgba(22,163,74,0.1)' : 'var(--bg-tertiary)',
color: accent === 'green' ? '#16a34a' : 'var(--text-faint)',
fontSize: 11, fontWeight: 600, padding: '2px 7px', borderRadius: 99,
background: 'var(--bg-tertiary)', color: 'var(--text-faint)',
minWidth: 20, textAlign: 'center',
}}>{count}</span>
</button>
{open && <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{children}</div>}
{open && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(max(33.33% - 14px, 340px), 1fr))', gap: 14, alignItems: 'stretch' }}>
{children}
</div>
)}
</div>
)
}
@@ -353,55 +429,152 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
const canEdit = can('reservation_edit', trip)
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
const storageKey = `trek-reservation-filters-${tripId}`
const [typeFilters, setTypeFilters] = useState<Set<string>>(() => {
try {
const saved = sessionStorage.getItem(storageKey)
return saved ? new Set(JSON.parse(saved)) : new Set()
} catch { return new Set() }
})
const toggleTypeFilter = (type: string) => {
setTypeFilters(prev => {
const next = new Set(prev)
if (next.has(type)) next.delete(type); else next.add(type)
sessionStorage.setItem(storageKey, JSON.stringify([...next]))
return next
})
}
const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
const allPending = reservations.filter(r => r.status !== 'confirmed')
const allConfirmed = reservations.filter(r => r.status === 'confirmed')
const total = reservations.length
const filtered = useMemo(() =>
typeFilters.size === 0 ? reservations : reservations.filter(r => typeFilters.has(r.type)),
[reservations, typeFilters])
const allPending = filtered.filter(r => r.status !== 'confirmed')
const allConfirmed = filtered.filter(r => r.status === 'confirmed')
const total = filtered.length
const usedTypes = useMemo(() => new Set(reservations.map(r => r.type)), [reservations])
const typeCounts = useMemo(() => {
const counts: Record<string, number> = {}
for (const r of reservations) counts[r.type] = (counts[r.type] || 0) + 1
return counts
}, [reservations])
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Header */}
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid var(--border-faint)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('reservations.title')}</h2>
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'var(--text-faint)' }}>
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
</p>
{/* Unified toolbar */}
<div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4">
<div style={{
background: 'var(--bg-tertiary)', borderRadius: 18,
padding: '14px 16px 14px 22px',
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
{t('reservations.title')}
</h2>
{reservations.length > 0 && (
<>
<div className="hidden md:block" style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
<div className="hidden md:inline-flex" style={{ gap: 4, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
<button
onClick={() => { setTypeFilters(new Set()); sessionStorage.removeItem(storageKey) }}
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
background: typeFilters.size === 0 ? 'var(--bg-card)' : 'transparent',
color: typeFilters.size === 0 ? 'var(--text-primary)' : 'var(--text-muted)',
fontWeight: typeFilters.size === 0 ? 500 : 400,
boxShadow: typeFilters.size === 0 ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
transition: 'all 0.15s ease',
}}
>
{t('common.all')}
<span style={{
fontSize: 10, fontWeight: 600,
background: typeFilters.size === 0 ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
color: 'var(--text-faint)',
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
}}>{reservations.length}</span>
</button>
{TYPE_OPTIONS.filter(opt => usedTypes.has(opt.value)).map(opt => {
const active = typeFilters.has(opt.value)
const Icon = opt.Icon
return (
<button
key={opt.value}
onClick={() => toggleTypeFilter(opt.value)}
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
background: active ? 'var(--bg-card)' : 'transparent',
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
fontWeight: active ? 500 : 400,
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
transition: 'all 0.15s ease',
}}
>
<Icon size={13} style={{ color: active ? opt.color : 'var(--text-faint)' }} />
{t(opt.labelKey)}
<span style={{
fontSize: 10, fontWeight: 600,
background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
color: 'var(--text-faint)',
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
}}>{typeCounts[opt.value] || 0}</span>
</button>
)
})}
</div>
</>
)}
{canEdit && (
<button onClick={onAdd} style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
marginLeft: 'auto',
transition: 'opacity 0.15s ease',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
<Plus size={14} strokeWidth={2.5} />
<span className="hidden sm:inline">{t('reservations.addManual')}</span>
</button>
)}
</div>
{canEdit && (
<button onClick={onAdd} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}>
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
</button>
)}
</div>
{/* Content */}
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px' }}>
{total === 0 ? (
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 28px 80px' }} className="max-md:!px-4 max-md:!pt-4">
{total === 0 && reservations.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<BookMarked size={36} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('reservations.empty')}</p>
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0 }}>{t('reservations.emptyHint')}</p>
</div>
) : total === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<p style={{ fontSize: 13, color: 'var(--text-faint)' }}>{t('places.noneFound')}</p>
</div>
) : (
<>
{allPending.length > 0 && (
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
</div>
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
</Section>
)}
{allConfirmed.length > 0 && (
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
</div>
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
</Section>
)}
</>
@@ -34,7 +34,7 @@ describe('AboutTab', () => {
it('FE-COMP-ABOUT-005: displays Discord link with correct href', () => {
render(<AboutTab appVersion="2.9.10" />);
const link = screen.getByText('Discord').closest('a');
expect(link).toHaveAttribute('href', 'https://discord.gg/nSdKaXgN');
expect(link).toHaveAttribute('href', 'https://discord.gg/NhZBDSd4qW');
});
it('FE-COMP-ABOUT-006: displays bug report link', () => {
+1 -1
View File
@@ -66,7 +66,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://discord.gg/nSdKaXgN"
href="https://discord.gg/NhZBDSd4qW"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
@@ -172,6 +172,37 @@ export default function DisplaySettingsTab(): React.ReactElement {
</div>
</div>
{/* Booking route labels */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.bookingLabels')}</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('map_booking_labels', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: (settings.map_booking_labels !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: (settings.map_booking_labels !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{t('settings.bookingLabelsHint')}</p>
</div>
{/* Blur Booking Codes */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.blurBookingCodes')}</label>
@@ -347,6 +347,99 @@ describe('NotificationsTab', () => {
});
});
it('FE-COMP-NOTIFICATIONS-ntfy-001: ntfy topic input renders when ntfy channel is available', async () => {
server.use(
http.get('/api/notifications/preferences', () =>
HttpResponse.json({
preferences: { trip_invite: { inapp: true, ntfy: false } },
available_channels: { email: false, webhook: false, inapp: true, ntfy: true },
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'ntfy'] },
}),
),
);
render(<NotificationsTab />);
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
// Ntfy topic input should be present (placeholder text from i18n key or EN default)
const inputs = await screen.findAllByRole('textbox');
expect(inputs.length).toBeGreaterThan(0);
});
it('FE-COMP-NOTIFICATIONS-ntfy-002: ntfy test button disabled when no topic entered', async () => {
server.use(
http.get('/api/notifications/preferences', () =>
HttpResponse.json({
preferences: { trip_invite: { inapp: true, ntfy: false } },
available_channels: { email: false, webhook: false, inapp: true, ntfy: true },
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'ntfy'] },
}),
),
http.get('/api/settings', () => HttpResponse.json({ settings: { ntfy_topic: '' } })),
);
render(<NotificationsTab />);
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
// Test button should be disabled when topic is empty
const allButtons = await screen.findAllByRole('button');
const testBtn = allButtons.find(b => /test/i.test(b.textContent || ''));
expect(testBtn).toBeDefined();
expect(testBtn).toBeDisabled();
});
it('FE-COMP-NOTIFICATIONS-ntfy-003: entering topic and clicking Test calls test-ntfy API', async () => {
const user = userEvent.setup();
let ntfyCalled = false;
server.use(
http.get('/api/notifications/preferences', () =>
HttpResponse.json({
preferences: { trip_invite: { inapp: true, ntfy: false } },
available_channels: { email: false, webhook: false, inapp: true, ntfy: true },
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'ntfy'] },
}),
),
http.post('/api/notifications/test-ntfy', () => {
ntfyCalled = true;
return HttpResponse.json({ success: true });
}),
);
render(
<>
<NotificationsTab />
<ToastContainer />
</>,
);
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
// Find the topic input (first textbox in the ntfy block) and type a topic
const inputs = await screen.findAllByRole('textbox');
await user.type(inputs[0], 'my-test-topic');
// Test button should now be enabled
const allButtons = screen.getAllByRole('button');
const testBtn = allButtons.find(b => /test/i.test(b.textContent || ''));
expect(testBtn).toBeDefined();
expect(testBtn).not.toBeDisabled();
await user.click(testBtn!);
await waitFor(() => {
expect(ntfyCalled).toBe(true);
});
});
it('FE-COMP-NOTIFICATIONS-014: failed test webhook shows error toast with message', async () => {
const user = userEvent.setup();
server.use(
@@ -8,15 +8,17 @@ import Section from './Section'
interface PreferencesMatrix {
preferences: Record<string, Record<string, boolean>>
available_channels: { email: boolean; webhook: boolean; inapp: boolean }
available_channels: { email: boolean; webhook: boolean; inapp: boolean; ntfy: boolean }
event_types: string[]
implemented_combos: Record<string, string[]>
defaults?: { ntfyServer: string | null }
}
const CHANNEL_LABEL_KEYS: Record<string, string> = {
email: 'settings.notificationPreferences.email',
webhook: 'settings.notificationPreferences.webhook',
inapp: 'settings.notificationPreferences.inapp',
ntfy: 'settings.notificationPreferences.ntfy',
}
const EVENT_LABEL_KEYS: Record<string, string> = {
@@ -39,6 +41,12 @@ export default function NotificationsTab(): React.ReactElement {
const [webhookIsSet, setWebhookIsSet] = useState(false)
const [webhookSaving, setWebhookSaving] = useState(false)
const [webhookTesting, setWebhookTesting] = useState(false)
const [ntfyTopic, setNtfyTopic] = useState('')
const [ntfyServer, setNtfyServer] = useState('')
const [ntfyToken, setNtfyToken] = useState('')
const [ntfyTokenIsSet, setNtfyTokenIsSet] = useState(false)
const [ntfySaving, setNtfySaving] = useState(false)
const [ntfyTesting, setNtfyTesting] = useState(false)
useEffect(() => {
notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {})
@@ -50,12 +58,21 @@ export default function NotificationsTab(): React.ReactElement {
} else {
setWebhookUrl(val)
}
setNtfyTopic((data.settings?.ntfy_topic as string) || '')
setNtfyServer((data.settings?.ntfy_server as string) || '')
const rawToken = (data.settings?.ntfy_token as string) || ''
if (rawToken === '••••••••') {
setNtfyTokenIsSet(true)
setNtfyToken('')
} else {
setNtfyToken(rawToken)
}
}).catch(() => {})
}, [])
const visibleChannels = matrix
? (['email', 'webhook', 'inapp'] as const).filter(ch => {
if (!matrix.available_channels[ch]) return false
? (['email', 'webhook', 'ntfy', 'inapp'] as const).filter(ch => {
if (!matrix.available_channels[ch as keyof typeof matrix.available_channels]) return false
return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch))
})
: []
@@ -106,6 +123,52 @@ export default function NotificationsTab(): React.ReactElement {
}
}
const saveNtfySettings = async () => {
setNtfySaving(true)
try {
await settingsApi.setBulk({
ntfy_topic: ntfyTopic,
ntfy_server: ntfyServer,
...(ntfyToken && ntfyToken !== '••••••••' ? { ntfy_token: ntfyToken } : {}),
})
if (ntfyToken && ntfyToken !== '••••••••') setNtfyTokenIsSet(true)
toast.success(t('settings.ntfyUrl.saved'))
} catch {
toast.error(t('common.error'))
} finally {
setNtfySaving(false)
}
}
const clearNtfyToken = async () => {
try {
await settingsApi.set('ntfy_token', '')
setNtfyToken('')
setNtfyTokenIsSet(false)
toast.success(t('settings.ntfyUrl.tokenCleared'))
} catch {
toast.error(t('common.error'))
}
}
const testNtfySettings = async () => {
if (!ntfyTopic) return
setNtfyTesting(true)
try {
const result = await notificationsApi.testNtfy({
topic: ntfyTopic,
server: ntfyServer || null,
token: ntfyToken && ntfyToken !== '••••••••' ? ntfyToken : null,
})
if (result.success) toast.success(t('settings.ntfyUrl.testSuccess'))
else toast.error(result.error || t('settings.ntfyUrl.testFailed'))
} catch {
toast.error(t('settings.ntfyUrl.testFailed'))
} finally {
setNtfyTesting(false)
}
}
const renderContent = () => {
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>{t('common.loading')}</p>
@@ -139,7 +202,7 @@ export default function NotificationsTab(): React.ReactElement {
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')}
{t('common.save')}
</button>
<button
onClick={testWebhookUrl}
@@ -151,6 +214,66 @@ export default function NotificationsTab(): React.ReactElement {
</div>
</div>
)}
{matrix.available_channels.ntfy && (
<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.ntfyUrl.topicLabel')}
</label>
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>{t('settings.ntfyUrl.hint')}</p>
<input
type="text"
value={ntfyTopic}
onChange={e => setNtfyTopic(e.target.value)}
placeholder={t('settings.ntfyUrl.topicPlaceholder')}
style={{ width: '100%', boxSizing: 'border-box', fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)', marginBottom: 6 }}
/>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
{t('settings.ntfyUrl.serverLabel')}
</label>
<input
type="text"
value={ntfyServer}
onChange={e => setNtfyServer(e.target.value)}
placeholder={matrix.defaults?.ntfyServer || t('settings.ntfyUrl.serverPlaceholder')}
style={{ width: '100%', boxSizing: 'border-box', fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)', marginBottom: 6 }}
/>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
{t('settings.ntfyUrl.tokenLabel')}
</label>
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 4 }}>{t('settings.ntfyUrl.tokenHint')}</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="password"
value={ntfyToken}
onChange={e => setNtfyToken(e.target.value)}
placeholder={ntfyTokenIsSet ? '••••••••' : ''}
style={{ flex: 1, fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)' }}
/>
{ntfyTokenIsSet && (
<button
onClick={clearNtfyToken}
style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', color: 'var(--color-danger, #e53e3e)', border: '1px solid var(--color-danger, #e53e3e)', borderRadius: 6, cursor: 'pointer' }}
>
{t('common.clear')}
</button>
)}
<button
onClick={saveNtfySettings}
disabled={ntfySaving}
style={{ fontSize: 12, padding: '6px 12px', background: 'var(--text-primary)', color: 'var(--bg-primary)', border: 'none', borderRadius: 6, cursor: ntfySaving ? 'not-allowed' : 'pointer', opacity: ntfySaving ? 0.6 : 1 }}
>
{t('common.save')}
</button>
<button
onClick={testNtfySettings}
disabled={!ntfyTopic || ntfyTesting}
style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: (!ntfyTopic || ntfyTesting) ? 'not-allowed' : 'pointer', opacity: (!ntfyTopic || ntfyTesting) ? 0.5 : 1 }}
>
{t('settings.ntfyUrl.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 />
@@ -0,0 +1,133 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { act } from '@testing-library/react';
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useSystemNoticeStore } from '../../store/systemNoticeStore';
import { BannerRenderer } from './SystemNoticeBanner';
import type { SystemNoticeDTO } from '../../store/systemNoticeStore';
function makeBanner(overrides: Partial<SystemNoticeDTO> = {}): SystemNoticeDTO {
return {
id: 'banner-1',
display: 'banner',
severity: 'info',
titleKey: 'Maintenance notice',
bodyKey: 'System will be down briefly.',
dismissible: true,
...overrides,
};
}
describe('BannerRenderer', () => {
beforeEach(() => {
server.use(
http.post('/api/system-notices/:id/dismiss', () => {
return new HttpResponse(null, { status: 204 });
}),
);
useSystemNoticeStore.setState({ notices: [], loaded: true });
});
afterEach(() => {
vi.clearAllMocks();
document.documentElement.style.removeProperty('--banner-stack-h');
});
it('FE-SN-BANNER-001: renders banner with correct title and body', async () => {
const notice = makeBanner();
await act(async () => {
render(<BannerRenderer notices={[notice]} />);
});
expect(screen.getByText('Maintenance notice')).toBeTruthy();
expect(screen.getByText('System will be down briefly.')).toBeTruthy();
});
it('FE-SN-BANNER-002: dismiss button calls store.dismiss(id)', async () => {
const notice = makeBanner();
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
await act(async () => {
render(<BannerRenderer notices={[notice]} />);
});
const dismissBtn = screen.getByLabelText(/Dismiss/);
await act(async () => {
fireEvent.click(dismissBtn);
});
expect(dismissSpy).toHaveBeenCalledWith('banner-1');
});
it('FE-SN-BANNER-003: two banners stack correctly', async () => {
const n1 = makeBanner({ id: 'banner-1', titleKey: 'First notice' });
const n2 = makeBanner({ id: 'banner-2', titleKey: 'Second notice' });
await act(async () => {
render(<BannerRenderer notices={[n1, n2]} />);
});
expect(screen.getByText('First notice')).toBeTruthy();
expect(screen.getByText('Second notice')).toBeTruthy();
});
it('FE-SN-BANNER-004: third banner is not rendered (only top 2 shown)', async () => {
// Server returns notices highest-priority first; BannerRenderer takes slice(0,2)
const n1 = makeBanner({ id: 'banner-1', titleKey: 'Highest notice' });
const n2 = makeBanner({ id: 'banner-2', titleKey: 'Second notice' });
const n3 = makeBanner({ id: 'banner-3', titleKey: 'Lowest notice' });
await act(async () => {
render(<BannerRenderer notices={[n1, n2, n3]} />);
});
expect(screen.getByText('Highest notice')).toBeTruthy();
expect(screen.getByText('Second notice')).toBeTruthy();
expect(screen.queryByText('Lowest notice')).toBeNull();
});
it('FE-SN-BANNER-005: critical banner has aria-live="assertive"', async () => {
const notice = makeBanner({ severity: 'critical', id: 'crit-1' });
await act(async () => {
render(<BannerRenderer notices={[notice]} />);
});
const alertEl = screen.getByRole('alert');
expect(alertEl.getAttribute('aria-live')).toBe('assertive');
});
it('FE-SN-BANNER-006: info banner has aria-live="polite"', async () => {
const notice = makeBanner({ severity: 'info' });
await act(async () => {
render(<BannerRenderer notices={[notice]} />);
});
const statusEl = screen.getByRole('status');
expect(statusEl.getAttribute('aria-live')).toBe('polite');
});
it('FE-SN-BANNER-007: warn banner has aria-live="polite"', async () => {
const notice = makeBanner({ severity: 'warn', id: 'warn-1' });
await act(async () => {
render(<BannerRenderer notices={[notice]} />);
});
const statusEl = screen.getByRole('status');
expect(statusEl.getAttribute('aria-live')).toBe('polite');
});
it('FE-SN-BANNER-008: renders nothing when notices array is empty', () => {
const { container } = render(<BannerRenderer notices={[]} />);
expect(container.firstChild).toBeNull();
});
it('FE-SN-BANNER-009: non-dismissible banner hides dismiss button', async () => {
const notice = makeBanner({ dismissible: false });
await act(async () => {
render(<BannerRenderer notices={[notice]} />);
});
expect(screen.getByText('Maintenance notice')).toBeTruthy();
expect(screen.queryByLabelText(/Dismiss/)).toBeNull();
});
});
@@ -0,0 +1,268 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Info, AlertTriangle, AlertOctagon, X } from 'lucide-react';
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js';
import { useTranslation } from '../../i18n/index.js';
import { isRtlLanguage } from '../../i18n/index.js';
import { runNoticeAction } from './noticeActions.js';
const SEVERITY_ICONS: Record<string, React.ElementType> = {
info: Info,
warn: AlertTriangle,
critical: AlertOctagon,
};
const SEVERITY = {
info: {
bg: 'bg-white dark:bg-slate-900',
border: 'border-blue-500 dark:border-blue-400',
text: 'text-slate-900 dark:text-slate-100',
icon: 'text-blue-500 dark:text-blue-400',
ariaLive: 'polite' as const,
role: 'status' as const,
},
warn: {
bg: 'bg-amber-50 dark:bg-amber-950',
border: 'border-amber-500 dark:border-amber-400',
text: 'text-amber-900 dark:text-amber-100',
icon: 'text-amber-500 dark:text-amber-400',
ariaLive: 'polite' as const,
role: 'status' as const,
},
critical: {
bg: 'bg-rose-50 dark:bg-rose-950',
border: 'border-rose-600 dark:border-rose-400',
text: 'text-rose-900 dark:text-rose-100',
icon: 'text-rose-600 dark:text-rose-400',
ariaLive: 'assertive' as const,
role: 'alert' as const,
},
} as const;
interface BannerItemProps {
notice: SystemNoticeDTO;
onDismiss: () => void;
language: string;
}
function CTALink({
notice,
label,
onDismiss,
}: {
notice: SystemNoticeDTO;
label: string;
onDismiss: () => void;
}) {
const navigate = useNavigate();
function handleClick() {
if (!notice.cta) return;
if (notice.cta.kind === 'nav') {
navigate(notice.cta.href);
if (notice.dismissible) onDismiss();
} else {
runNoticeAction(notice.cta.actionId, { navigate });
const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
if (actionCta.dismissOnAction !== false) onDismiss();
}
}
if (!notice.cta) return null;
if (notice.cta.kind === 'nav') {
return (
<a
href={notice.cta.href}
onClick={e => { e.preventDefault(); handleClick(); }}
className="underline hover:no-underline font-medium ml-3 shrink-0"
>
{label}
</a>
);
}
return (
<button
onClick={handleClick}
className="underline hover:no-underline font-medium ml-3 shrink-0"
>
{label}
</button>
);
}
function BannerItem({ notice, onDismiss, language }: BannerItemProps) {
const { t } = useTranslation();
const s = SEVERITY[notice.severity] ?? SEVERITY.info;
const title = t(notice.titleKey);
const body = t(notice.bodyKey);
const ctaLabel = notice.cta ? t(notice.cta.labelKey) : null;
// Tailwind 3.3+ supports border-s-4 (logical, RTL-aware)
const accentBorder = 'border-s-4';
return (
<div
role={s.role}
aria-live={s.ariaLive}
aria-atomic="true"
className={`flex items-start gap-x-3 py-3 px-4 ${accentBorder} ${s.bg} ${s.border} ${s.text}`}
>
{React.createElement(
(SEVERITY_ICONS[notice.severity] ?? Info) as React.ElementType,
{ size: 20, className: `shrink-0 mt-0.5 ${s.icon}` },
)}
<div className="flex-1 min-w-0">
<span className="font-semibold">{title}</span>
{body !== title && (
<span className="ml-2 opacity-80">{body}</span>
)}
{ctaLabel && notice.cta && (
<CTALink notice={notice} label={ctaLabel} onDismiss={onDismiss} />
)}
</div>
{notice.dismissible && (
<button
onClick={onDismiss}
className="shrink-0 p-2 -mr-2 rounded hover:bg-black/5 dark:hover:bg-white/10 transition"
aria-label={`Dismiss: ${title}`}
>
<X size={20} />
</button>
)}
</div>
);
}
interface AnimatedBannerItemProps {
notice: SystemNoticeDTO;
onDismiss: () => void;
language: string;
}
function AnimatedBannerItem({ notice, onDismiss, language }: AnimatedBannerItemProps) {
const [mounted, setMounted] = useState(false);
const prefersReducedMotion =
typeof window !== 'undefined' &&
(window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false);
useEffect(() => {
if (typeof requestAnimationFrame !== 'undefined') {
const id = requestAnimationFrame(() => setMounted(true));
return () => cancelAnimationFrame(id);
}
setMounted(true);
}, []);
const transition = prefersReducedMotion
? 'transition-opacity duration-[120ms]'
: 'transition-all duration-200 ease-out';
const state = mounted ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-2';
return (
<div className={`${transition} ${state}`}>
<BannerItem notice={notice} onDismiss={onDismiss} language={language} />
</div>
);
}
interface BannerRendererProps {
notices: SystemNoticeDTO[];
}
export function BannerRenderer({ notices }: BannerRendererProps) {
const { dismiss } = useSystemNoticeStore();
const { language } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
// Show at most 2 highest-priority banners
const visible = notices.slice(0, 2);
// Report banner stack height for layout reflow
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver(() => {
document.documentElement.style.setProperty('--banner-stack-h', el.offsetHeight + 'px');
});
observer.observe(el);
return () => {
observer.disconnect();
document.documentElement.style.setProperty('--banner-stack-h', '0px');
};
}, []);
if (visible.length === 0) return null;
return (
<div
ref={containerRef}
className="fixed left-0 right-0 z-40"
style={{ top: 'var(--nav-h, 0px)' }}
>
{visible.map((notice, i) => (
<React.Fragment key={notice.id}>
{i > 0 && <div className="border-t border-black/10 dark:border-white/10" />}
<AnimatedBannerItem
notice={notice}
onDismiss={() => dismiss(notice.id)}
language={language}
/>
</React.Fragment>
))}
</div>
);
}
interface ToastRendererProps {
notices: SystemNoticeDTO[];
}
export function ToastRenderer({ notices }: ToastRendererProps) {
const { dismiss } = useSystemNoticeStore();
const { t } = useTranslation();
const firedRef = useRef(new Set<string>());
useEffect(() => {
for (const notice of notices) {
if (firedRef.current.has(notice.id)) continue;
firedRef.current.add(notice.id);
// Critical should not be a toast — log and skip
if (notice.severity === 'critical') {
console.warn(
`[systemNotices] notice "${notice.id}" is critical but display=toast. ` +
'Should be banner or modal.'
);
dismiss(notice.id);
continue;
}
const variantMap: Record<string, string> = { info: 'info', warn: 'warning' };
const variant = variantMap[notice.severity] ?? 'info';
const titleStr = t(notice.titleKey);
const bodyStr = t(notice.bodyKey);
const message = bodyStr !== titleStr ? `${titleStr}: ${bodyStr}` : titleStr;
const duration = notice.severity === 'warn' ? 9000 : 6000;
// Fire the toast, retrying on the next frame if __addToast isn't registered yet
// (race between ToastContainer mounting and SystemNoticeHost mounting on cold load).
const fireToast = (attempt = 0) => {
if (typeof window.__addToast === 'function') {
window.__addToast(message, variant as 'info' | 'success' | 'error' | 'warning', duration);
} else if (attempt < 10) {
requestAnimationFrame(() => fireToast(attempt + 1));
return; // don't schedule dismiss until the toast actually fires
} else {
console.warn(`[systemNotices] toast "${notice.id}" dropped — __addToast never registered`);
}
setTimeout(() => dismiss(notice.id), duration + 500);
};
fireToast();
}
}, [notices]); // eslint-disable-line react-hooks/exhaustive-deps
return null;
}
@@ -0,0 +1,31 @@
import React, { useEffect } from 'react';
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
import { ModalRenderer } from './SystemNoticeModal.js';
import { BannerRenderer, ToastRenderer } from './SystemNoticeBanner.js';
export function SystemNoticeHost() {
const { notices, loaded } = useSystemNoticeStore();
// Notices are fetched by authStore after login (see App.tsx / authStore modification).
// Cold-session fetch (page reload with valid session) is triggered here:
useEffect(() => {
// Only fetch if not already loaded (authStore may have already triggered)
if (!loaded) {
useSystemNoticeStore.getState().fetch();
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
if (!loaded) return null;
const modals = notices.filter(n => n.display === 'modal');
const banners = notices.filter(n => n.display === 'banner');
const toasts = notices.filter(n => n.display === 'toast');
return (
<>
<BannerRenderer notices={banners} />
<ModalRenderer notices={modals} />
<ToastRenderer notices={toasts} />
</>
);
}
@@ -0,0 +1,392 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { act } from '@testing-library/react';
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useSystemNoticeStore } from '../../store/systemNoticeStore';
import { ModalRenderer } from './SystemNoticeModal';
import type { SystemNoticeDTO } from '../../store/systemNoticeStore';
// Stub react-markdown to avoid async chunk issues in tests
vi.mock('react-markdown', () => ({
default: ({ children }: { children: string }) => <span data-testid="md">{children}</span>,
}));
vi.mock('remark-gfm', () => ({ default: () => ({}) }));
vi.mock('rehype-sanitize', () => ({ default: () => ({}) }));
function makeNotice(overrides: Partial<SystemNoticeDTO> = {}): SystemNoticeDTO {
return {
id: 'test-notice-1',
display: 'modal',
severity: 'info',
titleKey: 'Test Title',
bodyKey: 'Test body text',
dismissible: true,
...overrides,
};
}
/**
* Advance fake timers past the grace delay (2× rAF fallback each is a
* setTimeout(0), then 500ms). All three timers fire in sequence with
* runAllTimers() no need to advance exact milliseconds.
*/
async function flushGraceDelay() {
await act(async () => {
vi.runAllTimers();
});
}
describe('ModalRenderer', () => {
beforeEach(() => {
server.use(
http.post('/api/system-notices/:id/dismiss', () => {
return new HttpResponse(null, { status: 204 });
}),
);
useSystemNoticeStore.setState({ notices: [], loaded: true });
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
document.body.style.overflow = '';
});
it('FE-SN-MODAL-001: renders title and body after grace delay', async () => {
const notice = makeNotice();
render(<ModalRenderer notices={[notice]} />);
// Before delay fires: dialog present but body not yet visible (class-based)
expect(screen.getByRole('dialog')).toBeTruthy();
await flushGraceDelay();
expect(screen.getByText('Test Title')).toBeTruthy();
expect(screen.getByText('Test body text')).toBeTruthy();
});
it('FE-SN-MODAL-002: dismiss button calls store.dismiss(id)', async () => {
const notice = makeNotice();
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
render(<ModalRenderer notices={[notice]} />);
await flushGraceDelay();
const dismissBtn = screen.getByLabelText('Dismiss');
await act(async () => {
fireEvent.click(dismissBtn);
});
expect(dismissSpy).toHaveBeenCalledWith('test-notice-1');
});
it('FE-SN-MODAL-003: non-dismissible critical notice hides dismiss affordance', async () => {
const notice = makeNotice({ severity: 'critical', dismissible: false });
render(<ModalRenderer notices={[notice]} />);
await flushGraceDelay();
expect(screen.queryByLabelText('Dismiss')).toBeNull();
expect(screen.queryByText('Not now')).toBeNull();
});
it('FE-SN-MODAL-004: ESC key does not close non-dismissible notice', async () => {
const notice = makeNotice({ severity: 'critical', dismissible: false });
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
render(<ModalRenderer notices={[notice]} />);
await flushGraceDelay();
await act(async () => {
fireEvent.keyDown(document, { key: 'Escape' });
});
expect(dismissSpy).not.toHaveBeenCalled();
expect(screen.getByRole('dialog')).toBeTruthy();
});
it('FE-SN-MODAL-005: CTA nav button dismisses all notices (not just current)', async () => {
// CTA is only shown on the last page; navigate there first
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } });
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
render(<ModalRenderer notices={[noticeA, noticeB]} />);
await flushGraceDelay();
// Navigate to last page
await act(async () => {
fireEvent.click(screen.getByLabelText('Go to notice 2'));
});
await flushGraceDelay();
const ctaBtn = screen.getByRole('button', { name: 'Go to trips' });
await act(async () => {
fireEvent.click(ctaBtn);
});
expect(dismissSpy).toHaveBeenCalledWith('n-a');
expect(dismissSpy).toHaveBeenCalledWith('n-b');
expect(dismissSpy).toHaveBeenCalledTimes(2);
});
it('FE-SN-MODAL-006: modal backdrop has opacity-0 class before grace delay fires', () => {
const notice = makeNotice();
const { container } = render(<ModalRenderer notices={[notice]} />);
// Dialog is in DOM, backdrop has opacity-0 before timers fire
expect(screen.getByRole('dialog')).toBeTruthy();
const backdrop = container.querySelector('[role="presentation"]');
expect(backdrop?.className).toContain('opacity-0');
});
it('FE-SN-MODAL-007: body params are interpolated before rendering', async () => {
const notice = makeNotice({
bodyKey: 'Hello {name}, welcome to {app}',
bodyParams: { name: 'Alice', app: 'TREK' },
});
render(<ModalRenderer notices={[notice]} />);
await flushGraceDelay();
expect(screen.getByText('Hello Alice, welcome to TREK')).toBeTruthy();
});
it('FE-SN-MODAL-008: empty notices renders nothing', () => {
const { container } = render(<ModalRenderer notices={[]} />);
expect(container.firstChild).toBeNull();
});
// ── Multipage (pager) ──────────────────────────────────────────────────────
it('FE-SN-MODAL-009: pager is hidden when only one notice is present', async () => {
const notice = makeNotice();
render(<ModalRenderer notices={[notice]} />);
await flushGraceDelay();
expect(screen.queryByLabelText('Previous notice')).toBeNull();
expect(screen.queryByLabelText('Next notice')).toBeNull();
});
it('FE-SN-MODAL-010: pager shows counter and dots for multiple notices', async () => {
const notices = [
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
makeNotice({ id: 'n3', titleKey: 'Notice C' }),
];
render(<ModalRenderer notices={notices} />);
await flushGraceDelay();
expect(screen.getByText('1 / 3')).toBeTruthy();
expect(screen.getByLabelText('Go to notice 1')).toBeTruthy();
expect(screen.getByLabelText('Go to notice 2')).toBeTruthy();
expect(screen.getByLabelText('Go to notice 3')).toBeTruthy();
expect(screen.getByLabelText('Previous notice')).toBeTruthy();
expect(screen.getByLabelText('Next notice')).toBeTruthy();
});
it('FE-SN-MODAL-011: next button advances to the next notice; prev returns', async () => {
const notices = [
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
makeNotice({ id: 'n3', titleKey: 'Notice C' }),
];
render(<ModalRenderer notices={notices} />);
await flushGraceDelay();
expect(screen.getByText('1 / 3')).toBeTruthy();
expect(screen.getByText('Notice A')).toBeTruthy();
// Navigate to page 2
await act(async () => {
fireEvent.click(screen.getByLabelText('Next notice'));
});
await flushGraceDelay();
expect(screen.getByText('2 / 3')).toBeTruthy();
expect(screen.getByText('Notice B')).toBeTruthy();
// Navigate back to page 1
await act(async () => {
fireEvent.click(screen.getByLabelText('Previous notice'));
});
await flushGraceDelay();
expect(screen.getByText('1 / 3')).toBeTruthy();
expect(screen.getByText('Notice A')).toBeTruthy();
});
it('FE-SN-MODAL-012: ArrowRight / ArrowLeft keys navigate between pages', async () => {
const notices = [
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
];
render(<ModalRenderer notices={notices} />);
await flushGraceDelay();
expect(screen.getByText('Notice A')).toBeTruthy();
await act(async () => {
fireEvent.keyDown(document, { key: 'ArrowRight' });
});
await flushGraceDelay();
expect(screen.getByText('Notice B')).toBeTruthy();
await act(async () => {
fireEvent.keyDown(document, { key: 'ArrowLeft' });
});
await flushGraceDelay();
expect(screen.getByText('Notice A')).toBeTruthy();
});
it('FE-SN-MODAL-013: clicking a dot navigates directly to that page', async () => {
const notices = [
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
makeNotice({ id: 'n3', titleKey: 'Notice C' }),
];
render(<ModalRenderer notices={notices} />);
await flushGraceDelay();
expect(screen.getByText('Notice A')).toBeTruthy();
// Click third dot
await act(async () => {
fireEvent.click(screen.getByLabelText('Go to notice 3'));
});
await flushGraceDelay();
expect(screen.getByText('3 / 3')).toBeTruthy();
expect(screen.getByText('Notice C')).toBeTruthy();
});
it('FE-SN-MODAL-014: non-dismissible notice locks the pager (prev/next/dots disabled)', async () => {
const notices = [
makeNotice({ id: 'n1', titleKey: 'Notice A', dismissible: false }),
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
];
render(<ModalRenderer notices={notices} />);
await flushGraceDelay();
const prevBtn = screen.getByLabelText('Previous notice') as HTMLButtonElement;
const nextBtn = screen.getByLabelText('Next notice') as HTMLButtonElement;
const dot2 = screen.getByLabelText('Go to notice 2') as HTMLButtonElement;
expect(prevBtn.disabled).toBe(true);
expect(nextBtn.disabled).toBe(true);
expect(dot2.disabled).toBe(true);
// Arrow keys should also be blocked
await act(async () => {
fireEvent.keyDown(document, { key: 'ArrowRight' });
});
// Still on page 1 (no grace delay needed because page didn't change)
expect(screen.getByText('1 / 2')).toBeTruthy();
});
it('FE-SN-MODAL-015: dismissing a notice does not skip the next one (regression)', async () => {
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
const noticeC = makeNotice({ id: 'n-c', titleKey: 'Notice C' });
useSystemNoticeStore.setState({ notices: [noticeA, noticeB, noticeC], loaded: true });
const { rerender } = render(<ModalRenderer notices={[noticeA, noticeB, noticeC]} />);
await flushGraceDelay();
expect(screen.getByText('Notice A')).toBeTruthy();
expect(screen.getByText('1 / 3')).toBeTruthy();
// Navigate to last page where X button is available
await act(async () => {
fireEvent.click(screen.getByLabelText('Go to notice 3'));
});
await flushGraceDelay();
// Dismiss all from last page — store shrinks
await act(async () => {
fireEvent.click(screen.getByLabelText('Dismiss'));
useSystemNoticeStore.setState({ notices: [], loaded: true });
rerender(<ModalRenderer notices={[]} />);
});
await flushGraceDelay();
// All dismissed — modal should be gone
expect(screen.queryByRole('dialog')).toBeNull();
});
it('FE-SN-MODAL-017: X button dismisses all notices, not just the current one', async () => {
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
render(<ModalRenderer notices={[noticeA, noticeB]} />);
await flushGraceDelay();
// X button only appears on the last page — navigate there
await act(async () => {
fireEvent.click(screen.getByLabelText('Go to notice 2'));
});
await flushGraceDelay();
await act(async () => {
fireEvent.click(screen.getByLabelText('Dismiss'));
});
expect(dismissSpy).toHaveBeenCalledWith('n-a');
expect(dismissSpy).toHaveBeenCalledWith('n-b');
expect(dismissSpy).toHaveBeenCalledTimes(2);
});
it('FE-SN-MODAL-018: ESC key dismisses all notices when on last page', async () => {
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
render(<ModalRenderer notices={[noticeA, noticeB]} />);
await flushGraceDelay();
// ESC only works on last page — navigate there first
await act(async () => {
fireEvent.click(screen.getByLabelText('Go to notice 2'));
});
await flushGraceDelay();
await act(async () => {
fireEvent.keyDown(document, { key: 'Escape' });
});
expect(dismissSpy).toHaveBeenCalledWith('n-a');
expect(dismissSpy).toHaveBeenCalledWith('n-b');
expect(dismissSpy).toHaveBeenCalledTimes(2);
});
it('FE-SN-MODAL-016: dismissing the only remaining notice closes the modal', async () => {
const notice = makeNotice({ id: 'solo', titleKey: 'Solo Notice' });
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
const { rerender, container } = render(<ModalRenderer notices={[notice]} />);
await flushGraceDelay();
expect(screen.getByText('Solo Notice')).toBeTruthy();
await act(async () => {
fireEvent.click(screen.getByLabelText('Dismiss'));
useSystemNoticeStore.setState({ notices: [], loaded: true });
rerender(<ModalRenderer notices={[]} />);
});
expect(container.firstChild).toBeNull();
});
});
@@ -0,0 +1,646 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import remarkGfm from 'remark-gfm';
import rehypeSanitize from 'rehype-sanitize';
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js';
import { useTranslation, isRtlLanguage } from '../../i18n/index.js';
import { runNoticeAction } from './noticeActions.js';
const ReactMarkdown = React.lazy(() =>
import('react-markdown').then(m => ({ default: m.default }))
);
/** Safe rAF shim — falls back to setTimeout(0) in environments without rAF (e.g. jsdom). */
function scheduleFrame(cb: () => void): () => void {
if (typeof requestAnimationFrame !== 'undefined') {
const id = requestAnimationFrame(cb);
return () => cancelAnimationFrame(id);
}
const id = setTimeout(cb, 0);
return () => clearTimeout(id);
}
const SEVERITY_ICONS: Record<string, React.ElementType> = {
info: Info,
warn: AlertTriangle,
critical: AlertOctagon,
};
const SEVERITY_ACCENT: Record<string, string> = {
info: 'text-blue-500 dark:text-blue-400 bg-blue-50 dark:bg-blue-950',
warn: 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950',
critical: 'text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-950',
};
interface Props {
notices: SystemNoticeDTO[];
}
// Inner content shared between desktop and mobile layouts
interface ContentProps {
notice: SystemNoticeDTO;
title: string;
body: string;
ctaLabel: string | null;
titleId: string;
bodyId: string;
isDark: boolean;
onDismiss: () => void;
onDismissAll: () => void;
onCTA: () => void;
// Pager
total: number;
currentPage: number;
canPage: boolean;
onPrev: () => void;
onNext: () => void;
onGoto: (i: number) => void;
}
function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) {
const { t } = useTranslation();
const isLastPage = total <= 1 || currentPage === total - 1;
const DefaultIcon = SEVERITY_ICONS[notice.severity] ?? Info;
const LucideIcon: React.ElementType = notice.icon
? ((LucideIcons as Record<string, unknown>)[notice.icon] as React.ElementType) ?? DefaultIcon
: DefaultIcon;
return (
<div className="flex flex-col relative">
{/* Dismiss X button — only on last page so users read all notices */}
{notice.dismissible && isLastPage && (
<button
onClick={onDismissAll}
className="absolute top-4 right-4 p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
aria-label="Dismiss"
>
<X size={18} />
</button>
)}
{/* Hero image (not inline) */}
{notice.media && notice.media.placement !== 'inline' && (
<div
className="w-full overflow-hidden"
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
>
<img
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
alt={t(notice.media.altKey)}
className="w-full h-full object-cover"
fetchPriority="high"
decoding="async"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
)}
{/* Special warm header for Heart icon (thank-you notice) */}
{notice.icon === 'Heart' && !notice.media && (
<div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-5 text-center">
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 20% 50%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px), radial-gradient(circle at 60% 80%, white 1px, transparent 1px)', backgroundSize: '60px 60px, 80px 80px, 40px 40px' }} />
<div className="relative flex items-center justify-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/10">
<LucideIcon size={20} className="text-white" />
</div>
<div className="text-left">
<h2 id={titleId} className="text-lg font-bold text-white leading-tight">{title}</h2>
<p className="text-xs text-white/60 font-medium">TREK 3.0</p>
</div>
</div>
</div>
)}
<div className={notice.icon === 'Heart' && !notice.media ? 'px-8 py-6' : 'p-8'}>
{/* Severity icon (when no hero and not Heart) */}
{!notice.media && notice.icon !== 'Heart' && (
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${SEVERITY_ACCENT[notice.severity] ?? ''}`}>
<LucideIcon size={28} />
</div>
)}
{/* Title (not for Heart — rendered in gradient header) */}
{(notice.icon !== 'Heart' || notice.media) && (
<h2
id={titleId}
className="text-xl font-semibold text-center text-slate-900 dark:text-slate-100 mb-3"
>
{title}
</h2>
)}
{/* Body — markdown (long body text uses left-aligned layout) */}
<div
id={bodyId}
className="text-sm leading-relaxed text-slate-600 dark:text-slate-400 mx-auto mb-4 text-center"
>
<React.Suspense fallback={<p className="text-sm text-slate-500">{body}</p>}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize]}
components={{
a: ({ href, children }) => (
<a
href={href}
className="text-indigo-600 dark:text-indigo-400 underline decoration-indigo-300 dark:decoration-indigo-700 hover:decoration-indigo-500 dark:hover:decoration-indigo-400 underline-offset-2 transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
p: ({ children }) => {
// Signature line styling (e.g. "— Maurice")
const text = typeof children === 'string' ? children : Array.isArray(children) ? children.find(c => typeof c === 'string') : '';
if (typeof text === 'string' && text.trim().startsWith('—') && text.trim().length < 30) {
return <p className="mt-4 mb-3 text-base font-semibold text-slate-800 dark:text-slate-200 italic">{children}</p>;
}
return <p className="mb-3 last:mb-0">{children}</p>;
},
hr: () => (
<div className="my-5 flex items-center gap-3">
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
<span className="text-slate-300 dark:text-slate-600 text-xs"></span>
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
</div>
),
strong: ({ children }) => <strong className="font-semibold text-slate-800 dark:text-slate-200">{children}</strong>,
ul: ({ children }) => <ul className="list-disc list-inside text-left">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside text-left">{children}</ol>,
}}
>
{body}
</ReactMarkdown>
</React.Suspense>
</div>
{/* Inline image */}
{notice.media?.placement === 'inline' && (
<div
className="w-full overflow-hidden rounded-lg mb-4 mx-auto"
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
>
<img
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
alt={t(notice.media.altKey)}
className="w-full h-full object-cover"
decoding="async"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
)}
{/* Highlights */}
{notice.highlights && notice.highlights.length > 0 && (
<ul className="mx-auto mb-4 space-y-2">
{notice.highlights.map((h, i) => {
const HIcon: React.ElementType | null = h.iconName
? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null
: null;
return (
<li key={i} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
{HIcon
? <HIcon size={16} className="text-blue-500 shrink-0" />
: <span className="text-blue-500 shrink-0"></span>
}
{t(h.labelKey)}
</li>
);
})}
</ul>
)}
{/* Pager — dots, arrows, counter (only when multiple notices) */}
{total > 1 && (
<div className="flex flex-col items-center gap-1 mb-4">
<div className="flex items-center gap-2">
<button
onClick={onPrev}
disabled={!canPage || currentPage === 0}
aria-label={t('system_notice.pager.prev')}
className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={14} />
</button>
{Array.from({ length: total }, (_, i) => (
<button
key={i}
onClick={() => { if (canPage) onGoto(i); }}
aria-label={t('system_notice.pager.goto').replace('{n}', String(i + 1))}
aria-current={i === currentPage ? 'true' : undefined}
disabled={!canPage && i !== currentPage}
className={`w-2 h-2 rounded-full transition-colors ${
i === currentPage
? 'bg-blue-500 dark:bg-blue-400'
: 'bg-slate-300 dark:bg-slate-600 hover:bg-slate-400 dark:hover:bg-slate-500 disabled:cursor-not-allowed'
}`}
/>
))}
<button
onClick={onNext}
disabled={!canPage || currentPage === total - 1}
aria-label={t('system_notice.pager.next')}
className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={14} />
</button>
</div>
<span className="text-xs text-slate-400 tabular-nums">
{t('system_notice.pager.counter')
.replace('{current}', String(currentPage + 1))
.replace('{total}', String(total))}
</span>
</div>
)}
{/* CTA + dismiss link */}
<div className="flex flex-col items-center gap-3 mt-2">
{!isLastPage && total > 1 ? (
/* Non-last page: "Next" button to advance through all notices */
<button
id={`notice-cta-${notice.id}`}
onClick={onNext}
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors flex items-center justify-center gap-2"
>
{t('system_notice.pager.next')} <ChevronRight size={16} />
</button>
) : ctaLabel ? (
<button
id={`notice-cta-${notice.id}`}
onClick={onCTA}
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
>
{ctaLabel}
</button>
) : (
<button
id={`notice-cta-${notice.id}`}
onClick={onDismissAll}
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
>
{t('common.ok')}
</button>
)}
{notice.dismissible && isLastPage && ctaLabel && (
<button
onClick={onDismiss}
className="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
>
Not now
</button>
)}
</div>
</div>
</div>
);
}
export function ModalRenderer({ notices }: Props) {
const [idx, setIdx] = useState(0);
const [visible, setVisible] = useState(false);
const [pageAnnouncement, setPageAnnouncement] = useState('');
const navigate = useNavigate();
const { dismiss } = useSystemNoticeStore();
const { t, language } = useTranslation();
const [isMobile, setIsMobile] = useState(
() => typeof window !== 'undefined' && (window.matchMedia?.('(max-width: 639px)')?.matches ?? false)
);
const [isDark, setIsDark] = useState(
() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
);
const prefersReducedMotion =
typeof window !== 'undefined' &&
(window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false);
const notice = notices[idx] ?? null;
// Non-dismissible notices lock the pager so users must act before advancing.
const canPage = notice?.dismissible !== false;
const touchStartY = useRef<number | null>(null);
// Keep a ref to the current notice id so dismiss/CTA handlers see the latest value
const noticeIdRef = useRef<string | null>(null);
noticeIdRef.current = notice?.id ?? null;
// Page-slide animation refs.
// isPageNavRef: set to true just before a user-initiated page change so the
// grace-delay effect knows to run a slide instead of hide+show.
// slideDirRef: 'right' = new content enters from the right (Next), 'left' = from the left (Prev).
// contentWrapperRef: the div wrapping NoticeContent — we animate its transform directly.
const isPageNavRef = useRef(false);
const slideDirRef = useRef<'left' | 'right'>('right');
const contentWrapperRef = useRef<HTMLDivElement>(null);
// Mobile breakpoint
useEffect(() => {
const mq = window.matchMedia?.('(max-width: 639px)');
if (!mq) return;
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
// Dark mode observer
useEffect(() => {
const obs = new MutationObserver(() => {
setIsDark(document.documentElement.classList.contains('dark'));
});
obs.observe(document.documentElement, { attributeFilter: ['class'] });
return () => obs.disconnect();
}, []);
// Clamp idx when notices array shrinks (e.g. after dismiss of the last page)
useEffect(() => {
if (notices.length > 0 && idx >= notices.length) {
setIdx(notices.length - 1);
}
}, [notices.length, idx]);
// Fires on every notice-id change. Branches on whether this is a user-initiated
// page navigation (slide the content wrapper) or a modal appear/dismiss-advance
// (grace-delay the whole modal).
useEffect(() => {
if (!notice) return;
// ── Page navigation: slide new content in, keep modal visible ────────────
if (isPageNavRef.current) {
isPageNavRef.current = false;
const el = contentWrapperRef.current;
if (el && !prefersReducedMotion) {
// The handler already set el.style.transform to the start position
// synchronously before setIdx was called. Trigger the transition here.
requestAnimationFrame(() => {
el.style.transition = 'transform 260ms ease-out';
el.style.transform = 'translateX(0)';
const onEnd = () => {
el.style.transition = '';
el.style.transform = '';
el.removeEventListener('transitionend', onEnd);
};
el.addEventListener('transitionend', onEnd);
});
}
return;
}
// ── Modal appearing / dismiss-advance: grace delay ────────────────────────
setVisible(false);
let cancelled = false;
let timerId: ReturnType<typeof setTimeout> | undefined;
const cancel1 = scheduleFrame(() => {
const cancel2 = scheduleFrame(() => {
timerId = setTimeout(() => {
if (!cancelled) setVisible(true);
}, 500);
});
if (cancelled) cancel2();
});
return () => {
cancelled = true;
cancel1();
if (timerId !== undefined) clearTimeout(timerId);
};
}, [notice?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// ESC key — closes all modal notices (only on last page so users read all notices)
const isLastPage = notices.length <= 1 || idx === notices.length - 1;
useEffect(() => {
if (!visible || !notice?.dismissible || !isLastPage) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleDismissAll();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [visible, notice?.dismissible, isLastPage]); // eslint-disable-line react-hooks/exhaustive-deps
// Arrow-key pager navigation
useEffect(() => {
if (!visible || notices.length <= 1) return;
const handler = (e: KeyboardEvent) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
if (!canPage) return;
// In RTL layouts the directional meaning of arrows is flipped
const forward = isRtlLanguage(language) ? e.key === 'ArrowLeft' : e.key === 'ArrowRight';
if (forward && idx < notices.length - 1) {
triggerPageSlide('right');
setIdx(idx + 1);
announceIndex(idx + 1, notices.length);
} else if (!forward && idx > 0) {
triggerPageSlide('left');
setIdx(idx - 1);
announceIndex(idx - 1, notices.length);
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [visible, idx, notices.length, canPage, language]); // eslint-disable-line react-hooks/exhaustive-deps
// Body scroll lock
useEffect(() => {
if (visible && notice) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => { document.body.style.overflow = ''; };
}, [visible, notice]);
function announceIndex(newIdx: number, total: number) {
setPageAnnouncement(
t('system_notice.pager.position')
.replace('{current}', String(newIdx + 1))
.replace('{total}', String(total)),
);
}
// Dismiss current notice. The store removes it from the array, and the next
// notice naturally shifts into notices[idx]. The clamp effect handles the
// edge case where idx was pointing at the last item.
function handleDismissById(id: string) {
setVisible(false);
dismiss(id);
}
function handleDismiss() {
const id = noticeIdRef.current;
if (id) handleDismissById(id);
}
// Dismiss every notice in the current modal list — used by the X button and ESC.
function handleDismissAll() {
setVisible(false);
notices.forEach(n => dismiss(n.id));
}
function handleCTA() {
if (!notice) return;
if (!notice.cta) {
handleDismissAll();
return;
}
if (notice.cta.kind === 'nav') {
navigate(notice.cta.href);
if (notice.dismissible !== false) handleDismissAll();
} else {
runNoticeAction(notice.cta.actionId, { navigate });
const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
if (actionCta.dismissOnAction !== false) handleDismissAll();
}
}
// Sets up the content wrapper's start transform SYNCHRONOUSLY (before React
// re-renders with the new notice), then flags the grace-delay effect to slide
// rather than hide+show.
function triggerPageSlide(dir: 'left' | 'right') {
isPageNavRef.current = true;
slideDirRef.current = dir;
if (!prefersReducedMotion) {
const el = contentWrapperRef.current;
if (el) {
el.style.transition = 'none';
el.style.transform = dir === 'right' ? 'translateX(100%)' : 'translateX(-100%)';
}
}
}
function handlePrev() {
if (!canPage || idx <= 0) return;
const next = idx - 1;
triggerPageSlide('left');
setIdx(next);
announceIndex(next, notices.length);
}
function handleNext() {
if (!canPage || idx >= notices.length - 1) return;
const next = idx + 1;
triggerPageSlide('right');
setIdx(next);
announceIndex(next, notices.length);
}
function handleGoto(i: number) {
if (!canPage || i === idx) return;
triggerPageSlide(i > idx ? 'right' : 'left');
setIdx(i);
announceIndex(i, notices.length);
}
// No notice to show
if (!notice) return null;
// Pre-compute body with params interpolated
const rawBody = t(notice.bodyKey);
const body = notice.bodyParams
? Object.entries(notice.bodyParams).reduce(
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v),
rawBody
)
: rawBody;
const title = t(notice.titleKey);
const ctaLabel = notice.cta ? t(notice.cta.labelKey) : null;
const titleId = `notice-title-${notice.id}`;
const bodyId = `notice-body-${notice.id}`;
// Animation classes
const dur = prefersReducedMotion ? 'duration-[120ms]' : 'duration-[260ms]';
const ease = visible ? 'ease-out' : 'ease-in';
const contentProps: ContentProps = {
notice, title, body, ctaLabel, titleId, bodyId, isDark,
onDismiss: handleDismiss,
onDismissAll: handleDismissAll,
onCTA: handleCTA,
total: notices.length,
currentPage: idx,
canPage,
onPrev: handlePrev,
onNext: handleNext,
onGoto: handleGoto,
};
if (isMobile) {
const mobileMotion = prefersReducedMotion
? (visible ? 'opacity-100' : 'opacity-0')
: (visible ? 'opacity-100 translate-y-0' : 'opacity-100 translate-y-full');
return (
<div className="fixed inset-0 z-50" role="presentation">
{/* Screen-reader page announcements */}
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{pageAnnouncement}</span>
{/* Backdrop */}
<div
className={`absolute inset-0 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
onClick={notice.dismissible ? handleDismiss : undefined}
/>
{/* Bottom sheet */}
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={bodyId}
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden max-h-[85dvh] overflow-y-auto bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-all ${dur} ${ease} ${mobileMotion}`}
onTouchStart={e => { touchStartY.current = e.touches[0].clientY; }}
onTouchEnd={e => {
if (touchStartY.current !== null && notice.dismissible) {
const delta = e.changedTouches[0].clientY - touchStartY.current;
if (delta > 80) handleDismiss();
}
touchStartY.current = null;
}}
>
{/* Drag handle */}
<div className="pt-3 pb-1 flex justify-center">
<div className="w-9 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
</div>
<div ref={contentWrapperRef}>
<NoticeContent {...contentProps} />
</div>
</div>
</div>
);
}
// Desktop centered modal
const maxWidth = notice.severity === 'critical' ? 'max-w-[680px]' : 'max-w-[620px]';
const desktopMotion = prefersReducedMotion
? (visible ? 'opacity-100' : 'opacity-0')
: (visible ? 'opacity-100 scale-100' : 'opacity-0 scale-[0.97]');
return (
<div
className={`fixed inset-0 z-50 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
role="presentation"
onClick={notice.dismissible && isLastPage ? handleDismissAll : undefined}
>
{/* Screen-reader page announcements */}
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{pageAnnouncement}</span>
<div className="absolute inset-0 flex items-center justify-center p-4">
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={bodyId}
className={`w-full ${maxWidth} rounded-2xl overflow-hidden overflow-y-auto max-h-[90vh] shadow-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 transition-all ${dur} ${ease} ${desktopMotion}`}
onClick={e => e.stopPropagation()}
>
<div ref={contentWrapperRef}>
<NoticeContent {...contentProps} />
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,21 @@
import type { NavigateFunction } from 'react-router-dom';
export interface NoticeActionContext {
navigate: NavigateFunction;
}
type NoticeActionHandler = (ctx: NoticeActionContext) => void | Promise<void>;
const actions = new Map<string, NoticeActionHandler>();
export function registerNoticeAction(id: string, handler: NoticeActionHandler): void {
actions.set(id, handler);
}
export function runNoticeAction(id: string, ctx: NoticeActionContext): void {
const handler = actions.get(id);
if (!handler) {
console.error(`[systemNotices] unknown action CTA id: "${id}". Register it via registerNoticeAction().`);
return;
}
void handler(ctx);
}
+18
View File
@@ -0,0 +1,18 @@
import { useState, useEffect } from 'react'
/** Returns true when the viewport is below the lg breakpoint (1024px). */
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(
() => typeof window !== 'undefined' && window.innerWidth < 1024,
)
useEffect(() => {
const mq = window.matchMedia('(max-width: 1023px)')
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
setIsMobile(mq.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
return isMobile
}
+3 -3
View File
@@ -10,6 +10,7 @@ import ru from './translations/ru'
import zh from './translations/zh'
import zhTw from './translations/zhTw'
import nl from './translations/nl'
import id from './translations/id'
import ar from './translations/ar'
import br from './translations/br'
import cs from './translations/cs'
@@ -22,14 +23,13 @@ type TranslationStrings = Record<string, string | { name: string; category: stri
// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
const translations: Record<SupportedLanguageCode, TranslationStrings> = {
de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, ar, br, cs, pl,
de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl,
}
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
const LOCALES: Record<string, string> = Object.fromEntries(
SUPPORTED_LANGUAGES.map(l => [l.value, l.locale])
)
const RTL_LANGUAGES = new Set(['ar'])
export function getLocaleForLanguage(language: string): string {
@@ -38,7 +38,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', 'zh-TW', 'nl', 'ar', 'cs', 'pl'].includes(language) ? language : 'en'
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id'].includes(language) ? language : 'en'
}
export function isRtlLanguage(language: string): boolean {
+1
View File
@@ -13,6 +13,7 @@ export const SUPPORTED_LANGUAGES = [
{ value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' },
{ value: 'it', label: 'Italiano', locale: 'it-IT' },
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
{ value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' },
] as const
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
+93 -1
View File
@@ -8,6 +8,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'common.showMore': 'عرض المزيد',
'common.showLess': 'عرض أقل',
'common.cancel': 'إلغاء',
'common.clear': 'مسح',
'common.delete': 'حذف',
'common.edit': 'تعديل',
'common.add': 'إضافة',
@@ -463,6 +464,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.audit': 'تدقيق',
'admin.tabs.settings': 'الإعدادات',
'admin.tabs.config': 'التخصيص',
'admin.tabs.defaults': 'الإعدادات الافتراضية',
'admin.defaultSettings.title': 'إعدادات المستخدم الافتراضية',
'admin.defaultSettings.description': 'تعيين الإعدادات الافتراضية على مستوى النظام. سيرى المستخدمون الذين لم يغيروا إعدادًا هذه القيم. تحظى تغييراتهم دائمًا بالأولوية.',
'admin.defaultSettings.saved': 'تم حفظ الإعداد الافتراضي',
'admin.defaultSettings.reset': 'إعادة التعيين إلى الإعداد الافتراضي المدمج',
'admin.defaultSettings.resetToBuiltIn': 'إعادة تعيين',
'admin.tabs.templates': 'قوالب التعبئة',
'admin.tabs.addons': 'الإضافات',
'admin.tabs.mcpTokens': 'وصول MCP',
@@ -583,6 +590,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// Packing Templates & Bag Tracking
'admin.bagTracking.title': 'تتبع الأمتعة',
'admin.bagTracking.subtitle': 'تفعيل الوزن وتعيين الأمتعة للعناصر',
'admin.collab.chat.title': 'الدردشة',
'admin.collab.chat.subtitle': 'المراسلة في الوقت الفعلي للتعاون',
'admin.collab.notes.title': 'الملاحظات',
'admin.collab.notes.subtitle': 'ملاحظات ومستندات مشتركة',
'admin.collab.polls.title': 'الاستطلاعات',
'admin.collab.polls.subtitle': 'استطلاعات وتصويت جماعي',
'admin.collab.whatsnext.title': 'ما التالي',
'admin.collab.whatsnext.subtitle': 'اقتراحات الأنشطة والخطوات التالية',
'admin.packingTemplates.title': 'قوالب التعبئة',
'admin.packingTemplates.subtitle': 'إنشاء قوائم تعبئة قابلة لإعادة الاستخدام',
'admin.packingTemplates.create': 'قالب جديد',
@@ -1006,6 +1021,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.platform': 'المنصة',
'reservations.meta.seat': 'المقعد',
'reservations.meta.checkIn': 'تسجيل الوصول',
'reservations.meta.checkInUntil': 'تسجيل الدخول حتى',
'reservations.meta.checkOut': 'تسجيل المغادرة',
'reservations.meta.linkAccommodation': 'الإقامة',
'reservations.meta.pickAccommodation': 'ربط بالإقامة',
@@ -1490,6 +1506,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'day.noPlacesForHotel': 'أضف أماكن إلى رحلتك أولًا',
'day.allDays': 'الكل',
'day.checkIn': 'تسجيل الوصول',
'day.checkInUntil': 'حتى',
'day.checkOut': 'تسجيل المغادرة',
'day.confirmation': 'التأكيد',
'day.editAccommodation': 'تعديل الإقامة',
@@ -1809,14 +1826,26 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'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.ntfyUrl.topicLabel': 'موضوع Ntfy',
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
'settings.ntfyUrl.serverLabel': 'عنوان URL خادم Ntfy (اختياري)',
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
'settings.ntfyUrl.hint': 'أدخل موضوع Ntfy الخاص بك لتلقي الإشعارات الفورية. اترك حقل الخادم فارغاً لاستخدام الإعداد الافتراضي الذي حدده المسؤول.',
'settings.ntfyUrl.tokenLabel': 'رمز الوصول (اختياري)',
'settings.ntfyUrl.tokenHint': 'مطلوب للمواضيع المحمية بكلمة مرور.',
'settings.ntfyUrl.saved': 'تم حفظ إعدادات Ntfy',
'settings.ntfyUrl.test': 'اختبار',
'settings.ntfyUrl.testSuccess': 'تم إرسال إشعار Ntfy التجريبي بنجاح',
'settings.ntfyUrl.testFailed': 'فشل إشعار Ntfy التجريبي',
'settings.ntfyUrl.tokenCleared': 'تم مسح رمز الوصول',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
@@ -1827,6 +1856,25 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminWebhookPanel.testSuccess': 'تم إرسال Webhook الاختباري بنجاح',
'admin.notifications.adminWebhookPanel.testFailed': 'فشل إرسال Webhook الاختباري',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'يُرسل Webhook المسؤول تلقائيًا عند تعيين رابط URL',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': 'تسمح للمستخدمين بإعداد موضوعات ntfy الخاصة لتلقي إشعارات الدفع. قم بتعيين الخادم الافتراضي أدناه لملء إعدادات المستخدم مسبقًا.',
'admin.notifications.testNtfy': 'إرسال Ntfy تجريبي',
'admin.notifications.testNtfySuccess': 'تم إرسال Ntfy التجريبي بنجاح',
'admin.notifications.testNtfyFailed': 'فشل إرسال Ntfy التجريبي',
'admin.notifications.adminNtfyPanel.title': 'Ntfy المسؤول',
'admin.notifications.adminNtfyPanel.hint': 'يُستخدم موضوع Ntfy هذا حصريًا لإشعارات المسؤول (مثل تنبيهات الإصدارات). وهو مستقل عن مواضيع المستخدمين ويُرسل دائمًا عند تهيئته.',
'admin.notifications.adminNtfyPanel.serverLabel': 'عنوان URL خادم Ntfy',
'admin.notifications.adminNtfyPanel.serverHint': 'يُستخدم أيضًا كخادم افتراضي لإشعارات ntfy للمستخدمين. اتركه فارغًا لاستخدام ntfy.sh. يمكن للمستخدمين تغييره في إعداداتهم الخاصة.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'موضوع المسؤول',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'رمز الوصول (اختياري)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'تم مسح رمز وصول المسؤول',
'admin.notifications.adminNtfyPanel.saved': 'تم حفظ إعدادات Ntfy للمسؤول',
'admin.notifications.adminNtfyPanel.test': 'إرسال Ntfy تجريبي',
'admin.notifications.adminNtfyPanel.testSuccess': 'تم إرسال Ntfy التجريبي بنجاح',
'admin.notifications.adminNtfyPanel.testFailed': 'فشل إرسال Ntfy التجريبي',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'يُرسل Ntfy للمسؤول دائمًا عند تهيئة موضوع',
'admin.notifications.adminNotificationsHint': 'حدد القنوات التي تُسلّم إشعارات المسؤول (مثل تنبيهات الإصدارات). يُرسل الـ Webhook تلقائيًا عند تعيين رابط URL لـ Webhook المسؤول.',
'admin.tabs.notifications': 'الإشعارات',
'notifications.versionAvailable.title': 'تحديث متاح',
@@ -1931,6 +1979,50 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'oauth.scope.geo:read.description': 'البحث عن مواقع وحل عناوين الخرائط والترميز الجغرافي العكسي للإحداثيات',
'oauth.scope.weather:read.label': 'توقعات الطقس',
'oauth.scope.weather:read.description': 'جلب توقعات الطقس لمواقع الرحلة وتواريخها',
// System notices
'system_notice.welcome_v1.title': 'مرحبًا بك في TREK',
'system_notice.welcome_v1.body': 'مخطط رحلاتك الشامل. أنشئ جداول السفر، وشارك رحلاتك مع الأصدقاء، وابقَ منظمًا — سواء كنت متصلاً بالإنترنت أم لا.',
'system_notice.welcome_v1.cta_label': 'خطط لرحلة',
'system_notice.welcome_v1.hero_alt': 'وجهة سفر خلابة مع واجهة تطبيق TREK',
'system_notice.welcome_v1.highlight_plan': 'جداول رحلات يومية لكل سفرة',
'system_notice.welcome_v1.highlight_share': 'تعاون مع شركاء السفر',
'system_notice.welcome_v1.highlight_offline': 'يعمل بلا إنترنت على الهاتف',
'system_notice.dev_test_modal.title': '[Dev] Test notice',
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
'system_notice.pager.prev': 'الإشعار السابق',
'system_notice.pager.next': 'الإشعار التالي',
'system_notice.pager.counter': '{current} / {total}',
'system_notice.pager.goto': 'الانتقال إلى الإشعار {n}',
'system_notice.pager.position': 'الإشعار {current} من {total}',
// System notices — 3.0.0 upgrade
'system_notice.v3_photos.title': 'تم نقل الصور في الإصدار 3.0',
'system_notice.v3_photos.body': 'تمت إزالة تبويب ​**الصور**​ من مخطط الرحلة. صورك آمنة — لم يعدّل TREK مكتبتك على Immich أو Synology قطّ.\n\nتعيش الصور الآن في إضافة **Journey**. Journey اختيارية — إن لم تكن متاحة بعد، اطلب من المسؤول تفعيلها عبر Admin ← الإضافات.',
'system_notice.v3_journey.title': 'تعرّف على Journey — مذكرة سفر',
'system_notice.v3_journey.body': 'وثّق رحلاتك كقصص غنية بخطوط زمنية ومعارض صور وخرائط تفاعلية.',
'system_notice.v3_journey.cta_label': 'فتح Journey',
'system_notice.v3_journey.highlight_timeline': 'جدول زمني يومي ومعرض',
'system_notice.v3_journey.highlight_photos': 'استيراد من Immich أو Synology',
'system_notice.v3_journey.highlight_share': 'مشاركة علنية — دون تسجيل دخول',
'system_notice.v3_journey.highlight_export': 'تصدير كألبوم صور PDF',
'system_notice.v3_features.title': 'مزيد من مميزات 3.0',
'system_notice.v3_features.body': 'بعض الجديد الآخر الجدير بالمعرفة في هذا الإصدار.',
'system_notice.v3_features.highlight_dashboard': 'إعادة تصميم لوحة التحكم mobile-first',
'system_notice.v3_features.highlight_offline': 'وضع لا اتصال كامل كتطبيق PWA',
'system_notice.v3_features.highlight_search': 'إكمال تلقائي في الوقت الفعلي',
'system_notice.v3_features.highlight_import': 'استيراد أماكن من ملفات KMZ/KML',
// System notices — MCP OAuth 2.1 upgrade
'system_notice.v3_mcp.title': 'MCP: ترقية OAuth 2.1',
'system_notice.v3_mcp.body': 'تمت إعادة تصميم تكامل MCP بالكامل. OAuth 2.1 هو الآن طريقة المصادقة الموصى بها. الرموز الثابتة (trek_…) مهملة وستُزال في إصدار مستقبلي.',
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 موصى به (mcp-remote)',
'system_notice.v3_mcp.highlight_scopes': '24 نطاق أذونات دقيق',
'system_notice.v3_mcp.highlight_deprecated': 'الرموز الثابتة trek_ مهملة',
'system_notice.v3_mcp.highlight_tools': 'مجموعة أدوات وإرشادات موسعة',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'كلمة شخصية مني',
'system_notice.v3_thankyou.body': 'قبل أن تمضي — أريد أن أتوقف لحظة.\n\nبدأ TREK كمشروع جانبي بنيته لرحلاتي الخاصة. لم أتخيل يومًا أنه سيكبر ليصبح شيئًا يعتمد عليه 4,000 منكم لتخطيط مغامراتهم. كل نجمة، كل مشكلة، كل طلب ميزة — أقرأها جميعًا، وهي ما يبقيني مستمرًا في الليالي المتأخرة بين عمل بدوام كامل والجامعة.\n\nأريدكم أن تعرفوا: TREK سيبقى دائمًا مفتوح المصدر، دائمًا مستضافًا ذاتيًا، دائمًا ملككم. لا تتبع، لا اشتراكات، لا شروط خفية. مجرد أداة بناها شخص يحب السفر بقدر ما تحبونه.\n\nشكر خاص لـ [jubnl](https://github.com/jubnl) — لقد أصبحت متعاونًا رائعًا. الكثير مما يجعل الإصدار 3.0 عظيمًا يحمل بصماتك. شكرًا لإيمانك بهذا المشروع عندما كان لا يزال في بداياته.\n\nولكل واحد منكم ممن أبلغ عن خطأ، أو ترجم نصًا، أو شارك TREK مع صديق، أو ببساطة استخدمه لتخطيط رحلة — **شكرًا لكم**. أنتم السبب في وجود هذا.\n\nإلى المزيد من المغامرات معًا.\n\n— Maurice\n\n---\n\n[انضم إلى المجتمع على Discord](https://discord.gg/7Q6M6jDwzf)\n\nإذا جعل TREK رحلاتك أفضل، [فنجان قهوة صغير](https://ko-fi.com/mauriceboe) يبقي الأضواء مشتعلة.',
}
export default ar
+93 -1
View File
@@ -4,6 +4,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'common.showMore': 'Mostrar mais',
'common.showLess': 'Mostrar menos',
'common.cancel': 'Cancelar',
'common.clear': 'Limpar',
'common.delete': 'Excluir',
'common.edit': 'Editar',
'common.add': 'Adicionar',
@@ -547,7 +548,21 @@ 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.collab.chat.title': 'Chat',
'admin.collab.chat.subtitle': 'Mensagens em tempo real para colaboração',
'admin.collab.notes.title': 'Notas',
'admin.collab.notes.subtitle': 'Notas e documentos compartilhados',
'admin.collab.polls.title': 'Enquetes',
'admin.collab.polls.subtitle': 'Enquetes e votações em grupo',
'admin.collab.whatsnext.title': 'Próximos passos',
'admin.collab.whatsnext.subtitle': 'Sugestões de atividades e próximos passos',
'admin.tabs.config': 'Personalização',
'admin.tabs.defaults': 'Padrões do usuário',
'admin.defaultSettings.title': 'Configurações padrão do usuário',
'admin.defaultSettings.description': 'Defina padrões para toda a instância. Usuários que não alteraram uma configuração verão esses valores. As próprias alterações deles sempre têm prioridade.',
'admin.defaultSettings.saved': 'Padrão salvo',
'admin.defaultSettings.reset': 'Redefinir para o padrão integrado',
'admin.defaultSettings.resetToBuiltIn': 'redefinir',
'admin.tabs.templates': 'Modelos de mala',
'admin.packingTemplates.title': 'Modelos de mala',
'admin.packingTemplates.subtitle': 'Crie listas de mala reutilizáveis para suas viagens',
@@ -975,6 +990,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.platform': 'Plataforma',
'reservations.meta.seat': 'Assento',
'reservations.meta.checkIn': 'Check-in',
'reservations.meta.checkInUntil': 'Check-in até',
'reservations.meta.checkOut': 'Check-out',
'reservations.meta.linkAccommodation': 'Hospedagem',
'reservations.meta.pickAccommodation': 'Vincular à hospedagem',
@@ -1459,6 +1475,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'day.noPlacesForHotel': 'Adicione lugares à viagem primeiro',
'day.allDays': 'Todos',
'day.checkIn': 'Check-in',
'day.checkInUntil': 'Até',
'day.checkOut': 'Check-out',
'day.confirmation': 'Confirmação',
'day.editAccommodation': 'Editar hospedagem',
@@ -1758,14 +1775,26 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'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.ntfyUrl.topicLabel': 'Tópico Ntfy',
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
'settings.ntfyUrl.serverLabel': 'URL do servidor Ntfy (opcional)',
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
'settings.ntfyUrl.hint': 'Insira seu tópico Ntfy para receber notificações push. Deixe o servidor em branco para usar o padrão configurado pelo seu administrador.',
'settings.ntfyUrl.tokenLabel': 'Token de acesso (opcional)',
'settings.ntfyUrl.tokenHint': 'Necessário para tópicos protegidos por senha.',
'settings.ntfyUrl.saved': 'Configurações do Ntfy salvas',
'settings.ntfyUrl.test': 'Testar',
'settings.ntfyUrl.testSuccess': 'Notificação de teste do Ntfy enviada com sucesso',
'settings.ntfyUrl.testFailed': 'Falha na notificação de teste do Ntfy',
'settings.ntfyUrl.tokenCleared': 'Token de acesso removido',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
@@ -1776,6 +1805,25 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'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.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Permite que os usuários configurem seus próprios tópicos ntfy para notificações push. Configure o servidor padrão abaixo para preencher as configurações do usuário.',
'admin.notifications.testNtfy': 'Enviar Ntfy de teste',
'admin.notifications.testNtfySuccess': 'Ntfy de teste enviado com sucesso',
'admin.notifications.testNtfyFailed': 'Falha ao enviar Ntfy de teste',
'admin.notifications.adminNtfyPanel.title': 'Ntfy de admin',
'admin.notifications.adminNtfyPanel.hint': 'Este tópico Ntfy é usado exclusivamente para notificações de admin (ex. alertas de versão). É independente dos tópicos por usuário e sempre dispara quando configurado.',
'admin.notifications.adminNtfyPanel.serverLabel': 'URL do servidor Ntfy',
'admin.notifications.adminNtfyPanel.serverHint': 'Também usado como servidor padrão para notificações ntfy dos usuários. Deixe em branco para usar ntfy.sh. Os usuários podem substituir isso em suas próprias configurações.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Tópico de admin',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Token de acesso (opcional)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Token de acesso admin removido',
'admin.notifications.adminNtfyPanel.saved': 'Configurações de Ntfy de admin salvas',
'admin.notifications.adminNtfyPanel.test': 'Enviar Ntfy de teste',
'admin.notifications.adminNtfyPanel.testSuccess': 'Ntfy de teste enviado com sucesso',
'admin.notifications.adminNtfyPanel.testFailed': 'Falha ao enviar Ntfy de teste',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'O Ntfy de admin sempre dispara quando um tópico está configurado',
'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',
@@ -2134,6 +2182,50 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'oauth.scope.geo:read.description': 'Pesquisar locais, resolver URLs de mapa e geocodificar coordenadas',
'oauth.scope.weather:read.label': 'Previsão do tempo',
'oauth.scope.weather:read.description': 'Obter previsão do tempo para locais e datas da viagem',
// System notices
'system_notice.welcome_v1.title': 'Bem-vindo ao TREK',
'system_notice.welcome_v1.body': 'Seu planejador de viagens tudo-em-um. Crie roteiros, compartilhe viagens com amigos e fique organizado — online ou offline.',
'system_notice.welcome_v1.cta_label': 'Planejar uma viagem',
'system_notice.welcome_v1.hero_alt': 'Destino de viagem pitoresco com a interface do TREK',
'system_notice.welcome_v1.highlight_plan': 'Roteiros dia a dia para qualquer viagem',
'system_notice.welcome_v1.highlight_share': 'Colabore com seus companheiros de viagem',
'system_notice.welcome_v1.highlight_offline': 'Funciona offline no celular',
'system_notice.dev_test_modal.title': '[Dev] Test notice',
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
'system_notice.pager.prev': 'Aviso anterior',
'system_notice.pager.next': 'Próximo aviso',
'system_notice.pager.counter': '{current} / {total}',
'system_notice.pager.goto': 'Ir para o aviso {n}',
'system_notice.pager.position': 'Aviso {current} de {total}',
// System notices — 3.0.0 upgrade
'system_notice.v3_photos.title': 'Fotos foram movidas na versão 3.0',
'system_notice.v3_photos.body': '**Fotos** no Planejador de Viagens foram removidas. Suas fotos estão seguras — o TREK nunca modificou sua biblioteca Immich ou Synology.\n\nAs fotos agora vivem no addon **Journey**. Journey é opcional — se ainda não estiver disponível, peça ao seu admin para ativá-lo em Admin → Addons.',
'system_notice.v3_journey.title': 'Conheça o Journey — diário de viagem',
'system_notice.v3_journey.body': 'Documente suas viagens como histórias ricas com cronologias, galerias de fotos e mapas interativos.',
'system_notice.v3_journey.cta_label': 'Abrir Journey',
'system_notice.v3_journey.highlight_timeline': 'Linha do tempo e galeria diária',
'system_notice.v3_journey.highlight_photos': 'Importar do Immich ou Synology',
'system_notice.v3_journey.highlight_share': 'Compartilhar publicamente — sem login',
'system_notice.v3_journey.highlight_export': 'Exportar como álbum de fotos PDF',
'system_notice.v3_features.title': 'Mais destaques na versão 3.0',
'system_notice.v3_features.body': 'Algumas outras novidades que vale a pena conhecer nesta versão.',
'system_notice.v3_features.highlight_dashboard': 'Redesign do painel mobile-first',
'system_notice.v3_features.highlight_offline': 'Modo offline completo como PWA',
'system_notice.v3_features.highlight_search': 'Autocompleção de lugares em tempo real',
'system_notice.v3_features.highlight_import': 'Importar lugares de arquivos KMZ/KML',
// System notices — MCP OAuth 2.1 upgrade
'system_notice.v3_mcp.title': 'MCP: atualização OAuth 2.1',
'system_notice.v3_mcp.body': 'A integração MCP foi completamente reformulada. OAuth 2.1 agora é o método de autenticação recomendado. Tokens estáticos (trek_…) foram descontinuados e serão removidos em uma versão futura.',
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recomendado (mcp-remote)',
'system_notice.v3_mcp.highlight_scopes': '24 escopos de permissão granulares',
'system_notice.v3_mcp.highlight_deprecated': 'Tokens estáticos trek_ descontinuados',
'system_notice.v3_mcp.highlight_tools': 'Conjunto de ferramentas e prompts expandido',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Uma nota pessoal minha',
'system_notice.v3_thankyou.body': 'Antes de seguir em frente — quero fazer uma pausa.\n\nO TREK começou como um projeto paralelo que criei para minhas próprias viagens. Nunca imaginei que cresceria a ponto de 4.000 de vocês confiarem nele para planejar suas aventuras. Cada estrela, cada issue, cada pedido de recurso — eu leio todos, e eles me mantêm firme nas noites longas entre um trabalho em tempo integral e a universidade.\n\nQuero que saibam: o TREK sempre será open source, sempre self-hosted, sempre de vocês. Sem rastreamento, sem assinaturas, sem pegadinhas. Apenas uma ferramenta feita por alguém que ama viajar tanto quanto vocês.\n\nAgradecimento especial ao [jubnl](https://github.com/jubnl) — você se tornou um colaborador incrível. Muito do que torna a versão 3.0 especial tem a sua marca. Obrigado por acreditar neste projeto quando ele ainda era bem cru.\n\nE a cada um de vocês que reportou um bug, traduziu uma string, compartilhou o TREK com um amigo ou simplesmente o usou para planejar uma viagem — **obrigado**. Vocês são a razão de tudo isso existir.\n\nQue venham muitas mais aventuras juntos.\n\n— Maurice\n\n---\n\n[Junte-se à comunidade no Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe o TREK torna suas viagens melhores, um [cafezinho](https://ko-fi.com/mauriceboe) sempre mantém as luzes acesas.',
}
export default br
+93 -1
View File
@@ -4,6 +4,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'common.showMore': 'Zobrazit více',
'common.showLess': 'Zobrazit méně',
'common.cancel': 'Zrušit',
'common.clear': 'Vymazat',
'common.delete': 'Smazat',
'common.edit': 'Upravit',
'common.add': 'Přidat',
@@ -547,7 +548,21 @@ 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.collab.chat.title': 'Chat',
'admin.collab.chat.subtitle': 'Zasílání zpráv v reálném čase',
'admin.collab.notes.title': 'Poznámky',
'admin.collab.notes.subtitle': 'Sdílené poznámky a dokumenty',
'admin.collab.polls.title': 'Ankety',
'admin.collab.polls.subtitle': 'Skupinové ankety a hlasování',
'admin.collab.whatsnext.title': 'Co dál',
'admin.collab.whatsnext.subtitle': 'Návrhy aktivit a další kroky',
'admin.tabs.config': 'Personalizace',
'admin.tabs.defaults': 'Výchozí nastavení uživatele',
'admin.defaultSettings.title': 'Výchozí nastavení uživatele',
'admin.defaultSettings.description': 'Nastavte výchozí hodnoty pro celou instanci. Uživatelé, kteří nezměnili nastavení, uvidí tyto hodnoty. Jejich vlastní změny mají vždy přednost.',
'admin.defaultSettings.saved': 'Výchozí nastavení uloženo',
'admin.defaultSettings.reset': 'Obnovit na vestavěnou výchozí hodnotu',
'admin.defaultSettings.resetToBuiltIn': 'obnovit',
'admin.tabs.templates': 'Šablony seznamů',
'admin.packingTemplates.title': 'Šablony pro balení',
'admin.packingTemplates.subtitle': 'Vytvářejte opakovaně použitelné seznamy pro své cesty',
@@ -1004,6 +1019,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.platform': 'Nástupiště',
'reservations.meta.seat': 'Sedadlo',
'reservations.meta.checkIn': 'Check-in',
'reservations.meta.checkInUntil': 'Check-in do',
'reservations.meta.checkOut': 'Check-out',
'reservations.meta.linkAccommodation': 'Ubytování',
'reservations.meta.pickAccommodation': 'Propojit s ubytováním',
@@ -1488,6 +1504,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'day.noPlacesForHotel': 'Nejprve přidejte místa ke své cestě',
'day.allDays': 'Vše',
'day.checkIn': 'Check-in',
'day.checkInUntil': 'Do',
'day.checkOut': 'Check-out',
'day.confirmation': 'Potvrzení',
'day.editAccommodation': 'Upravit ubytování',
@@ -1763,14 +1780,26 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'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.ntfyUrl.topicLabel': 'Téma Ntfy',
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
'settings.ntfyUrl.serverLabel': 'URL serveru Ntfy (volitelné)',
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
'settings.ntfyUrl.hint': 'Zadejte své téma Ntfy pro příjem push notifikací. Pole serveru ponechte prázdné pro použití výchozího nastavení správce.',
'settings.ntfyUrl.tokenLabel': 'Přístupový token (volitelné)',
'settings.ntfyUrl.tokenHint': 'Vyžadováno pro témata chráněná heslem.',
'settings.ntfyUrl.saved': 'Nastavení Ntfy uloženo',
'settings.ntfyUrl.test': 'Otestovat',
'settings.ntfyUrl.testSuccess': 'Testovací notifikace Ntfy byla úspěšně odeslána',
'settings.ntfyUrl.testFailed': 'Testovací notifikace Ntfy selhala',
'settings.ntfyUrl.tokenCleared': 'Přístupový token byl vymazán',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
@@ -1781,6 +1810,25 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'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.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Umožňuje uživatelům nakonfigurovat vlastní témata ntfy pro přijímání push notifikací. Níže nastavte výchozí server pro předvyplnění nastavení uživatelů.',
'admin.notifications.testNtfy': 'Odeslat testovací Ntfy',
'admin.notifications.testNtfySuccess': 'Testovací Ntfy bylo úspěšně odesláno',
'admin.notifications.testNtfyFailed': 'Testovací Ntfy selhalo',
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
'admin.notifications.adminNtfyPanel.hint': 'Toto téma Ntfy se používá výhradně pro admin oznámení (např. upozornění na verze). Je nezávislé na tématech uživatelů a odesílá vždy, když je nakonfigurováno.',
'admin.notifications.adminNtfyPanel.serverLabel': 'URL serveru Ntfy',
'admin.notifications.adminNtfyPanel.serverHint': 'Slouží také jako výchozí server pro ntfy notifikace uživatelů. Ponechte prázdné pro použití ntfy.sh. Uživatelé si to mohou změnit ve vlastním nastavení.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin téma',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Přístupový token (volitelné)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Přístupový token admina byl vymazán',
'admin.notifications.adminNtfyPanel.saved': 'Nastavení admin Ntfy uloženo',
'admin.notifications.adminNtfyPanel.test': 'Odeslat testovací Ntfy',
'admin.notifications.adminNtfyPanel.testSuccess': 'Testovací Ntfy bylo úspěšně odesláno',
'admin.notifications.adminNtfyPanel.testFailed': 'Testovací Ntfy selhalo',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy odesílá vždy, když je nakonfigurováno téma',
'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',
@@ -2138,6 +2186,50 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'oauth.scope.geo:read.description': 'Vyhledávat místa, řešit URL map a zpětně geokódovat souřadnice',
'oauth.scope.weather:read.label': 'Předpovědi počasí',
'oauth.scope.weather:read.description': 'Získávat předpovědi počasí pro místa a data výletu',
// System notices
'system_notice.welcome_v1.title': 'Vítejte v TREK',
'system_notice.welcome_v1.body': 'Váš kompletní plánovač cest. Vytvářejte itineráře, sdílejte výlety s přáteli a zůstaňte organizovaní — online i offline.',
'system_notice.welcome_v1.cta_label': 'Naplánovat cestu',
'system_notice.welcome_v1.hero_alt': 'Malebné cestovní místo s rozhraním TREK',
'system_notice.welcome_v1.highlight_plan': 'Denní itineráře pro každou cestu',
'system_notice.welcome_v1.highlight_share': 'Spolupráce s cestovními partnery',
'system_notice.welcome_v1.highlight_offline': 'Funguje offline na mobilu',
'system_notice.dev_test_modal.title': '[Dev] Test notice',
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
'system_notice.pager.prev': 'Předchozí oznámení',
'system_notice.pager.next': 'Další oznámení',
'system_notice.pager.counter': '{current} / {total}',
'system_notice.pager.goto': 'Přejít na oznámení {n}',
'system_notice.pager.position': 'Oznámení {current} z {total}',
// System notices — 3.0.0 upgrade
'system_notice.v3_photos.title': 'Fotografie přesunuty ve verzi 3.0',
'system_notice.v3_photos.body': '**Fotografie** v Plánovacím nástroji byly odebrány. Vaše fotografie jsou v bezpečí — TREK nikdy neupravoval vaši knihovnu Immich nebo Synology.\n\nFotografie jsou nyní dostupné v doplňku **Journey**. Journey je volitelný — pokud ještě není k dispozici, požádejte svého správce, aby ho aktivoval v Admin → Doplňky.',
'system_notice.v3_journey.title': 'Poznejte Journey — cest. denník',
'system_notice.v3_journey.body': 'Dokumentujte své cesty jako bohaté příběhy s časovnicemi, galeriemi fotek a interaktivními mapami.',
'system_notice.v3_journey.cta_label': 'Otevřít Journey',
'system_notice.v3_journey.highlight_timeline': 'Denní časovnice a galerie',
'system_notice.v3_journey.highlight_photos': 'Import z Immich nebo Synology',
'system_notice.v3_journey.highlight_share': 'Sdílet veřejně — bez přihlašování',
'system_notice.v3_journey.highlight_export': 'Export jako PDF fotokniha',
'system_notice.v3_features.title': 'Další novinky ve verzi 3.0',
'system_notice.v3_features.body': 'Několik dalších změn, které stojí za pozornost.',
'system_notice.v3_features.highlight_dashboard': 'Předesign dashboardu mobile-first',
'system_notice.v3_features.highlight_offline': 'Plný offline režim jako PWA',
'system_notice.v3_features.highlight_search': 'Autodoplňování vyhledávání míst',
'system_notice.v3_features.highlight_import': 'Import míst ze souborů KMZ/KML',
// System notices — MCP OAuth 2.1 upgrade
'system_notice.v3_mcp.title': 'MCP: aktualizace OAuth 2.1',
'system_notice.v3_mcp.body': 'Integrace MCP byla kompletně přepracována. OAuth 2.1 je nyní doporučenou metodou ověřování. Statické tokeny (trek_…) jsou zastaralé a budou v budoucí verzi odstraněny.',
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 doporučeno (mcp-remote)',
'system_notice.v3_mcp.highlight_scopes': '24 jemnozrnných oprávnění',
'system_notice.v3_mcp.highlight_deprecated': 'Statické tokeny trek_ zastaralé',
'system_notice.v3_mcp.highlight_tools': 'Rozšířená sada nástrojů a promptů',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Osobní slovo ode mě',
'system_notice.v3_thankyou.body': 'Než budete pokračovat — chci se na chvíli zastavit.\n\nTREK začal jako vedlejší projekt, který jsem vytvořil pro své vlastní cesty. Nikdy jsem si nepředstavoval, že vyroste v něco, čemu 4 000 z vás důvěřuje při plánování svých dobrodružství. Každou hvězdičku, každý issue, každý požadavek na funkci — všechny čtu a právě ony mě drží při životě během pozdních nocí mezi prací na plný úvazek a univerzitou.\n\nChci, abyste věděli: TREK bude vždy open source, vždy self-hosted, vždy váš. Žádné sledování, žádná předplatná, žádné háčky. Jen nástroj vytvořený někým, kdo miluje cestování stejně jako vy.\n\nZvláštní poděkování patří [jubnl](https://github.com/jubnl) — stal ses neuvěřitelným spolupracovníkem. Tolik z toho, co dělá verzi 3.0 skvělou, nese tvůj rukopis. Děkuji, že jsi věřil tomuto projektu, když byl ještě v plenkách.\n\nA každému z vás, kdo nahlásil chybu, přeložil řetězec, sdílel TREK s přítelem nebo ho jednoduše použil k plánování cesty — **děkuji**. Vy jste důvod, proč tohle existuje.\n\nNa mnoho dalších dobrodružství společně.\n\n— Maurice\n\n---\n\n[Přidej se ke komunitě na Discordu](https://discord.gg/7Q6M6jDwzf)\n\nPokud ti TREK zlepšuje cestování, [malá káva](https://ko-fi.com/mauriceboe) vždy pomůže udržet světla rozsvícená.',
}
export default cs
+102 -1
View File
@@ -4,6 +4,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'common.showMore': 'Mehr anzeigen',
'common.showLess': 'Weniger anzeigen',
'common.cancel': 'Abbrechen',
'common.clear': 'Löschen',
'common.delete': 'Löschen',
'common.edit': 'Bearbeiten',
'common.add': 'Hinzufügen',
@@ -175,6 +176,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.temperature': 'Temperatureinheit',
'settings.timeFormat': 'Zeitformat',
'settings.routeCalculation': 'Routenberechnung',
'settings.bookingLabels': 'Orts-Labels auf Buchungsrouten',
'settings.bookingLabelsHint': 'Zeigt Bahnhofs-/Flughafennamen auf der Karte. Wenn aus, wird nur das Icon angezeigt.',
'settings.blurBookingCodes': 'Buchungscodes verbergen',
'settings.notifications': 'Benachrichtigungen',
'settings.notifyTripInvite': 'Trip-Einladungen',
@@ -551,7 +554,21 @@ 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.collab.chat.title': 'Chat',
'admin.collab.chat.subtitle': 'Echtzeit-Nachrichten für die Reiseplanung',
'admin.collab.notes.title': 'Notizen',
'admin.collab.notes.subtitle': 'Gemeinsame Notizen und Dokumente',
'admin.collab.polls.title': 'Umfragen',
'admin.collab.polls.subtitle': 'Gruppen-Umfragen und Abstimmungen',
'admin.collab.whatsnext.title': 'Was kommt als Nächstes',
'admin.collab.whatsnext.subtitle': 'Aktivitätsvorschläge und nächste Schritte',
'admin.tabs.config': 'Personalisierung',
'admin.tabs.defaults': 'Benutzer-Standards',
'admin.defaultSettings.title': 'Standard-Benutzereinstellungen',
'admin.defaultSettings.description': 'Instanzweite Standards festlegen. Benutzer, die eine Einstellung nicht geändert haben, sehen diese Werte. Eigene Änderungen haben immer Vorrang.',
'admin.defaultSettings.saved': 'Standard gespeichert',
'admin.defaultSettings.reset': 'Auf eingebauten Standard zurücksetzen',
'admin.defaultSettings.resetToBuiltIn': 'zurücksetzen',
'admin.tabs.templates': 'Packvorlagen',
'admin.packingTemplates.title': 'Packvorlagen',
'admin.packingTemplates.subtitle': 'Wiederverwendbare Packlisten für deine Reisen erstellen',
@@ -1002,10 +1019,18 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.flightNumber': 'Flugnr.',
'reservations.meta.from': 'Von',
'reservations.meta.to': 'Nach',
'reservations.needsReview': 'Prüfen',
'reservations.needsReviewHint': 'Flughafen konnte nicht automatisch erkannt werden — bitte Ort bestätigen.',
'reservations.searchLocation': 'Bahnhof, Hafen, Adresse suchen…',
'airport.searchPlaceholder': 'Flughafencode oder Stadt (z. B. FRA)',
'map.connections': 'Verbindungen',
'map.showConnections': 'Buchungsrouten anzeigen',
'map.hideConnections': 'Buchungsrouten ausblenden',
'reservations.meta.trainNumber': 'Zugnr.',
'reservations.meta.platform': 'Gleis',
'reservations.meta.seat': 'Sitzplatz',
'reservations.meta.checkIn': 'Check-in',
'reservations.meta.checkInUntil': 'Check-in bis',
'reservations.meta.checkOut': 'Check-out',
'reservations.meta.linkAccommodation': 'Unterkunft',
'reservations.meta.pickAccommodation': 'Mit Unterkunft verknüpfen',
@@ -1490,6 +1515,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'day.noPlacesForHotel': 'Füge zuerst Orte zu deiner Reise hinzu',
'day.allDays': 'Alle',
'day.checkIn': 'Check-in',
'day.checkInUntil': 'Bis',
'day.checkOut': 'Check-out',
'day.confirmation': 'Bestätigung',
'day.editAccommodation': 'Unterkunft bearbeiten',
@@ -1766,14 +1792,26 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'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.ntfyUrl.topicLabel': 'Ntfy-Thema',
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
'settings.ntfyUrl.serverLabel': 'Ntfy-Server-URL (optional)',
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
'settings.ntfyUrl.hint': 'Gib dein Ntfy-Thema ein, um Push-Benachrichtigungen zu erhalten. Lasse das Server-Feld leer, um den vom Administrator konfigurierten Standard zu verwenden.',
'settings.ntfyUrl.tokenLabel': 'Zugriffstoken (optional)',
'settings.ntfyUrl.tokenHint': 'Erforderlich für passwortgeschützte Themen.',
'settings.ntfyUrl.saved': 'Ntfy-Einstellungen gespeichert',
'settings.ntfyUrl.test': 'Testen',
'settings.ntfyUrl.testSuccess': 'Test-Ntfy-Benachrichtigung erfolgreich gesendet',
'settings.ntfyUrl.testFailed': 'Test-Ntfy-Benachrichtigung fehlgeschlagen',
'settings.ntfyUrl.tokenCleared': 'Zugriffstoken gelöscht',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
@@ -1784,6 +1822,25 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'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.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Erlaubt Benutzern, eigene ntfy-Themen für Push-Benachrichtigungen zu konfigurieren. Legen Sie unten den Standardserver fest, um die Benutzereinstellungen vorauszufüllen.',
'admin.notifications.testNtfy': 'Test-Ntfy senden',
'admin.notifications.testNtfySuccess': 'Test-Ntfy erfolgreich gesendet',
'admin.notifications.testNtfyFailed': 'Test-Ntfy fehlgeschlagen',
'admin.notifications.adminNtfyPanel.title': 'Admin-Ntfy',
'admin.notifications.adminNtfyPanel.hint': 'Dieses Ntfy-Thema wird ausschließlich für Admin-Benachrichtigungen verwendet (z. B. Versions-Updates). Es ist unabhängig von Benutzer-Themen und sendet immer, wenn es konfiguriert ist.',
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy-Server-URL',
'admin.notifications.adminNtfyPanel.serverHint': 'Wird auch als Standardserver für Benutzer-ntfy-Benachrichtigungen verwendet. Leer lassen für ntfy.sh. Benutzer können dies in ihren eigenen Einstellungen überschreiben.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin-Thema',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Zugriffstoken (optional)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Admin-Zugriffstoken gelöscht',
'admin.notifications.adminNtfyPanel.saved': 'Admin-Ntfy-Einstellungen gespeichert',
'admin.notifications.adminNtfyPanel.test': 'Test-Ntfy senden',
'admin.notifications.adminNtfyPanel.testSuccess': 'Test-Ntfy erfolgreich gesendet',
'admin.notifications.adminNtfyPanel.testFailed': 'Test-Ntfy fehlgeschlagen',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin-Ntfy sendet immer, wenn ein Thema 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',
@@ -2138,6 +2195,50 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'oauth.scope.geo:read.description': 'Orte suchen, Karten-URLs auflösen und Koordinaten rückwärts geokodieren',
'oauth.scope.weather:read.label': 'Wettervorhersagen',
'oauth.scope.weather:read.description': 'Wettervorhersagen für Reiseorte und -daten abrufen',
// System notices
'system_notice.welcome_v1.title': 'Willkommen bei TREK',
'system_notice.welcome_v1.body': 'Dein All-in-one-Reiseplaner. Erstelle Reisepläne, teile sie mit Freunden und bleib organisiert online und offline.',
'system_notice.welcome_v1.cta_label': 'Reise planen',
'system_notice.welcome_v1.hero_alt': 'Malerisches Reiseziel mit TREK-Planungs-UI',
'system_notice.welcome_v1.highlight_plan': 'Tagesweise Reisepläne für jede Reise',
'system_notice.welcome_v1.highlight_share': 'Gemeinsam mit Reisepartnern planen',
'system_notice.welcome_v1.highlight_offline': 'Funktioniert offline auf dem Handy',
'system_notice.dev_test_modal.title': '[Dev] Test notice',
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
'system_notice.pager.prev': 'Vorherige Meldung',
'system_notice.pager.next': 'Nächste Meldung',
'system_notice.pager.counter': '{current} / {total}',
'system_notice.pager.goto': 'Zu Meldung {n}',
'system_notice.pager.position': 'Meldung {current} von {total}',
// System notices — 3.0.0 upgrade
'system_notice.v3_photos.title': 'Fotos wurden in 3.0 verschoben',
'system_notice.v3_photos.body': '**Fotos** im Trip-Planer wurden entfernt. Deine Fotos sind sicher — TREK hat deine Immich- oder Synology-Bibliothek nie verändert.\n\nFotos befinden sich jetzt im **Journey**-Addon. Journey ist optional — falls es noch nicht verfügbar ist, bitte deinen Admin, es unter Admin → Addons zu aktivieren.',
'system_notice.v3_journey.title': 'Neu: Journey — dein Reisetagebuch',
'system_notice.v3_journey.body': 'Dokumentiere deine Reisen als lebendige Geschichten mit Zeitachsen, Fotogalerien und interaktiven Karten.',
'system_notice.v3_journey.cta_label': 'Journey öffnen',
'system_notice.v3_journey.highlight_timeline': 'Zeitleiste und Galerie',
'system_notice.v3_journey.highlight_photos': 'Import von Immich oder Synology',
'system_notice.v3_journey.highlight_share': 'Öffentlich teilen — kein Login nötig',
'system_notice.v3_journey.highlight_export': 'Als PDF-Fotobuch exportieren',
'system_notice.v3_features.title': 'Weitere Highlights in 3.0',
'system_notice.v3_features.body': 'Ein paar weitere Neuerungen in diesem Release.',
'system_notice.v3_features.highlight_dashboard': 'Mobile-first Dashboard-Redesign',
'system_notice.v3_features.highlight_offline': 'Vollständiger Offline-Modus als PWA',
'system_notice.v3_features.highlight_search': 'Echtzeit-Autovervollständigung für Orte',
'system_notice.v3_features.highlight_import': 'Orte aus KMZ/KML-Dateien importieren',
// System notices — MCP OAuth 2.1 upgrade
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1-Upgrade',
'system_notice.v3_mcp.body': 'Die MCP-Integration wurde vollständig überarbeitet. OAuth 2.1 ist jetzt die empfohlene Authentifizierungsmethode. Statische Tokens (trek_…) sind veraltet und werden in einer zukünftigen Version entfernt.',
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 empfohlen (mcp-remote)',
'system_notice.v3_mcp.highlight_scopes': '24 feingranulare Berechtigungs-Scopes',
'system_notice.v3_mcp.highlight_deprecated': 'Statische trek_-Tokens veraltet',
'system_notice.v3_mcp.highlight_tools': 'Erweitertes Toolset & Prompts',
// System notices — persönlicher Dank
'system_notice.v3_thankyou.title': 'Ein persönliches Wort von mir',
'system_notice.v3_thankyou.body': 'Bevor du weiterklickst — einen Moment noch.\n\nTREK hat als Nebenprojekt für meine eigenen Reisen angefangen. Ich hätte nie gedacht, dass es jemals so weit kommt, dass 4.000 von euch damit ihre Abenteuer planen. Jeder Stern, jedes Issue, jeder Feature-Wunsch — ich lese sie alle, und sie halten mich am Laufen durch die späten Nächte zwischen Vollzeitjob und Studium.\n\nEins will ich euch sagen: TREK wird immer Open Source bleiben, immer self-hosted, immer eures. Kein Tracking, keine Abos, keine versteckten Haken. Einfach ein Tool, gebaut von jemandem, der das Reisen genauso liebt wie ihr.\n\nBesonderer Dank an [jubnl](https://github.com/jubnl) — du bist ein unglaublicher Mitstreiter geworden. So vieles, was 3.0 großartig macht, trägt deine Handschrift. Danke, dass du an dieses Projekt geglaubt hast, als es noch holprig war.\n\nUnd an jeden einzelnen von euch, der einen Bug gemeldet, einen String übersetzt, TREK mit Freunden geteilt oder einfach damit eine Reise geplant hat — **danke**. Ihr seid der Grund, warum es das hier gibt.\n\nAuf viele weitere Abenteuer zusammen.\n\n— Maurice\n\n---\n\n[Tritt der Community auf Discord bei](https://discord.gg/7Q6M6jDwzf)\n\nWenn TREK deine Reisen besser macht, hält ein [kleiner Kaffee](https://ko-fi.com/mauriceboe) die Lichter an.',
}
export default de
+104 -1
View File
@@ -4,6 +4,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'common.showMore': 'Show more',
'common.showLess': 'Show less',
'common.cancel': 'Cancel',
'common.clear': 'Clear',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.add': 'Add',
@@ -175,6 +176,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.temperature': 'Temperature Unit',
'settings.timeFormat': 'Time Format',
'settings.routeCalculation': 'Route Calculation',
'settings.bookingLabels': 'Booking route labels',
'settings.bookingLabelsHint': 'Show station / airport names on the map. When off, only the icon is shown.',
'settings.blurBookingCodes': 'Blur Booking Codes',
'settings.notifications': 'Notifications',
'settings.notifyTripInvite': 'Trip invitations',
@@ -189,25 +192,42 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.ntfy': 'Ntfy',
'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',
'settings.ntfyUrl.topicLabel': 'Ntfy Topic',
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
'settings.ntfyUrl.serverLabel': 'Ntfy Server URL (optional)',
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
'settings.ntfyUrl.hint': 'Enter your ntfy topic to receive push notifications. Leave server blank to use the default configured by your admin.',
'settings.ntfyUrl.tokenLabel': 'Access Token (optional)',
'settings.ntfyUrl.tokenHint': 'Required for password-protected topics.',
'settings.ntfyUrl.saved': 'Ntfy settings saved',
'settings.ntfyUrl.test': 'Test',
'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully',
'settings.ntfyUrl.testFailed': 'Test ntfy notification failed',
'settings.ntfyUrl.tokenCleared': 'Access token cleared',
'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.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Allow users to configure their own ntfy topics for push notifications. Set the default server below to pre-fill user settings.',
'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.testNtfy': 'Send test ntfy',
'admin.notifications.testNtfySuccess': 'Test ntfy sent successfully',
'admin.notifications.testNtfyFailed': 'Test ntfy failed',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
@@ -218,6 +238,20 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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.adminNtfyPanel.title': 'Admin Ntfy',
'admin.notifications.adminNtfyPanel.hint': 'This ntfy topic is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user topics and always fires when configured.',
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL',
'admin.notifications.adminNtfyPanel.serverHint': 'Also used as the default server for user ntfy notifications. Leave blank to default to ntfy.sh. Users can override this in their own settings.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Topic',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Access Token (optional)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Admin access token cleared',
'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy settings saved',
'admin.notifications.adminNtfyPanel.test': 'Send test ntfy',
'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy sent successfully',
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic 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.',
@@ -576,7 +610,21 @@ 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.collab.chat.title': 'Chat',
'admin.collab.chat.subtitle': 'Real-time messaging for trip collaboration',
'admin.collab.notes.title': 'Notes',
'admin.collab.notes.subtitle': 'Shared notes and documents',
'admin.collab.polls.title': 'Polls',
'admin.collab.polls.subtitle': 'Group polls and voting',
'admin.collab.whatsnext.title': "What's Next",
'admin.collab.whatsnext.subtitle': 'Activity suggestions and next steps',
'admin.tabs.config': 'Personalization',
'admin.tabs.defaults': 'User Defaults',
'admin.defaultSettings.title': 'Default User Settings',
'admin.defaultSettings.description': 'Set instance-wide defaults. Users who have not changed a setting will see these values. Their own changes always take priority.',
'admin.defaultSettings.saved': 'Default saved',
'admin.defaultSettings.reset': 'Reset to built-in default',
'admin.defaultSettings.resetToBuiltIn': 'reset',
'admin.tabs.templates': 'Packing Templates',
'admin.packingTemplates.title': 'Packing Templates',
'admin.packingTemplates.subtitle': 'Create reusable packing lists for your trips',
@@ -1024,10 +1072,18 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.flightNumber': 'Flight No.',
'reservations.meta.from': 'From',
'reservations.meta.to': 'To',
'reservations.needsReview': 'Review',
'reservations.needsReviewHint': 'Airport could not be matched automatically — please confirm the location.',
'reservations.searchLocation': 'Search station, port, address…',
'airport.searchPlaceholder': 'Airport code or city (e.g. FRA)',
'map.connections': 'Connections',
'map.showConnections': 'Show booking routes',
'map.hideConnections': 'Hide booking routes',
'reservations.meta.trainNumber': 'Train No.',
'reservations.meta.platform': 'Platform',
'reservations.meta.seat': 'Seat',
'reservations.meta.checkIn': 'Check-in',
'reservations.meta.checkInUntil': 'Check-in until',
'reservations.meta.checkOut': 'Check-out',
'reservations.meta.linkAccommodation': 'Accommodation',
'reservations.meta.pickAccommodation': 'Link to accommodation',
@@ -1512,6 +1568,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'day.noPlacesForHotel': 'Add places to your trip first',
'day.allDays': 'All',
'day.checkIn': 'Check-in',
'day.checkInUntil': 'Until',
'day.checkOut': 'Check-out',
'day.confirmation': 'Confirmation',
'day.editAccommodation': 'Edit accommodation',
@@ -1910,6 +1967,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.noEntriesHint': 'Add a trip to get started with skeleton entries',
'journey.detail.noPhotos': 'No photos yet',
'journey.detail.noPhotosHint': 'Upload photos to entries or browse your Immich/Synology library',
'journey.detail.journeyTab': 'Journey',
'journey.detail.journeyStats': 'Journey Stats',
'journey.detail.syncedTrips': 'Synced Trips',
'journey.detail.noTripsLinked': 'No trips linked yet',
@@ -2174,6 +2232,51 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'oauth.scope.geo:read.description': 'Search locations, resolve map URLs, and reverse geocode coordinates',
'oauth.scope.weather:read.label': 'Weather forecasts',
'oauth.scope.weather:read.description': 'Fetch weather forecasts for trip locations and dates',
// System notices — 3.0.0 upgrade
'system_notice.v3_photos.title': 'Photos have moved in 3.0',
'system_notice.v3_photos.body': '**Photos** in the Trip Planner have been removed. Your photos are safe — TREK never modified your Immich or Synology library.\n\nPhotos now live in the **Journey** addon. Journey is optional — if it is not yet available, ask your admin to enable it under Admin → Addons.',
'system_notice.v3_journey.title': 'Meet Journey — travel journal',
'system_notice.v3_journey.body': 'Document your trips as rich travel stories with timelines, photo galleries, and interactive maps.',
'system_notice.v3_journey.cta_label': 'Open Journey',
'system_notice.v3_journey.highlight_timeline': 'Day-by-day timeline & gallery',
'system_notice.v3_journey.highlight_photos': 'Import from Immich or Synology',
'system_notice.v3_journey.highlight_share': 'Share publicly — no login needed',
'system_notice.v3_journey.highlight_export': 'Export as a PDF photo book',
'system_notice.v3_features.title': 'More highlights in 3.0',
'system_notice.v3_features.body': 'A few more things worth knowing about this release.',
'system_notice.v3_features.highlight_dashboard': 'Mobile-first dashboard redesign',
'system_notice.v3_features.highlight_offline': 'Full offline mode as a PWA',
'system_notice.v3_features.highlight_search': 'Real-time place search autocomplete',
'system_notice.v3_features.highlight_import': 'Import places from KMZ/KML files',
// System notices — MCP OAuth 2.1 upgrade
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1 upgrade',
'system_notice.v3_mcp.body': 'The MCP integration has been fully overhauled. OAuth 2.1 is now the recommended auth method. Legacy static tokens (trek_\u2026) are deprecated and will be removed in a future release.',
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recommended (mcp-remote)',
'system_notice.v3_mcp.highlight_scopes': '24 fine-grained permission scopes',
'system_notice.v3_mcp.highlight_deprecated': 'Static trek_ tokens deprecated',
'system_notice.v3_mcp.highlight_tools': 'Expanded toolset & prompts',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'A personal note from me',
'system_notice.v3_thankyou.body': 'Before you go — I want to take a moment.\n\nTREK started as a side project I built for my own trips. I never imagined it would grow into something that 4,000 of you now trust to plan your adventures. Every star, every issue, every feature request — I read them all, and they keep me going through late nights between a full-time job and university.\n\nI want you to know: TREK will always be open source, always self-hosted, always yours. No tracking, no subscriptions, no strings attached. Just a tool built by someone who loves traveling as much as you do.\n\nSpecial thanks to [jubnl](https://github.com/jubnl) — you have become an incredible collaborator. So much of what makes 3.0 great carries your fingerprints. Thank you for believing in this project when it was still rough around the edges.\n\nAnd to every single one of you who filed a bug, translated a string, shared TREK with a friend, or simply used it to plan a trip — **thank you**. You are the reason this exists.\n\nHere\'s to many more adventures together.\n\n— Maurice\n\n---\n\n[Join the community on Discord](https://discord.gg/7Q6M6jDwzf)\n\nIf TREK makes your travels better, a [small coffee](https://ko-fi.com/mauriceboe) always keeps the lights on.',
// System notices — onboarding
'system_notice.welcome_v1.title': 'Welcome to TREK',
'system_notice.welcome_v1.body': 'Your all-in-one travel planner. Build itineraries, share trips with friends, and stay organized — online or offline.',
'system_notice.welcome_v1.cta_label': 'Plan a trip',
'system_notice.welcome_v1.hero_alt': 'A scenic travel destination with TREK planning UI overlay',
'system_notice.welcome_v1.highlight_plan': 'Day-by-day itineraries for any trip',
'system_notice.welcome_v1.highlight_share': 'Collaborate with travel partners',
'system_notice.welcome_v1.highlight_offline': 'Works offline on mobile',
'system_notice.dev_test_modal.title': '[Dev] Test notice',
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
'system_notice.pager.prev': 'Previous notice',
'system_notice.pager.next': 'Next notice',
'system_notice.pager.counter': '{current} / {total}',
'system_notice.pager.goto': 'Go to notice {n}',
'system_notice.pager.position': 'Notice {current} of {total}',
}
export default en
+93 -1
View File
@@ -4,6 +4,7 @@ const es: Record<string, string> = {
'common.showMore': 'Ver más',
'common.showLess': 'Ver menos',
'common.cancel': 'Cancelar',
'common.clear': 'Borrar',
'common.delete': 'Eliminar',
'common.edit': 'Editar',
'common.add': 'Añadir',
@@ -542,7 +543,21 @@ 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.collab.chat.title': 'Chat',
'admin.collab.chat.subtitle': 'Mensajería en tiempo real para la colaboración',
'admin.collab.notes.title': 'Notas',
'admin.collab.notes.subtitle': 'Notas y documentos compartidos',
'admin.collab.polls.title': 'Encuestas',
'admin.collab.polls.subtitle': 'Encuestas y votaciones grupales',
'admin.collab.whatsnext.title': 'Qué sigue',
'admin.collab.whatsnext.subtitle': 'Sugerencias de actividades y próximos pasos',
'admin.tabs.config': 'Personalización',
'admin.tabs.defaults': 'Valores predeterminados',
'admin.defaultSettings.title': 'Configuración predeterminada de usuarios',
'admin.defaultSettings.description': 'Establece valores predeterminados para toda la instancia. Los usuarios que no hayan cambiado una opción verán estos valores. Sus propios cambios siempre tienen prioridad.',
'admin.defaultSettings.saved': 'Predeterminado guardado',
'admin.defaultSettings.reset': 'Restaurar al valor predeterminado integrado',
'admin.defaultSettings.resetToBuiltIn': 'restaurar',
'admin.tabs.templates': 'Plantillas de equipaje',
'admin.packingTemplates.title': 'Plantillas de equipaje',
'admin.packingTemplates.subtitle': 'Crear listas de equipaje reutilizables para tus viajes',
@@ -1439,6 +1454,7 @@ const es: Record<string, string> = {
'day.noPlacesForHotel': 'Añade primero lugares al viaje',
'day.allDays': 'Todos',
'day.checkIn': 'Registro de entrada',
'day.checkInUntil': 'Hasta',
'day.checkOut': 'Registro de salida',
'day.confirmation': 'Confirmación',
'day.editAccommodation': 'Editar alojamiento',
@@ -1606,6 +1622,7 @@ const es: Record<string, string> = {
'reservations.meta.platform': 'Andén',
'reservations.meta.seat': 'Asiento',
'reservations.meta.checkIn': 'Registro de entrada',
'reservations.meta.checkInUntil': 'Check-in hasta',
'reservations.meta.checkOut': 'Registro de salida',
'reservations.meta.linkAccommodation': 'Alojamiento',
'reservations.meta.pickAccommodation': 'Vincular con alojamiento',
@@ -1768,14 +1785,26 @@ const es: Record<string, string> = {
'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.ntfyUrl.topicLabel': 'Tema de Ntfy',
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
'settings.ntfyUrl.serverLabel': 'URL del servidor Ntfy (opcional)',
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
'settings.ntfyUrl.hint': 'Introduce tu tema de Ntfy para recibir notificaciones push. Deja el servidor en blanco para usar el predeterminado configurado por tu administrador.',
'settings.ntfyUrl.tokenLabel': 'Token de acceso (opcional)',
'settings.ntfyUrl.tokenHint': 'Requerido para temas protegidos con contraseña.',
'settings.ntfyUrl.saved': 'Configuración de Ntfy guardada',
'settings.ntfyUrl.test': 'Probar',
'settings.ntfyUrl.testSuccess': 'Notificación de prueba de Ntfy enviada correctamente',
'settings.ntfyUrl.testFailed': 'Error en la notificación de prueba de Ntfy',
'settings.ntfyUrl.tokenCleared': 'Token de acceso eliminado',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
@@ -1786,6 +1815,25 @@ const es: Record<string, string> = {
'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.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Permite a los usuarios configurar sus propios temas ntfy para notificaciones push. Establece el servidor predeterminado a continuación para rellenar automáticamente los ajustes del usuario.',
'admin.notifications.testNtfy': 'Enviar Ntfy de prueba',
'admin.notifications.testNtfySuccess': 'Ntfy de prueba enviado correctamente',
'admin.notifications.testNtfyFailed': 'Error al enviar el Ntfy de prueba',
'admin.notifications.adminNtfyPanel.title': 'Ntfy de admin',
'admin.notifications.adminNtfyPanel.hint': 'Este tema Ntfy se usa exclusivamente para notificaciones de admin (ej. alertas de versión). Es independiente de los temas por usuario y siempre se activa cuando está configurado.',
'admin.notifications.adminNtfyPanel.serverLabel': 'URL del servidor Ntfy',
'admin.notifications.adminNtfyPanel.serverHint': 'También se usa como servidor predeterminado para las notificaciones ntfy de los usuarios. Déjalo en blanco para usar ntfy.sh. Los usuarios pueden cambiarlo en sus propios ajustes.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Tema de admin',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Token de acceso (opcional)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Token de acceso de admin eliminado',
'admin.notifications.adminNtfyPanel.saved': 'Configuración de Ntfy de admin guardada',
'admin.notifications.adminNtfyPanel.test': 'Enviar Ntfy de prueba',
'admin.notifications.adminNtfyPanel.testSuccess': 'Ntfy de prueba enviado correctamente',
'admin.notifications.adminNtfyPanel.testFailed': 'Error al enviar el Ntfy de prueba',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'El Ntfy de admin siempre se activa cuando hay un tema configurado',
'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',
@@ -2140,6 +2188,50 @@ const es: Record<string, string> = {
'oauth.scope.geo:read.description': 'Buscar lugares, resolver URLs de mapa y geocodificar coordenadas',
'oauth.scope.weather:read.label': 'Previsiones meteorológicas',
'oauth.scope.weather:read.description': 'Obtener previsiones meteorológicas para lugares y fechas del viaje',
// System notices
'system_notice.welcome_v1.title': 'Bienvenido a TREK',
'system_notice.welcome_v1.body': 'Tu planificador de viajes todo en uno. Crea itinerarios, comparte viajes con amigos y mantente organizado, online o sin conexión.',
'system_notice.welcome_v1.cta_label': 'Planificar un viaje',
'system_notice.welcome_v1.hero_alt': 'Destino de viaje pintoresco con la interfaz de TREK',
'system_notice.welcome_v1.highlight_plan': 'Itinerarios día a día para cualquier viaje',
'system_notice.welcome_v1.highlight_share': 'Colabora con tus compañeros de viaje',
'system_notice.welcome_v1.highlight_offline': 'Funciona sin conexión en móvil',
'system_notice.dev_test_modal.title': '[Dev] Test notice',
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
'system_notice.pager.prev': 'Aviso anterior',
'system_notice.pager.next': 'Siguiente aviso',
'system_notice.pager.counter': '{current} / {total}',
'system_notice.pager.goto': 'Ir al aviso {n}',
'system_notice.pager.position': 'Aviso {current} de {total}',
// System notices — 3.0.0 upgrade
'system_notice.v3_photos.title': 'Las fotos se han movido en 3.0',
'system_notice.v3_photos.body': '**Fotos** en el Planificador de Viajes han sido eliminadas. Tus fotos están a salvo — TREK nunca modificó tu biblioteca de Immich o Synology.\n\nLas fotos ahora viven en el addon **Journey**. Journey es opcional — si aún no está disponible, pide a tu admin que lo active en Admin → Complementos.',
'system_notice.v3_journey.title': 'Conoce Journey — diario de viaje',
'system_notice.v3_journey.body': 'Documenta tus viajes como historias enriquecidas con cronologías, galerías de fotos y mapas interactivos.',
'system_notice.v3_journey.cta_label': 'Abrir Journey',
'system_notice.v3_journey.highlight_timeline': 'Cronología y galería por día',
'system_notice.v3_journey.highlight_photos': 'Importar desde Immich o Synology',
'system_notice.v3_journey.highlight_share': 'Compartir públicamente — sin inicio de sesión',
'system_notice.v3_journey.highlight_export': 'Exportar como libro de fotos PDF',
'system_notice.v3_features.title': 'Más novedades en 3.0',
'system_notice.v3_features.body': 'Otras cosas que vale la pena conocer de esta versión.',
'system_notice.v3_features.highlight_dashboard': 'Rediseño del panel mobile-first',
'system_notice.v3_features.highlight_offline': 'Modo sin conexión completo como PWA',
'system_notice.v3_features.highlight_search': 'Autocompletado de lugares en tiempo real',
'system_notice.v3_features.highlight_import': 'Importar lugares desde archivos KMZ/KML',
// System notices — MCP OAuth 2.1 upgrade
'system_notice.v3_mcp.title': 'MCP: actualización OAuth 2.1',
'system_notice.v3_mcp.body': 'La integración MCP ha sido completamente renovada. OAuth 2.1 es ahora el método de autenticación recomendado. Los tokens estáticos (trek_…) están obsoletos y se eliminarán en una versión futura.',
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recomendado (mcp-remote)',
'system_notice.v3_mcp.highlight_scopes': '24 ámbitos de permisos granulares',
'system_notice.v3_mcp.highlight_deprecated': 'Tokens estáticos trek_ obsoletos',
'system_notice.v3_mcp.highlight_tools': 'Herramientas y prompts ampliados',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Una nota personal de mi parte',
'system_notice.v3_thankyou.body': 'Antes de seguir — quiero tomarme un momento.\n\nTREK empezó como un proyecto personal que construí para mis propios viajes. Nunca imaginé que crecería hasta convertirse en algo en lo que 4.000 de vosotros confían para planificar sus aventuras. Cada estrella, cada issue, cada solicitud de funcionalidad — los leo todos, y son lo que me mantiene en pie durante las noches largas entre un trabajo a jornada completa y la universidad.\n\nQuiero que sepáis: TREK siempre será open source, siempre self-hosted, siempre vuestro. Sin rastreo, sin suscripciones, sin letra pequeña. Solo una herramienta hecha por alguien que ama viajar tanto como vosotros.\n\nUn agradecimiento especial a [jubnl](https://github.com/jubnl) — te has convertido en un colaborador increíble. Mucho de lo que hace grande la versión 3.0 lleva tu huella. Gracias por creer en este proyecto cuando todavía era un borrador.\n\nY a cada uno de vosotros que reportó un bug, tradujo un texto, compartió TREK con un amigo o simplemente lo usó para planificar un viaje — **gracias**. Vosotros sois la razón de que esto exista.\n\nPor muchas más aventuras juntos.\n\n— Maurice\n\n---\n\n[Únete a la comunidad en Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK mejora tus viajes, un [pequeño café](https://ko-fi.com/mauriceboe) siempre mantiene las luces encendidas.',
}
export default es
+93 -1
View File
@@ -4,6 +4,7 @@ const fr: Record<string, string> = {
'common.showMore': 'Voir plus',
'common.showLess': 'Voir moins',
'common.cancel': 'Annuler',
'common.clear': 'Effacer',
'common.delete': 'Supprimer',
'common.edit': 'Modifier',
'common.add': 'Ajouter',
@@ -546,7 +547,21 @@ 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.collab.chat.title': 'Chat',
'admin.collab.chat.subtitle': 'Messagerie en temps réel pour la collaboration',
'admin.collab.notes.title': 'Notes',
'admin.collab.notes.subtitle': 'Notes et documents partagés',
'admin.collab.polls.title': 'Sondages',
'admin.collab.polls.subtitle': 'Sondages et votes de groupe',
'admin.collab.whatsnext.title': 'Et ensuite',
'admin.collab.whatsnext.subtitle': "Suggestions d'activités et prochaines étapes",
'admin.tabs.config': 'Personnalisation',
'admin.tabs.defaults': 'Valeurs par défaut',
'admin.defaultSettings.title': 'Paramètres utilisateur par défaut',
'admin.defaultSettings.description': "Définissez des valeurs par défaut pour toute l'instance. Les utilisateurs n'ayant pas modifié un paramètre verront ces valeurs. Leurs propres modifications ont toujours la priorité.",
'admin.defaultSettings.saved': 'Valeur par défaut enregistrée',
'admin.defaultSettings.reset': 'Réinitialiser à la valeur par défaut intégrée',
'admin.defaultSettings.resetToBuiltIn': 'réinitialiser',
'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',
@@ -1002,6 +1017,7 @@ const fr: Record<string, string> = {
'reservations.meta.platform': 'Quai',
'reservations.meta.seat': 'Place',
'reservations.meta.checkIn': 'Arrivée',
'reservations.meta.checkInUntil': "Check-in jusqu'à",
'reservations.meta.checkOut': 'Départ',
'reservations.meta.linkAccommodation': 'Hébergement',
'reservations.meta.pickAccommodation': 'Lier à un hébergement',
@@ -1486,6 +1502,7 @@ const fr: Record<string, string> = {
'day.noPlacesForHotel': 'Ajoutez d\'abord des lieux à votre voyage',
'day.allDays': 'Tous',
'day.checkIn': 'Arrivée',
'day.checkInUntil': "Jusqu'à",
'day.checkOut': 'Départ',
'day.confirmation': 'Confirmation',
'day.editAccommodation': 'Modifier l\'hébergement',
@@ -1762,14 +1779,26 @@ const fr: Record<string, string> = {
'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.ntfyUrl.topicLabel': 'Sujet Ntfy',
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
'settings.ntfyUrl.serverLabel': "URL du serveur Ntfy (optionnel)",
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
'settings.ntfyUrl.hint': "Entrez votre sujet Ntfy pour recevoir des notifications push. Laissez le serveur vide pour utiliser la valeur par défaut configurée par votre administrateur.",
'settings.ntfyUrl.tokenLabel': "Jeton d'accès (optionnel)",
'settings.ntfyUrl.tokenHint': 'Requis pour les sujets protégés par mot de passe.',
'settings.ntfyUrl.saved': 'Paramètres Ntfy enregistrés',
'settings.ntfyUrl.test': 'Tester',
'settings.ntfyUrl.testSuccess': 'Notification de test Ntfy envoyée avec succès',
'settings.ntfyUrl.testFailed': 'Échec de la notification de test Ntfy',
'settings.ntfyUrl.tokenCleared': "Jeton d'accès effacé",
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
@@ -1780,6 +1809,25 @@ const fr: Record<string, string> = {
'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.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Permet aux utilisateurs de configurer leurs propres sujets ntfy pour les notifications push. Définissez le serveur par défaut ci-dessous pour pré-remplir les paramètres utilisateur.',
'admin.notifications.testNtfy': 'Envoyer un Ntfy de test',
'admin.notifications.testNtfySuccess': 'Ntfy de test envoyé avec succès',
'admin.notifications.testNtfyFailed': 'Échec de l\'envoi du Ntfy de test',
'admin.notifications.adminNtfyPanel.title': 'Ntfy admin',
'admin.notifications.adminNtfyPanel.hint': 'Ce sujet Ntfy est utilisé exclusivement pour les notifications admin (ex. alertes de version). Il est séparé des sujets par utilisateur et s\'active toujours lorsqu\'il est configuré.',
'admin.notifications.adminNtfyPanel.serverLabel': 'URL du serveur Ntfy',
'admin.notifications.adminNtfyPanel.serverHint': 'Utilisé également comme serveur par défaut pour les notifications ntfy des utilisateurs. Laisser vide pour utiliser ntfy.sh. Les utilisateurs peuvent le modifier dans leurs propres paramètres.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Sujet admin',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': "Jeton d'accès (optionnel)",
'admin.notifications.adminNtfyPanel.tokenCleared': "Jeton d'accès admin effacé",
'admin.notifications.adminNtfyPanel.saved': 'Paramètres Ntfy admin enregistrés',
'admin.notifications.adminNtfyPanel.test': 'Envoyer un Ntfy de test',
'admin.notifications.adminNtfyPanel.testSuccess': 'Ntfy de test envoyé avec succès',
'admin.notifications.adminNtfyPanel.testFailed': 'Échec de l\'envoi du Ntfy de test',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Le Ntfy admin s\'active toujours lorsqu\'un sujet est configuré',
'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',
@@ -2134,6 +2182,50 @@ const fr: Record<string, string> = {
'oauth.scope.geo:read.description': 'Chercher des lieux, résoudre des URL cartographiques et géocoder des coordonnées',
'oauth.scope.weather:read.label': 'Prévisions météo',
'oauth.scope.weather:read.description': 'Obtenir les prévisions météo pour les lieux et dates de voyage',
// System notices
'system_notice.welcome_v1.title': 'Bienvenue sur TREK',
'system_notice.welcome_v1.body': 'Votre planificateur de voyage tout-en-un. Créez des itinéraires, partagez vos voyages et restez organisé — en ligne ou hors ligne.',
'system_notice.welcome_v1.cta_label': 'Planifier un voyage',
'system_notice.welcome_v1.hero_alt': 'Destination de voyage pittoresque avec l\'interface TREK',
'system_notice.welcome_v1.highlight_plan': 'Itinéraires jour par jour',
'system_notice.welcome_v1.highlight_share': 'Collaborez avec vos partenaires',
'system_notice.welcome_v1.highlight_offline': 'Fonctionne hors ligne sur mobile',
'system_notice.dev_test_modal.title': '[Dev] Test notice',
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
'system_notice.pager.prev': 'Avis précédent',
'system_notice.pager.next': 'Avis suivant',
'system_notice.pager.counter': '{current} / {total}',
'system_notice.pager.goto': "Aller à l'avis {n}",
'system_notice.pager.position': 'Avis {current} sur {total}',
// System notices — 3.0.0 upgrade
'system_notice.v3_photos.title': 'Les photos ont bougé dans 3.0',
'system_notice.v3_photos.body': "**Photos** dans le planificateur ont été supprimées. Tes photos sont en sécurité — TREK n'a jamais modifié ta bibliothèque Immich ou Synology.\n\nLes photos vivent désormais dans l'addon **Journey**. Journey est optionnel — s'il n'est pas encore disponible, demande à ton admin de l'activer dans Admin → Modules.",
'system_notice.v3_journey.title': 'Découvrez Journey — journal de voyage',
'system_notice.v3_journey.body': 'Documente tes voyages sous forme de récits enrichis avec chronologies, galeries photos et cartes interactives.',
'system_notice.v3_journey.cta_label': 'Ouvrir Journey',
'system_notice.v3_journey.highlight_timeline': 'Chronologie et galerie par jour',
'system_notice.v3_journey.highlight_photos': 'Import depuis Immich ou Synology',
'system_notice.v3_journey.highlight_share': 'Partage public — sans connexion requise',
'system_notice.v3_journey.highlight_export': 'Export en livre photo PDF',
'system_notice.v3_features.title': 'Plus de nouveautés en 3.0',
'system_notice.v3_features.body': 'Quelques autres choses à savoir sur cette version.',
'system_notice.v3_features.highlight_dashboard': 'Tableau de bord repensé mobile-first',
'system_notice.v3_features.highlight_offline': 'Mode hors ligne complet en PWA',
'system_notice.v3_features.highlight_search': 'Autocomplétion des lieux en temps réel',
'system_notice.v3_features.highlight_import': 'Importer des lieux depuis KMZ/KML',
// System notices — MCP OAuth 2.1 upgrade
'system_notice.v3_mcp.title': 'MCP : mise à niveau OAuth 2.1',
'system_notice.v3_mcp.body': "L'intégration MCP a été entièrement repensée. OAuth 2.1 est désormais la méthode d'authentification recommandée. Les tokens statiques (trek_\u2026) sont dépréciés et seront supprimés dans une future version.",
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recommandé (mcp-remote)',
'system_notice.v3_mcp.highlight_scopes': '24 scopes de permissions granulaires',
'system_notice.v3_mcp.highlight_deprecated': 'Tokens statiques trek_ dépréciés',
'system_notice.v3_mcp.highlight_tools': 'Outils et prompts étendus',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Un mot personnel de ma part',
'system_notice.v3_thankyou.body': 'Avant de continuer — je veux prendre un instant.\n\nTREK a commencé comme un projet perso que j\'ai construit pour mes propres voyages. Je n\'aurais jamais imaginé qu\'il grandirait au point que 4 000 d\'entre vous lui fassent confiance pour planifier vos aventures. Chaque étoile, chaque issue, chaque demande de fonctionnalité — je les lis toutes, et ce sont elles qui me font tenir pendant les nuits blanches entre un travail à temps plein et l\'université.\n\nJe veux que vous sachiez : TREK sera toujours open source, toujours auto-hébergé, toujours à vous. Pas de tracking, pas d\'abonnements, pas de conditions cachées. Juste un outil construit par quelqu\'un qui aime voyager autant que vous.\n\nUn merci tout particulier à [jubnl](https://github.com/jubnl) — tu es devenu un collaborateur incroyable. Une grande partie de ce qui rend la 3.0 géniale porte ton empreinte. Merci d\'avoir cru en ce projet quand il était encore brut.\n\nEt à chacun d\'entre vous qui a signalé un bug, traduit une chaîne, partagé TREK avec un ami ou simplement l\'a utilisé pour planifier un voyage — **merci**. Vous êtes la raison pour laquelle tout ceci existe.\n\nÀ de nombreuses autres aventures ensemble.\n\n— Maurice\n\n---\n\n[Rejoins la communauté sur Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK rend tes voyages meilleurs, un [petit café](https://ko-fi.com/mauriceboe) aide toujours à garder les lumières allumées.',
}
export default fr
+93 -1
View File
@@ -4,6 +4,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'common.showMore': 'Továbbiak',
'common.showLess': 'Kevesebb',
'common.cancel': 'Mégse',
'common.clear': 'Törlés',
'common.delete': 'Törlés',
'common.edit': 'Szerkesztés',
'common.add': 'Hozzáadás',
@@ -547,7 +548,21 @@ 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.collab.chat.title': 'Chat',
'admin.collab.chat.subtitle': 'Valós idejű üzenetküldés az együttműködéshez',
'admin.collab.notes.title': 'Jegyzetek',
'admin.collab.notes.subtitle': 'Megosztott jegyzetek és dokumentumok',
'admin.collab.polls.title': 'Szavazások',
'admin.collab.polls.subtitle': 'Csoportos szavazások',
'admin.collab.whatsnext.title': 'Mi következik',
'admin.collab.whatsnext.subtitle': 'Tevékenységjavaslatok és következő lépések',
'admin.tabs.config': 'Személyre szabás',
'admin.tabs.defaults': 'Alapértelmezett beállítások',
'admin.defaultSettings.title': 'Alapértelmezett felhasználói beállítások',
'admin.defaultSettings.description': 'Állítson be alapértelmezett értékeket az egész példányra. Azok a felhasználók, akik nem módosítottak egy beállítást, ezeket az értékeket fogják látni. A saját módosításaik mindig elsőbbséget élveznek.',
'admin.defaultSettings.saved': 'Alapértelmezett mentve',
'admin.defaultSettings.reset': 'Visszaállítás a beépített alapértelmezésre',
'admin.defaultSettings.resetToBuiltIn': 'visszaállítá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',
@@ -1004,6 +1019,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.platform': 'Vágány',
'reservations.meta.seat': 'Ülés',
'reservations.meta.checkIn': 'Bejelentkezés',
'reservations.meta.checkInUntil': 'Bejelentkezés eddig',
'reservations.meta.checkOut': 'Kijelentkezés',
'reservations.meta.linkAccommodation': 'Szállás',
'reservations.meta.pickAccommodation': 'Szállás hozzárendelése',
@@ -1487,6 +1503,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'day.noPlacesForHotel': 'Először adj hozzá helyeket az utazásodhoz',
'day.allDays': 'Összes',
'day.checkIn': 'Bejelentkezés',
'day.checkInUntil': 'Eddig',
'day.checkOut': 'Kijelentkezés',
'day.confirmation': 'Visszaigazolás',
'day.editAccommodation': 'Szállás szerkesztése',
@@ -1760,14 +1777,26 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'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.ntfyUrl.topicLabel': 'Ntfy téma',
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
'settings.ntfyUrl.serverLabel': 'Ntfy szerver URL (opcionális)',
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
'settings.ntfyUrl.hint': 'Add meg az Ntfy témádat push értesítések fogadásához. Hagyd üresen a szervert a rendszergazda által beállított alapértelmezett használatához.',
'settings.ntfyUrl.tokenLabel': 'Hozzáférési token (opcionális)',
'settings.ntfyUrl.tokenHint': 'Jelszóval védett témákhoz szükséges.',
'settings.ntfyUrl.saved': 'Ntfy beállítások mentve',
'settings.ntfyUrl.test': 'Teszt',
'settings.ntfyUrl.testSuccess': 'Teszt Ntfy értesítés sikeresen elküldve',
'settings.ntfyUrl.testFailed': 'Teszt Ntfy értesítés sikertelen',
'settings.ntfyUrl.tokenCleared': 'Hozzáférési token törölve',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
@@ -1778,6 +1807,25 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'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.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Lehetővé teszi a felhasználóknak, hogy saját ntfy-témáikat konfigurálják push értesítésekhez. Állítsa be az alapértelmezett szervert alább a felhasználói beállítások előre kitöltéséhez.',
'admin.notifications.testNtfy': 'Teszt Ntfy küldése',
'admin.notifications.testNtfySuccess': 'Teszt Ntfy sikeresen elküldve',
'admin.notifications.testNtfyFailed': 'Teszt Ntfy sikertelen',
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
'admin.notifications.adminNtfyPanel.hint': 'Ez az Ntfy téma kizárólag admin értesítésekhez használatos (pl. verziófrissítési figyelmeztetések). Független a felhasználói témáktól, és mindig küld, ha konfigurálva van.',
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy szerver URL',
'admin.notifications.adminNtfyPanel.serverHint': 'Alapértelmezett szerverként is szolgál a felhasználói ntfy értesítésekhez. Üresen hagyva ntfy.sh-t használ. A felhasználók felülírhatják saját beállításaikban.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin téma',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Hozzáférési token (opcionális)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Admin hozzáférési token törölve',
'admin.notifications.adminNtfyPanel.saved': 'Admin Ntfy beállítások mentve',
'admin.notifications.adminNtfyPanel.test': 'Teszt Ntfy küldése',
'admin.notifications.adminNtfyPanel.testSuccess': 'Teszt Ntfy sikeresen elküldve',
'admin.notifications.adminNtfyPanel.testFailed': 'Teszt Ntfy sikertelen',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Az admin Ntfy mindig küld, ha egy téma konfigurálva van',
'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',
@@ -2135,6 +2183,50 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'oauth.scope.geo:read.description': 'Helyek keresése, térkép URL-ek feloldása és koordináták fordított geokódolása',
'oauth.scope.weather:read.label': 'Időjárás-előrejelzések',
'oauth.scope.weather:read.description': 'Időjárás-előrejelzések lekérése az utazási helyszínekre és dátumokra',
// System notices
'system_notice.welcome_v1.title': 'Üdvözöl a TREK',
'system_notice.welcome_v1.body': 'Az összes az egyben utazástervező. Készítsen útvonalakat, ossza meg az utakat barátaival, és maradjon szervezett — online és offline.',
'system_notice.welcome_v1.cta_label': 'Utazás tervezése',
'system_notice.welcome_v1.hero_alt': 'Festői úticél TREK tervező felülettel',
'system_notice.welcome_v1.highlight_plan': 'Napi útvonalak minden utazáshoz',
'system_notice.welcome_v1.highlight_share': 'Együttműködés utazótársakkal',
'system_notice.welcome_v1.highlight_offline': 'Mobilon offline is működik',
'system_notice.dev_test_modal.title': '[Dev] Test notice',
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
'system_notice.pager.prev': 'Előző értesítés',
'system_notice.pager.next': 'Következő értesítés',
'system_notice.pager.counter': '{current} / {total}',
'system_notice.pager.goto': '{n}. értesítésre ugrás',
'system_notice.pager.position': '{current}/{total}. értesítés',
// System notices — 3.0.0 upgrade
'system_notice.v3_photos.title': 'A fotók helye megváltozott 3.0-ban',
'system_notice.v3_photos.body': 'Az útiterv-tervező **Fényképek** lapja eltávolításra került. Fényképeid biztonságban vannak — TREK soha nem módosította Immich vagy Synology könyvtáradat.\n\nA fényképek mostantól a **Journey** bővítményben élnek. A Journey opcionális — ha még nem elérhető, kérd meg a rendszergazdát, hogy engedélyezze Admin → Bővítmények alatt.',
'system_notice.v3_journey.title': 'Ismerje meg a Journey-t — útinnapló',
'system_notice.v3_journey.body': 'Dokumentáld utazazsaid gazdag történetekként idővonalakkal, fotgáriákkal és interaktív térképekkel.',
'system_notice.v3_journey.cta_label': 'Journey megnyitása',
'system_notice.v3_journey.highlight_timeline': 'Napi idővonal és galéria',
'system_notice.v3_journey.highlight_photos': 'Import Immich-ből vagy Synology-ból',
'system_notice.v3_journey.highlight_share': 'Nyilvános megosztás — bejelentkezés nélkül',
'system_notice.v3_journey.highlight_export': 'Exportálás PDF fotkönyvként',
'system_notice.v3_features.title': 'További újdonságok a 3.0-ban',
'system_notice.v3_features.body': 'Néhány további dolog, amit érdemes tudni erről a kiadásról.',
'system_notice.v3_features.highlight_dashboard': 'Mobile-first irmütébla újratervezve',
'system_notice.v3_features.highlight_offline': 'Teljes offline mód PWA-ként',
'system_notice.v3_features.highlight_search': 'Valós idejű helykeresés-kiegészítés',
'system_notice.v3_features.highlight_import': 'Helyek importálása KMZ/KML fájlokból',
// System notices — MCP OAuth 2.1 upgrade
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1 frissítés',
'system_notice.v3_mcp.body': 'Az MCP integráció teljesen megújult. Az OAuth 2.1 mostantól az ajánlott hitelesítési módszer. A statikus tokenek (trek_…) elavultak és egy jövőbeli kiadásban eltávolításra kerülnek.',
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 ajánlott (mcp-remote)',
'system_notice.v3_mcp.highlight_scopes': '24 részletes engedélyezési hatókör',
'system_notice.v3_mcp.highlight_deprecated': 'Statikus trek_ tokenek elavultak',
'system_notice.v3_mcp.highlight_tools': 'Bővített eszközkészlet és promptok',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Egy személyes gondolat tőlem',
'system_notice.v3_thankyou.body': 'Mielőtt továbbmennél — szeretnék egy pillanatra megállni.\n\nA TREK egy hobbiprojektként indult, amit a saját utazásaimhoz építettem. Sosem gondoltam volna, hogy valami olyanná nő, amire 4000-en bízzátok a kalandjaitok tervezését. Minden csillagot, minden issue-t, minden funkciókérést — mindet elolvasom, és ezek tartanak életben a késő éjszakákon a teljes állás és az egyetem között.\n\nSzeretnétek, ha tudnátok: a TREK mindig nyílt forráskódú marad, mindig self-hosted, mindig a tiétek. Nincs nyomkövetés, nincs előfizetés, nincsenek rejtett feltételek. Csak egy eszköz, amit valaki épített, aki ugyanúgy szereti az utazást, mint ti.\n\nKülönleges köszönet [jubnl](https://github.com/jubnl)-nek — hihetetlen társsá váltál. A 3.0 nagyszerűségének nagy része a te kézjegyedet viseli. Köszönöm, hogy hittél ebben a projektben, amikor még nyers volt.\n\nÉs mindannyiótoknak, akik hibát jelentettetek, szöveget fordítottatok, megosztottátok a TREK-et egy baráttal, vagy egyszerűen csak egy utazást terveztetek vele — **köszönöm**. Ti vagytok az ok, amiért ez létezik.\n\nSok további közös kalandért.\n\n— Maurice\n\n---\n\n[Csatlakozz a közösséghez a Discordon](https://discord.gg/7Q6M6jDwzf)\n\nHa a TREK jobbá teszi az utazásaidat, egy [kis kávé](https://ko-fi.com/mauriceboe) mindig segít, hogy égve maradjanak a fények.',
}
export default hu
File diff suppressed because it is too large Load Diff
+93 -1
View File
@@ -4,6 +4,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'common.showMore': 'Mostra di più',
'common.showLess': 'Mostra meno',
'common.cancel': 'Annulla',
'common.clear': 'Cancella',
'common.delete': 'Elimina',
'common.edit': 'Modifica',
'common.add': 'Aggiungi',
@@ -546,7 +547,21 @@ 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.collab.chat.title': 'Chat',
'admin.collab.chat.subtitle': 'Messaggistica in tempo reale per la collaborazione',
'admin.collab.notes.title': 'Note',
'admin.collab.notes.subtitle': 'Note e documenti condivisi',
'admin.collab.polls.title': 'Sondaggi',
'admin.collab.polls.subtitle': 'Sondaggi e votazioni di gruppo',
'admin.collab.whatsnext.title': 'Prossimi passi',
'admin.collab.whatsnext.subtitle': 'Suggerimenti attività e prossimi passi',
'admin.tabs.config': 'Personalizzazione',
'admin.tabs.defaults': 'Impostazioni predefinite',
'admin.defaultSettings.title': 'Impostazioni predefinite utente',
'admin.defaultSettings.description': "Imposta i valori predefiniti per l'intera istanza. Gli utenti che non hanno modificato un'impostazione vedranno questi valori. Le loro modifiche hanno sempre la priorità.",
'admin.defaultSettings.saved': 'Predefinito salvato',
'admin.defaultSettings.reset': 'Ripristina il predefinito integrato',
'admin.defaultSettings.resetToBuiltIn': 'ripristina',
'admin.tabs.templates': 'Modelli lista valigia',
'admin.packingTemplates.title': 'Modelli lista valigia',
'admin.packingTemplates.subtitle': 'Crea liste valigia riutilizzabili per i tuoi viaggi',
@@ -1003,6 +1018,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.platform': 'Binario',
'reservations.meta.seat': 'Posto',
'reservations.meta.checkIn': 'Check-in',
'reservations.meta.checkInUntil': 'Check-in fino a',
'reservations.meta.checkOut': 'Check-out',
'reservations.meta.linkAccommodation': 'Alloggio',
'reservations.meta.pickAccommodation': 'Collega a un alloggio',
@@ -1487,6 +1503,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'day.noPlacesForHotel': 'Aggiungi prima i luoghi al tuo viaggio',
'day.allDays': 'Tutti',
'day.checkIn': 'Check-in',
'day.checkInUntil': 'Fino a',
'day.checkOut': 'Check-out',
'day.confirmation': 'Conferma',
'day.editAccommodation': 'Modifica alloggio',
@@ -1763,14 +1780,26 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'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.ntfyUrl.topicLabel': 'Argomento Ntfy',
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
'settings.ntfyUrl.serverLabel': 'URL server Ntfy (opzionale)',
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
'settings.ntfyUrl.hint': "Inserisci il tuo argomento Ntfy per ricevere notifiche push. Lascia il server vuoto per usare il valore predefinito configurato dall'amministratore.",
'settings.ntfyUrl.tokenLabel': 'Token di accesso (opzionale)',
'settings.ntfyUrl.tokenHint': 'Richiesto per gli argomenti protetti da password.',
'settings.ntfyUrl.saved': 'Impostazioni Ntfy salvate',
'settings.ntfyUrl.test': 'Testa',
'settings.ntfyUrl.testSuccess': 'Notifica di test Ntfy inviata con successo',
'settings.ntfyUrl.testFailed': 'Notifica di test Ntfy fallita',
'settings.ntfyUrl.tokenCleared': 'Token di accesso rimosso',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
@@ -1781,6 +1810,25 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'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.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Consente agli utenti di configurare i propri argomenti ntfy per le notifiche push. Imposta il server predefinito di seguito per precompilare le impostazioni utente.',
'admin.notifications.testNtfy': 'Invia Ntfy di test',
'admin.notifications.testNtfySuccess': 'Ntfy di test inviato con successo',
'admin.notifications.testNtfyFailed': 'Invio Ntfy di test fallito',
'admin.notifications.adminNtfyPanel.title': 'Ntfy admin',
'admin.notifications.adminNtfyPanel.hint': 'Questo argomento Ntfy viene usato esclusivamente per le notifiche admin (es. avvisi di versione). È separato dagli argomenti per utente e si attiva sempre quando è configurato.',
'admin.notifications.adminNtfyPanel.serverLabel': 'URL server Ntfy',
'admin.notifications.adminNtfyPanel.serverHint': 'Usato anche come server predefinito per le notifiche ntfy degli utenti. Lasciare vuoto per usare ntfy.sh. Gli utenti possono sovrascriverlo nelle proprie impostazioni.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Argomento admin',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Token di accesso (opzionale)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Token di accesso admin rimosso',
'admin.notifications.adminNtfyPanel.saved': 'Impostazioni Ntfy admin salvate',
'admin.notifications.adminNtfyPanel.test': 'Invia Ntfy di test',
'admin.notifications.adminNtfyPanel.testSuccess': 'Ntfy di test inviato con successo',
'admin.notifications.adminNtfyPanel.testFailed': 'Invio Ntfy di test fallito',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Il Ntfy admin si attiva sempre quando un argomento è configurato',
'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': 'Notifiche',
'notifications.versionAvailable.title': 'Aggiornamento disponibile',
@@ -2135,6 +2183,50 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'oauth.scope.geo:read.description': 'Cerca luoghi, risolvi URL mappa e geocodifica inversa coordinate',
'oauth.scope.weather:read.label': 'Previsioni meteo',
'oauth.scope.weather:read.description': 'Ottieni previsioni meteo per luoghi e date del viaggio',
// System notices
'system_notice.welcome_v1.title': 'Benvenuto su TREK',
'system_notice.welcome_v1.body': 'Il tuo pianificatore di viaggi tutto in uno. Crea itinerari, condividi viaggi con gli amici e rimani organizzato — online e offline.',
'system_notice.welcome_v1.cta_label': 'Pianifica un viaggio',
'system_notice.welcome_v1.hero_alt': 'Destinazione di viaggio panoramica con l\'interfaccia TREK',
'system_notice.welcome_v1.highlight_plan': 'Itinerari giorno per giorno',
'system_notice.welcome_v1.highlight_share': 'Collabora con i tuoi compagni di viaggio',
'system_notice.welcome_v1.highlight_offline': 'Funziona offline su mobile',
'system_notice.dev_test_modal.title': '[Dev] Test notice',
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
'system_notice.pager.prev': 'Avviso precedente',
'system_notice.pager.next': 'Avviso successivo',
'system_notice.pager.counter': '{current} / {total}',
'system_notice.pager.goto': "Vai all'avviso {n}",
'system_notice.pager.position': 'Avviso {current} di {total}',
// System notices — 3.0.0 upgrade
'system_notice.v3_photos.title': 'Le foto sono spostate nella 3.0',
'system_notice.v3_photos.body': '**Foto** nel Pianificatore di Viaggio sono state rimosse. Le tue foto sono al sicuro — TREK non ha mai modificato la tua libreria Immich o Synology.\n\nLe foto ora si trovano nel componente aggiuntivo **Journey**. Journey è opzionale — se non è ancora disponibile, chiedi al tuo admin di abilitarlo in Admin → Addon.',
'system_notice.v3_journey.title': 'Scopri Journey — diario di viaggio',
'system_notice.v3_journey.body': 'Documenta i tuoi viaggi come storie ricche con cronologie, gallerie fotografiche e mappe interattive.',
'system_notice.v3_journey.cta_label': 'Apri Journey',
'system_notice.v3_journey.highlight_timeline': 'Cronologia e galleria giornaliera',
'system_notice.v3_journey.highlight_photos': 'Importa da Immich o Synology',
'system_notice.v3_journey.highlight_share': 'Condividi pubblicamente — senza accesso',
'system_notice.v3_journey.highlight_export': 'Esporta come libro fotografico PDF',
'system_notice.v3_features.title': 'Altri punti salienti nel 3.0',
'system_notice.v3_features.body': 'Altre novità da conoscere in questa versione.',
'system_notice.v3_features.highlight_dashboard': 'Dashboard ridisegnata mobile-first',
'system_notice.v3_features.highlight_offline': 'Modalità offline completa come PWA',
'system_notice.v3_features.highlight_search': 'Completamento automatico luoghi in tempo reale',
'system_notice.v3_features.highlight_import': 'Importa luoghi da file KMZ/KML',
// System notices — MCP OAuth 2.1 upgrade
'system_notice.v3_mcp.title': 'MCP: aggiornamento OAuth 2.1',
'system_notice.v3_mcp.body': "L'integrazione MCP è stata completamente rinnovata. OAuth 2.1 è ora il metodo di autenticazione consigliato. I token statici (trek_\u2026) sono deprecati e verranno rimossi in una versione futura.",
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 consigliato (mcp-remote)',
'system_notice.v3_mcp.highlight_scopes': '24 scope di autorizzazione granulari',
'system_notice.v3_mcp.highlight_deprecated': 'Token statici trek_ deprecati',
'system_notice.v3_mcp.highlight_tools': 'Strumenti e prompt estesi',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Una nota personale da parte mia',
'system_notice.v3_thankyou.body': 'Prima di andare avanti — voglio prendermi un momento.\n\nTREK è nato come un progetto secondario che ho costruito per i miei viaggi. Non avrei mai immaginato che sarebbe cresciuto fino a diventare qualcosa di cui 4.000 di voi si fidano per pianificare le proprie avventure. Ogni stella, ogni issue, ogni richiesta di funzionalità — le leggo tutte, e sono loro a tenermi in piedi nelle notti tarde tra un lavoro a tempo pieno e l\'università.\n\nVoglio che sappiate: TREK sarà sempre open source, sempre self-hosted, sempre vostro. Nessun tracciamento, nessun abbonamento, nessuna fregatura. Solo uno strumento creato da qualcuno che ama viaggiare tanto quanto voi.\n\nUn ringraziamento speciale a [jubnl](https://github.com/jubnl) — sei diventato un collaboratore incredibile. Molto di ciò che rende la 3.0 fantastica porta la tua impronta. Grazie per aver creduto in questo progetto quando era ancora acerbo.\n\nE a ognuno di voi che ha segnalato un bug, tradotto una stringa, condiviso TREK con un amico o semplicemente lo ha usato per pianificare un viaggio — **grazie**. Voi siete il motivo per cui tutto questo esiste.\n\nA molte altre avventure insieme.\n\n— Maurice\n\n---\n\n[Unisciti alla community su Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe TREK rende i tuoi viaggi migliori, un [piccolo caffè](https://ko-fi.com/mauriceboe) aiuta sempre a tenere le luci accese.',
}
export default it
+93 -1
View File
@@ -4,6 +4,7 @@ const nl: Record<string, string> = {
'common.showMore': 'Meer tonen',
'common.showLess': 'Minder tonen',
'common.cancel': 'Annuleren',
'common.clear': 'Wissen',
'common.delete': 'Verwijderen',
'common.edit': 'Bewerken',
'common.add': 'Toevoegen',
@@ -547,7 +548,21 @@ const nl: Record<string, string> = {
'admin.bagTracking.title': 'Bagagetracking',
'admin.bagTracking.subtitle': 'Gewicht en bagagetoewijzing inschakelen voor paklijstitems',
'admin.collab.chat.title': 'Chat',
'admin.collab.chat.subtitle': 'Realtime berichten voor reissamenwerking',
'admin.collab.notes.title': 'Notities',
'admin.collab.notes.subtitle': 'Gedeelde notities en documenten',
'admin.collab.polls.title': 'Peilingen',
'admin.collab.polls.subtitle': 'Groepspeilingen en stemmen',
'admin.collab.whatsnext.title': 'Wat nu',
'admin.collab.whatsnext.subtitle': 'Activiteitssuggesties en volgende stappen',
'admin.tabs.config': 'Personalisatie',
'admin.tabs.defaults': 'Standaardinstellingen',
'admin.defaultSettings.title': 'Standaard gebruikersinstellingen',
'admin.defaultSettings.description': 'Stel instantiebrede standaardwaarden in. Gebruikers die een instelling niet hebben gewijzigd, zien deze waarden. Hun eigen wijzigingen hebben altijd voorrang.',
'admin.defaultSettings.saved': 'Standaard opgeslagen',
'admin.defaultSettings.reset': 'Terugzetten naar ingebouwde standaard',
'admin.defaultSettings.resetToBuiltIn': 'terugzetten',
'admin.tabs.templates': 'Paksjablonen',
'admin.packingTemplates.title': 'Paksjablonen',
'admin.packingTemplates.subtitle': 'Herbruikbare paklijsten maken voor je reizen',
@@ -1002,6 +1017,7 @@ const nl: Record<string, string> = {
'reservations.meta.platform': 'Perron',
'reservations.meta.seat': 'Stoel',
'reservations.meta.checkIn': 'Inchecken',
'reservations.meta.checkInUntil': 'Check-in tot',
'reservations.meta.checkOut': 'Uitchecken',
'reservations.meta.linkAccommodation': 'Accommodatie',
'reservations.meta.pickAccommodation': 'Koppel aan accommodatie',
@@ -1486,6 +1502,7 @@ const nl: Record<string, string> = {
'day.noPlacesForHotel': 'Voeg eerst plaatsen toe aan je reis',
'day.allDays': 'Alle',
'day.checkIn': 'Inchecken',
'day.checkInUntil': 'Tot',
'day.checkOut': 'Uitchecken',
'day.confirmation': 'Bevestiging',
'day.editAccommodation': 'Accommodatie bewerken',
@@ -1762,14 +1779,26 @@ const nl: Record<string, string> = {
'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.ntfyUrl.topicLabel': 'Ntfy-onderwerp',
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
'settings.ntfyUrl.serverLabel': 'Ntfy-server-URL (optioneel)',
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
'settings.ntfyUrl.hint': 'Voer je Ntfy-onderwerp in om pushmeldingen te ontvangen. Laat het serverveld leeg om de standaard te gebruiken die door je beheerder is ingesteld.',
'settings.ntfyUrl.tokenLabel': 'Toegangstoken (optioneel)',
'settings.ntfyUrl.tokenHint': 'Vereist voor onderwerpen die met een wachtwoord zijn beveiligd.',
'settings.ntfyUrl.saved': 'Ntfy-instellingen opgeslagen',
'settings.ntfyUrl.test': 'Testen',
'settings.ntfyUrl.testSuccess': 'Test-Ntfy-melding succesvol verzonden',
'settings.ntfyUrl.testFailed': 'Test-Ntfy-melding mislukt',
'settings.ntfyUrl.tokenCleared': 'Toegangstoken gewist',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
@@ -1780,6 +1809,25 @@ const nl: Record<string, string> = {
'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.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Hiermee kunnen gebruikers hun eigen ntfy-onderwerpen instellen voor pushmeldingen. Stel de standaardserver hieronder in om de gebruikersinstellingen vooraf in te vullen.',
'admin.notifications.testNtfy': 'Test-Ntfy verzenden',
'admin.notifications.testNtfySuccess': 'Test-Ntfy succesvol verzonden',
'admin.notifications.testNtfyFailed': 'Test-Ntfy mislukt',
'admin.notifications.adminNtfyPanel.title': 'Admin-Ntfy',
'admin.notifications.adminNtfyPanel.hint': 'Dit Ntfy-onderwerp wordt uitsluitend gebruikt voor admin-meldingen (bijv. versie-updates). Het staat los van onderwerpen per gebruiker en verstuurt altijd wanneer het geconfigureerd is.',
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy-server-URL',
'admin.notifications.adminNtfyPanel.serverHint': 'Wordt ook gebruikt als standaardserver voor ntfy-meldingen van gebruikers. Laat leeg om ntfy.sh te gebruiken. Gebruikers kunnen dit aanpassen in hun eigen instellingen.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin-onderwerp',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Toegangstoken (optioneel)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Admin-toegangstoken gewist',
'admin.notifications.adminNtfyPanel.saved': 'Admin-Ntfy-instellingen opgeslagen',
'admin.notifications.adminNtfyPanel.test': 'Test-Ntfy verzenden',
'admin.notifications.adminNtfyPanel.testSuccess': 'Test-Ntfy succesvol verzonden',
'admin.notifications.adminNtfyPanel.testFailed': 'Test-Ntfy mislukt',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin-Ntfy verstuurt altijd wanneer een onderwerp is geconfigureerd',
'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',
@@ -2134,6 +2182,50 @@ const nl: Record<string, string> = {
'oauth.scope.geo:read.description': 'Locaties zoeken, kaart-URL\'s oplossen en coördinaten omgekeerd geocoderen',
'oauth.scope.weather:read.label': 'Weersverwachtingen',
'oauth.scope.weather:read.description': 'Weersverwachtingen ophalen voor reislocaties en -datums',
// System notices
'system_notice.welcome_v1.title': 'Welkom bij TREK',
'system_notice.welcome_v1.body': 'Jouw alles-in-één reisplanner. Maak reisschema\'s, deel trips met vrienden en blijf georganiseerd — online en offline.',
'system_notice.welcome_v1.cta_label': 'Reis plannen',
'system_notice.welcome_v1.hero_alt': 'Schilderachtige reisbestemming met TREK interface',
'system_notice.welcome_v1.highlight_plan': 'Dag-voor-dag reisschema\'s',
'system_notice.welcome_v1.highlight_share': 'Samenwerken met reisgezelschap',
'system_notice.welcome_v1.highlight_offline': 'Werkt offline op mobiel',
'system_notice.dev_test_modal.title': '[Dev] Test notice',
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
'system_notice.pager.prev': 'Vorige melding',
'system_notice.pager.next': 'Volgende melding',
'system_notice.pager.counter': '{current} / {total}',
'system_notice.pager.goto': 'Ga naar melding {n}',
'system_notice.pager.position': 'Melding {current} van {total}',
// System notices — 3.0.0 upgrade
'system_notice.v3_photos.title': "Foto's zijn verplaatst in 3.0",
'system_notice.v3_photos.body': "**Foto's** in de Reisplanner zijn verwijderd. Je foto's zijn veilig — TREK heeft je Immich- of Synology-bibliotheek nooit gewijzigd.\n\nFoto's leven nu in de **Journey**-addon. Journey is optioneel — als het nog niet beschikbaar is, vraag je admin het te activeren via Admin → Addons.",
'system_notice.v3_journey.title': 'Maak kennis met Journey — reisdagboek',
'system_notice.v3_journey.body': 'Documenteer je reizen als rijke verhalen met tijdlijnen, fotogalerijen en interactieve kaarten.',
'system_notice.v3_journey.cta_label': 'Journey openen',
'system_notice.v3_journey.highlight_timeline': 'Dag-voor-dag tijdlijn & galerij',
'system_notice.v3_journey.highlight_photos': 'Importeer van Immich of Synology',
'system_notice.v3_journey.highlight_share': 'Openbaar delen — geen login vereist',
'system_notice.v3_journey.highlight_export': 'Exporteer als PDF-fotoboek',
'system_notice.v3_features.title': 'Meer hoogtepunten in 3.0',
'system_notice.v3_features.body': 'Nog een paar dingen die het weten waard zijn in deze release.',
'system_notice.v3_features.highlight_dashboard': 'Mobile-first dashboard herontwerp',
'system_notice.v3_features.highlight_offline': 'Volledige offline modus als PWA',
'system_notice.v3_features.highlight_search': 'Realtime plaatsautocomplete',
'system_notice.v3_features.highlight_import': 'Importeer plaatsen uit KMZ/KML-bestanden',
// System notices — MCP OAuth 2.1 upgrade
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1-upgrade',
'system_notice.v3_mcp.body': 'De MCP-integratie is volledig vernieuwd. OAuth 2.1 is nu de aanbevolen authenticatiemethode. Statische tokens (trek_…) zijn verouderd en worden verwijderd in een toekomstige versie.',
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 aanbevolen (mcp-remote)',
'system_notice.v3_mcp.highlight_scopes': '24 gedetailleerde toestemmingsscopes',
'system_notice.v3_mcp.highlight_deprecated': 'Statische trek_-tokens verouderd',
'system_notice.v3_mcp.highlight_tools': 'Uitgebreide tools & prompts',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Een persoonlijk woord van mij',
'system_notice.v3_thankyou.body': 'Voordat je verdergaat — ik wil even stilstaan.\n\nTREK begon als een zijproject dat ik bouwde voor mijn eigen reizen. Ik had nooit gedacht dat het zou uitgroeien tot iets waar 4.000 van jullie op vertrouwen om avonturen te plannen. Elke ster, elke issue, elk functieverzoek — ik lees ze allemaal, en ze houden me op de been tijdens de late avonden tussen een fulltime baan en de universiteit.\n\nIk wil dat jullie weten: TREK zal altijd open source zijn, altijd self-hosted, altijd van jullie. Geen tracking, geen abonnementen, geen addertjes. Gewoon een tool gebouwd door iemand die net zo veel van reizen houdt als jullie.\n\nSpeciale dank aan [jubnl](https://github.com/jubnl) — je bent een ongelooflijke medewerker geworden. Zo veel van wat 3.0 geweldig maakt draagt jouw vingerafdruk. Bedankt dat je in dit project geloofde toen het nog ruw was.\n\nEn aan ieder van jullie die een bug meldde, een string vertaalde, TREK deelde met een vriend of het simpelweg gebruikte om een reis te plannen — **bedankt**. Jullie zijn de reden dat dit bestaat.\n\nOp nog vele avonturen samen.\n\n— Maurice\n\n---\n\n[Sluit je aan bij de community op Discord](https://discord.gg/7Q6M6jDwzf)\n\nAls TREK je reizen beter maakt, houdt een [klein kopje koffie](https://ko-fi.com/mauriceboe) altijd de lichten aan.',
}
export default nl
+93 -1
View File
@@ -4,6 +4,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'common.showMore': 'Pokaż więcej',
'common.showLess': 'Pokaż mniej',
'common.cancel': 'Anuluj',
'common.clear': 'Wyczyść',
'common.delete': 'Usuń',
'common.edit': 'Edytuj',
'common.add': 'Dodaj',
@@ -519,7 +520,21 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
// Packing Templates & Bag Tracking
'admin.bagTracking.title': 'Kontrola bagażu',
'admin.bagTracking.subtitle': 'Włącz wagę i przypisywanie do toreb dla przedmiotów do pakowania',
'admin.collab.chat.title': 'Czat',
'admin.collab.chat.subtitle': 'Wiadomości w czasie rzeczywistym',
'admin.collab.notes.title': 'Notatki',
'admin.collab.notes.subtitle': 'Wspólne notatki i dokumenty',
'admin.collab.polls.title': 'Ankiety',
'admin.collab.polls.subtitle': 'Ankiety grupowe i głosowania',
'admin.collab.whatsnext.title': 'Co dalej',
'admin.collab.whatsnext.subtitle': 'Sugestie aktywności i następne kroki',
'admin.tabs.config': 'Personalizacja',
'admin.tabs.defaults': 'Domyślne ustawienia',
'admin.defaultSettings.title': 'Domyślne ustawienia użytkownika',
'admin.defaultSettings.description': 'Ustaw domyślne wartości dla całej instancji. Użytkownicy, którzy nie zmienili ustawienia, zobaczą te wartości. Ich własne zmiany zawsze mają pierwszeństwo.',
'admin.defaultSettings.saved': 'Domyślne zapisane',
'admin.defaultSettings.reset': 'Przywróć wbudowaną wartość domyślną',
'admin.defaultSettings.resetToBuiltIn': 'przywróć',
'admin.tabs.templates': 'Szablony pakowania',
'admin.packingTemplates.title': 'Szablony pakowania',
'admin.packingTemplates.subtitle': 'Twórz szablony list pakowania do wielokrotnego użycia dla swoich podróży',
@@ -959,6 +974,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.platform': 'Peron',
'reservations.meta.seat': 'Miejsce',
'reservations.meta.checkIn': 'Zameldowanie',
'reservations.meta.checkInUntil': 'Check-in do',
'reservations.meta.checkOut': 'Wymeldowanie',
'reservations.meta.linkAccommodation': 'Zakwaterowanie',
'reservations.meta.pickAccommodation': 'Link do zakwaterowania',
@@ -1441,6 +1457,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'day.noPlacesForHotel': 'Najpierw dodaj miejsca do swojej podróży',
'day.allDays': 'Wszystkie',
'day.checkIn': 'Zameldowanie',
'day.checkInUntil': 'Do',
'day.checkOut': 'Wymeldowanie',
'day.confirmation': 'Potwierdzenie',
'day.editAccommodation': 'Edytuj zakwaterowanie',
@@ -1596,6 +1613,25 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminWebhookPanel.testSuccess': 'Testowy webhook wysłany pomyślnie',
'admin.notifications.adminWebhookPanel.testFailed': 'Wysyłanie testowego webhooka nie powiodło się',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Webhook admina wysyła automatycznie, gdy URL jest skonfigurowany',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Pozwala użytkownikom skonfigurować własne tematy ntfy dla powiadomień push. Ustaw domyślny serwer poniżej, aby wstępnie wypełnić ustawienia użytkownika.',
'admin.notifications.testNtfy': 'Wyślij testowe Ntfy',
'admin.notifications.testNtfySuccess': 'Testowe Ntfy wysłane pomyślnie',
'admin.notifications.testNtfyFailed': 'Wysyłanie testowego Ntfy nie powiodło się',
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
'admin.notifications.adminNtfyPanel.hint': 'Ten temat Ntfy jest używany wyłącznie do powiadomień admina (np. alertów o wersjach). Jest niezależny od tematów użytkowników i zawsze wysyła po skonfigurowaniu.',
'admin.notifications.adminNtfyPanel.serverLabel': 'URL serwera Ntfy',
'admin.notifications.adminNtfyPanel.serverHint': 'Używany również jako domyślny serwer dla powiadomień ntfy użytkowników. Pozostaw puste, aby użyć ntfy.sh. Użytkownicy mogą to nadpisać w swoich ustawieniach.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Temat admina',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Token dostępu (opcjonalne)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Token dostępu admina wyczyszczony',
'admin.notifications.adminNtfyPanel.saved': 'Ustawienia admin Ntfy zapisane',
'admin.notifications.adminNtfyPanel.test': 'Wyślij testowe Ntfy',
'admin.notifications.adminNtfyPanel.testSuccess': 'Testowe Ntfy wysłane pomyślnie',
'admin.notifications.adminNtfyPanel.testFailed': 'Wysyłanie testowego Ntfy nie powiodło się',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy zawsze wysyła po skonfigurowaniu tematu',
'admin.notifications.adminNotificationsHint': 'Skonfiguruj, które kanały dostarczają powiadomienia admina (np. alerty o wersjach). Webhook wysyła automatycznie, gdy ustawiony jest URL webhooka admina.',
'admin.webhook.hint': 'Pozwól użytkownikom konfigurować własne adresy URL webhooka dla powiadomień (Discord, Slack itp.).',
'settings.notificationsDisabled': 'Powiadomienia nie są skonfigurowane.',
@@ -1603,14 +1639,26 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'settings.webhookUrl.label': 'URL webhooka',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Wprowadź adres URL webhooka Discord, Slack lub własnego, aby otrzymywać powiadomienia.',
'settings.webhookUrl.save': 'Zapisz',
'settings.webhookUrl.saved': 'URL webhooka zapisany',
'settings.webhookUrl.test': 'Testuj',
'settings.webhookUrl.testSuccess': 'Testowy webhook wysłany pomyślnie',
'settings.webhookUrl.testFailed': 'Wysyłanie testowego webhooka nie powiodło się',
'settings.ntfyUrl.topicLabel': 'Temat Ntfy',
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
'settings.ntfyUrl.serverLabel': 'URL serwera Ntfy (opcjonalne)',
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
'settings.ntfyUrl.hint': 'Wprowadź swój temat Ntfy, aby otrzymywać powiadomienia push. Pozostaw pole serwera puste, aby użyć domyślnego ustawienia skonfigurowanego przez administratora.',
'settings.ntfyUrl.tokenLabel': 'Token dostępu (opcjonalne)',
'settings.ntfyUrl.tokenHint': 'Wymagane dla tematów chronionych hasłem.',
'settings.ntfyUrl.saved': 'Ustawienia Ntfy zapisane',
'settings.ntfyUrl.test': 'Testuj',
'settings.ntfyUrl.testSuccess': 'Testowe powiadomienie Ntfy wysłane pomyślnie',
'settings.ntfyUrl.testFailed': 'Testowe powiadomienie Ntfy nie powiodło się',
'settings.ntfyUrl.tokenCleared': 'Token dostępu wyczyszczony',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.notificationsActive': 'Aktywny kanał',
'settings.notificationsManagedByAdmin': 'Zdarzenia konfigurowane przez administratora.',
'settings.mustChangePassword': 'Musisz zmienić hasło przed kontynuowaniem.',
@@ -2127,6 +2175,50 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'oauth.scope.geo:read.description': 'Wyszukuj miejsca, rozwiązuj adresy URL map i odwrotnie geokoduj współrzędne',
'oauth.scope.weather:read.label': 'Prognozy pogody',
'oauth.scope.weather:read.description': 'Pobieraj prognozy pogody dla miejsc i dat podróży',
// System notices
'system_notice.welcome_v1.title': 'Witaj w TREK',
'system_notice.welcome_v1.body': 'Twój kompleksowy planer podróży. Twórz trasy, dziel się wycieczkami ze znajomymi i bądź zorganizowany — online i offline.',
'system_notice.welcome_v1.cta_label': 'Zaplanuj podróż',
'system_notice.welcome_v1.hero_alt': 'Malownicze miejsce z interfejsem planowania TREK',
'system_notice.welcome_v1.highlight_plan': 'Trasy dzień po dniu',
'system_notice.welcome_v1.highlight_share': 'Współpraca z partnerami podróży',
'system_notice.welcome_v1.highlight_offline': 'Działa offline na telefonie',
'system_notice.dev_test_modal.title': '[Dev] Test notice',
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
'system_notice.pager.prev': 'Poprzednie powiadomienie',
'system_notice.pager.next': 'Następne powiadomienie',
'system_notice.pager.counter': '{current} / {total}',
'system_notice.pager.goto': 'Przejdź do powiadomienia {n}',
'system_notice.pager.position': 'Powiadomienie {current} z {total}',
// System notices — 3.0.0 upgrade
'system_notice.v3_photos.title': 'Zdjęcia zostały przeniesione w 3.0',
'system_notice.v3_photos.body': '**Zdjęcia** w Planerze Podróży zostały usunięte. Twoje zdjęcia są bezpieczne — TREK nigdy nie modyfikował Twojej biblioteki Immich lub Synology.\n\nZdjęcia są teraz dostępne w dodatku **Journey**. Journey jest opcjonalny — jeśli jeszcze nie jest dostępny, poproś administratora o jego włączenie w Admin → Dodatki.',
'system_notice.v3_journey.title': 'Poznaj Journey — dziennik podróży',
'system_notice.v3_journey.body': 'Dokumentuj swoje podróże jako bogatrze opowieści z osami czasu, galeriami i mapami interaktywnymi.',
'system_notice.v3_journey.cta_label': 'Otwórz Journey',
'system_notice.v3_journey.highlight_timeline': 'Dzienna oś czasu i galeria',
'system_notice.v3_journey.highlight_photos': 'Import z Immich lub Synology',
'system_notice.v3_journey.highlight_share': 'Udostępnij publicznie — bez logowania',
'system_notice.v3_journey.highlight_export': 'Eksportuj jako książkę fotograficzną PDF',
'system_notice.v3_features.title': 'Więcej nowości w 3.0',
'system_notice.v3_features.body': 'Kilka innych rzeczy wartych uwagi w tym wydaniu.',
'system_notice.v3_features.highlight_dashboard': 'Przeprojektowany pulpit mobile-first',
'system_notice.v3_features.highlight_offline': 'Pełny tryb offline jako PWA',
'system_notice.v3_features.highlight_search': 'Autouzupełnianie wyszukiwania miejsc',
'system_notice.v3_features.highlight_import': 'Import miejsc z plików KMZ/KML',
// System notices — MCP OAuth 2.1 upgrade
'system_notice.v3_mcp.title': 'MCP: aktualizacja OAuth 2.1',
'system_notice.v3_mcp.body': 'Integracja MCP została całkowicie przeprojektowana. OAuth 2.1 jest teraz zalecaną metodą uwierzytelniania. Statyczne tokeny (trek_…) są przestarzałe i zostaną usunięte w przyszłej wersji.',
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 zalecany (mcp-remote)',
'system_notice.v3_mcp.highlight_scopes': '24 szczegółowe zakresy uprawnień',
'system_notice.v3_mcp.highlight_deprecated': 'Statyczne tokeny trek_ przestarzałe',
'system_notice.v3_mcp.highlight_tools': 'Rozszerzony zestaw narzędzi i promptów',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Osobiste słowo ode mnie',
'system_notice.v3_thankyou.body': 'Zanim pójdziesz dalej — chcę się na chwilę zatrzymać.\n\nTREK zaczął się jako poboczny projekt, który zbudowałem na własne podróże. Nigdy nie wyobrażałem sobie, że wyrośnie na coś, czemu 4000 z was ufa przy planowaniu swoich przygód. Każda gwiazdka, każdy issue, każda prośba o funkcję — czytam je wszystkie i to one trzymają mnie na nogach podczas późnych nocy między pracą na pełny etat a uczelnią.\n\nChcę, żebyście wiedzieli: TREK zawsze będzie open source, zawsze self-hosted, zawsze wasz. Bez śledzenia, bez subskrypcji, bez haczyków. Po prostu narzędzie zbudowane przez kogoś, kto kocha podróżowanie tak samo jak wy.\n\nSzczególne podziękowania dla [jubnl](https://github.com/jubnl) — stałeś się niesamowitym współpracownikiem. Tak wiele z tego, co czyni wersję 3.0 wspaniałą, nosi twój ślad. Dziękuję, że uwierzyłeś w ten projekt, gdy był jeszcze surowy.\n\nI każdemu z was, kto zgłosił błąd, przetłumaczył tekst, podzielił się TREK z przyjacielem lub po prostu użył go do zaplanowania podróży — **dziękuję**. To wy jesteście powodem, dla którego to istnieje.\n\nZa wiele kolejnych wspólnych przygód.\n\n— Maurice\n\n---\n\n[Dołącz do społeczności na Discordzie](https://discord.gg/7Q6M6jDwzf)\n\nJeśli TREK sprawia, że Twoje podróże są lepsze, [mała kawa](https://ko-fi.com/mauriceboe) zawsze pomaga utrzymać światła włączone.',
}
export default pl
+93 -1
View File
@@ -4,6 +4,7 @@ const ru: Record<string, string> = {
'common.showMore': 'Показать больше',
'common.showLess': 'Показать меньше',
'common.cancel': 'Отмена',
'common.clear': 'Очистить',
'common.delete': 'Удалить',
'common.edit': 'Редактировать',
'common.add': 'Добавить',
@@ -547,7 +548,21 @@ const ru: Record<string, string> = {
'admin.bagTracking.title': 'Отслеживание багажа',
'admin.bagTracking.subtitle': 'Включить вес и привязку к багажу для вещей',
'admin.collab.chat.title': 'Чат',
'admin.collab.chat.subtitle': 'Обмен сообщениями для совместной работы',
'admin.collab.notes.title': 'Заметки',
'admin.collab.notes.subtitle': 'Общие заметки и документы',
'admin.collab.polls.title': 'Опросы',
'admin.collab.polls.subtitle': 'Групповые опросы и голосования',
'admin.collab.whatsnext.title': 'Что дальше',
'admin.collab.whatsnext.subtitle': 'Предложения активностей и следующие шаги',
'admin.tabs.config': 'Персонализация',
'admin.tabs.defaults': 'Настройки по умолчанию',
'admin.defaultSettings.title': 'Настройки пользователей по умолчанию',
'admin.defaultSettings.description': 'Задайте значения по умолчанию для всего экземпляра. Пользователи, не изменившие параметр, увидят эти значения. Их собственные изменения всегда имеют приоритет.',
'admin.defaultSettings.saved': 'Значение по умолчанию сохранено',
'admin.defaultSettings.reset': 'Сбросить до встроенного значения',
'admin.defaultSettings.resetToBuiltIn': 'сбросить',
'admin.tabs.templates': 'Шаблоны упаковки',
'admin.packingTemplates.title': 'Шаблоны упаковки',
'admin.packingTemplates.subtitle': 'Создавайте многоразовые списки вещей для поездок',
@@ -1002,6 +1017,7 @@ const ru: Record<string, string> = {
'reservations.meta.platform': 'Платформа',
'reservations.meta.seat': 'Место',
'reservations.meta.checkIn': 'Заезд',
'reservations.meta.checkInUntil': 'Заселение до',
'reservations.meta.checkOut': 'Выезд',
'reservations.meta.linkAccommodation': 'Жильё',
'reservations.meta.pickAccommodation': 'Привязать к жилью',
@@ -1486,6 +1502,7 @@ const ru: Record<string, string> = {
'day.noPlacesForHotel': 'Сначала добавьте места в поездку',
'day.allDays': 'Все',
'day.checkIn': 'Заезд',
'day.checkInUntil': 'До',
'day.checkOut': 'Выезд',
'day.confirmation': 'Подтверждение',
'day.editAccommodation': 'Редактировать жильё',
@@ -1759,14 +1776,26 @@ const ru: Record<string, string> = {
'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.ntfyUrl.topicLabel': 'Тема Ntfy',
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
'settings.ntfyUrl.serverLabel': 'URL сервера Ntfy (необязательно)',
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
'settings.ntfyUrl.hint': 'Введите тему Ntfy для получения push-уведомлений. Оставьте поле сервера пустым, чтобы использовать настройку по умолчанию, заданную администратором.',
'settings.ntfyUrl.tokenLabel': 'Токен доступа (необязательно)',
'settings.ntfyUrl.tokenHint': 'Требуется для тем, защищённых паролем.',
'settings.ntfyUrl.saved': 'Настройки Ntfy сохранены',
'settings.ntfyUrl.test': 'Тест',
'settings.ntfyUrl.testSuccess': 'Тестовое уведомление Ntfy успешно отправлено',
'settings.ntfyUrl.testFailed': 'Ошибка отправки тестового уведомления Ntfy',
'settings.ntfyUrl.tokenCleared': 'Токен доступа очищен',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
@@ -1777,6 +1806,25 @@ const ru: Record<string, string> = {
'admin.notifications.adminWebhookPanel.testSuccess': 'Тестовый вебхук успешно отправлен',
'admin.notifications.adminWebhookPanel.testFailed': 'Ошибка тестового вебхука',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Вебхук администратора отправляется автоматически при наличии URL',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Позволяет пользователям настраивать собственные темы ntfy для push-уведомлений. Установите сервер по умолчанию ниже, чтобы предварительно заполнить настройки пользователей.',
'admin.notifications.testNtfy': 'Отправить тестовое Ntfy',
'admin.notifications.testNtfySuccess': 'Тестовое Ntfy успешно отправлено',
'admin.notifications.testNtfyFailed': 'Ошибка отправки тестового Ntfy',
'admin.notifications.adminNtfyPanel.title': 'Ntfy администратора',
'admin.notifications.adminNtfyPanel.hint': 'Эта тема Ntfy используется исключительно для уведомлений администратора (например, оповещения о версиях). Она независима от тем пользователей и всегда отправляется при наличии настройки.',
'admin.notifications.adminNtfyPanel.serverLabel': 'URL сервера Ntfy',
'admin.notifications.adminNtfyPanel.serverHint': 'Также используется как сервер по умолчанию для ntfy-уведомлений пользователей. Оставьте пустым, чтобы использовать ntfy.sh. Пользователи могут изменить это в своих настройках.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Тема администратора',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Токен доступа (необязательно)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Токен доступа администратора очищен',
'admin.notifications.adminNtfyPanel.saved': 'Настройки Ntfy администратора сохранены',
'admin.notifications.adminNtfyPanel.test': 'Отправить тестовое Ntfy',
'admin.notifications.adminNtfyPanel.testSuccess': 'Тестовое Ntfy успешно отправлено',
'admin.notifications.adminNtfyPanel.testFailed': 'Ошибка отправки тестового Ntfy',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Ntfy администратора всегда отправляется при наличии настроенной темы',
'admin.notifications.adminNotificationsHint': 'Настройте, какие каналы доставляют уведомления администратора (например, оповещения о версиях). Вебхук отправляется автоматически, если задан URL вебхука администратора.',
'admin.tabs.notifications': 'Уведомления',
'notifications.versionAvailable.title': 'Доступно обновление',
@@ -2134,6 +2182,50 @@ const ru: Record<string, string> = {
'oauth.scope.geo:read.description': 'Поиск мест, разрешение URL карт и обратное геокодирование координат',
'oauth.scope.weather:read.label': 'Прогнозы погоды',
'oauth.scope.weather:read.description': 'Получение прогнозов погоды для мест и дат поездки',
// System notices
'system_notice.welcome_v1.title': 'Добро пожаловать в TREK',
'system_notice.welcome_v1.body': 'Ваш универсальный планировщик путешествий. Создавайте маршруты, делитесь поездками с друзьями и оставайтесь организованными — онлайн и офлайн.',
'system_notice.welcome_v1.cta_label': 'Спланировать поездку',
'system_notice.welcome_v1.hero_alt': 'Живописное место назначения с интерфейсом TREK',
'system_notice.welcome_v1.highlight_plan': 'Маршруты по дням',
'system_notice.welcome_v1.highlight_share': 'Совместное планирование с партнёрами',
'system_notice.welcome_v1.highlight_offline': 'Работает офлайн на мобильном',
'system_notice.dev_test_modal.title': '[Dev] Test notice',
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
'system_notice.pager.prev': 'Предыдущее уведомление',
'system_notice.pager.next': 'Следующее уведомление',
'system_notice.pager.counter': '{current} / {total}',
'system_notice.pager.goto': 'Перейти к уведомлению {n}',
'system_notice.pager.position': 'Уведомление {current} из {total}',
// System notices — 3.0.0 upgrade
'system_notice.v3_photos.title': 'Фото перемещены в версии 3.0',
'system_notice.v3_photos.body': 'Вкладка **Фото** в Планировщике путешествий удалена. Ваши фото в безопасности — TREK никогда не изменял вашу библиотеку Immich или Synology.\n\nФото теперь доступны в дополнении **Journey**. Journey необязателен — если он ещё недоступен, попросите администратора включить его в разделе Admin → Дополнения.',
'system_notice.v3_journey.title': 'Знакомьтесь с Journey',
'system_notice.v3_journey.body': 'Документируйте путешествия в виде рассказов с хронологиями, фотогалереями и интерактивными картами.',
'system_notice.v3_journey.cta_label': 'Открыть Journey',
'system_notice.v3_journey.highlight_timeline': 'Ежедневная хронология и галерея',
'system_notice.v3_journey.highlight_photos': 'Импорт из Immich или Synology',
'system_notice.v3_journey.highlight_share': 'Общий доступ — без входа',
'system_notice.v3_journey.highlight_export': 'Экспорт в PDF-фотокнигу',
'system_notice.v3_features.title': 'Ещё нового в версии 3.0',
'system_notice.v3_features.body': 'Несколько других важных новшеств в этом релизе.',
'system_notice.v3_features.highlight_dashboard': 'Переработанная панель в mobile-first стиле',
'system_notice.v3_features.highlight_offline': 'Полный офлайн-режим как PWA',
'system_notice.v3_features.highlight_search': 'Автодополнение поиска мест в реальном времени',
'system_notice.v3_features.highlight_import': 'Импорт мест из KMZ/KML-файлов',
// System notices — MCP OAuth 2.1 upgrade
'system_notice.v3_mcp.title': 'MCP: обновление OAuth 2.1',
'system_notice.v3_mcp.body': 'Интеграция MCP была полностью переработана. OAuth 2.1 теперь является рекомендуемым методом аутентификации. Статические токены (trek_…) устарели и будут удалены в будущей версии.',
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 рекомендуется (mcp-remote)',
'system_notice.v3_mcp.highlight_scopes': '24 детальных области разрешений',
'system_notice.v3_mcp.highlight_deprecated': 'Статические токены trek_ устарели',
'system_notice.v3_mcp.highlight_tools': 'Расширенный набор инструментов',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Личное слово от меня',
'system_notice.v3_thankyou.body': 'Прежде чем продолжить — хочу остановиться на мгновение.\n\nTREK начинался как сторонний проект, который я создал для собственных поездок. Я никогда не думал, что он вырастет во что-то, чему 4 000 из вас доверяют планирование своих приключений. Каждая звёздочка, каждый issue, каждый запрос на фичу — я читаю их все, и именно они поддерживают меня в поздние ночи между основной работой и университетом.\n\nХочу, чтобы вы знали: TREK всегда будет open source, всегда self-hosted, всегда вашим. Никакого отслеживания, никаких подписок, никаких подвохов. Просто инструмент, созданный человеком, который любит путешествовать так же, как и вы.\n\nОсобая благодарность [jubnl](https://github.com/jubnl) — ты стал невероятным соратником. Многое из того, что делает версию 3.0 великолепной, несёт твой отпечаток. Спасибо, что поверил в этот проект, когда он был ещё сырым.\n\nИ каждому из вас, кто сообщил об ошибке, перевёл строку, поделился TREK с другом или просто использовал его для планирования поездки — **спасибо**. Вы — причина, по которой всё это существует.\n\nЗа множество новых приключений вместе.\n\n— Maurice\n\n---\n\n[Присоединяйся к сообществу в Discord](https://discord.gg/7Q6M6jDwzf)\n\nЕсли TREK делает твои путешествия лучше, [маленький кофе](https://ko-fi.com/mauriceboe) всегда помогает держать свет включённым.',
}
export default ru
+93 -1
View File
@@ -4,6 +4,7 @@ const zh: Record<string, string> = {
'common.showMore': '显示更多',
'common.showLess': '收起',
'common.cancel': '取消',
'common.clear': '清除',
'common.delete': '删除',
'common.edit': '编辑',
'common.add': '添加',
@@ -547,7 +548,21 @@ const zh: Record<string, string> = {
'admin.bagTracking.title': '行李追踪',
'admin.bagTracking.subtitle': '为打包物品启用重量和行李分配',
'admin.collab.chat.title': '聊天',
'admin.collab.chat.subtitle': '实时消息协作',
'admin.collab.notes.title': '笔记',
'admin.collab.notes.subtitle': '共享笔记和文档',
'admin.collab.polls.title': '投票',
'admin.collab.polls.subtitle': '群组投票和表决',
'admin.collab.whatsnext.title': '下一步',
'admin.collab.whatsnext.subtitle': '活动建议和后续步骤',
'admin.tabs.config': '个性化',
'admin.tabs.defaults': '用户默认设置',
'admin.defaultSettings.title': '用户默认设置',
'admin.defaultSettings.description': '设置实例范围的默认值。未更改设置的用户将看到这些值。用户自己的更改始终优先。',
'admin.defaultSettings.saved': '默认值已保存',
'admin.defaultSettings.reset': '重置为内置默认值',
'admin.defaultSettings.resetToBuiltIn': '重置',
'admin.tabs.templates': '打包模板',
'admin.packingTemplates.title': '打包模板',
'admin.packingTemplates.subtitle': '创建可复用的旅行打包清单',
@@ -1002,6 +1017,7 @@ const zh: Record<string, string> = {
'reservations.meta.platform': '站台',
'reservations.meta.seat': '座位',
'reservations.meta.checkIn': '入住',
'reservations.meta.checkInUntil': '入住截止',
'reservations.meta.checkOut': '退房',
'reservations.meta.linkAccommodation': '住宿',
'reservations.meta.pickAccommodation': '关联住宿',
@@ -1486,6 +1502,7 @@ const zh: Record<string, string> = {
'day.noPlacesForHotel': '请先在旅行中添加地点',
'day.allDays': '全部',
'day.checkIn': '入住',
'day.checkInUntil': '截止',
'day.checkOut': '退房',
'day.confirmation': '确认号',
'day.editAccommodation': '编辑住宿',
@@ -1759,14 +1776,26 @@ const zh: Record<string, string> = {
'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.ntfyUrl.topicLabel': 'Ntfy 主题',
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
'settings.ntfyUrl.serverLabel': 'Ntfy 服务器 URL(可选)',
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
'settings.ntfyUrl.hint': '输入您的 Ntfy 主题以接收推送通知。将服务器留空以使用管理员配置的默认值。',
'settings.ntfyUrl.tokenLabel': '访问令牌(可选)',
'settings.ntfyUrl.tokenHint': '受密码保护的主题需要此项。',
'settings.ntfyUrl.saved': 'Ntfy 设置已保存',
'settings.ntfyUrl.test': '测试',
'settings.ntfyUrl.testSuccess': '测试 Ntfy 通知发送成功',
'settings.ntfyUrl.testFailed': '测试 Ntfy 通知失败',
'settings.ntfyUrl.tokenCleared': '访问令牌已清除',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.email': 'Email',
'settings.notificationPreferences.ntfy': 'Ntfy',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
@@ -1777,6 +1806,25 @@ const zh: Record<string, string> = {
'admin.notifications.adminWebhookPanel.testSuccess': '测试 Webhook 发送成功',
'admin.notifications.adminWebhookPanel.testFailed': '测试 Webhook 失败',
'admin.notifications.adminWebhookPanel.alwaysOnHint': '配置 URL 后管理员 Webhook 自动触发',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': '允许用户配置自己的 ntfy 主题以接收推送通知。在下方设置默认服务器以预填充用户设置。',
'admin.notifications.testNtfy': '发送测试 Ntfy',
'admin.notifications.testNtfySuccess': '测试 Ntfy 发送成功',
'admin.notifications.testNtfyFailed': '测试 Ntfy 失败',
'admin.notifications.adminNtfyPanel.title': '管理员 Ntfy',
'admin.notifications.adminNtfyPanel.hint': '此 Ntfy 主题专用于管理员通知(如版本更新提醒)。它与每用户主题相互独立,配置后始终触发。',
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy 服务器 URL',
'admin.notifications.adminNtfyPanel.serverHint': '同时用作用户 ntfy 通知的默认服务器。留空则默认使用 ntfy.sh。用户可在其自己的设置中覆盖此项。',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': '管理员主题',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': '访问令牌(可选)',
'admin.notifications.adminNtfyPanel.tokenCleared': '管理员访问令牌已清除',
'admin.notifications.adminNtfyPanel.saved': '管理员 Ntfy 设置已保存',
'admin.notifications.adminNtfyPanel.test': '发送测试 Ntfy',
'admin.notifications.adminNtfyPanel.testSuccess': '测试 Ntfy 发送成功',
'admin.notifications.adminNtfyPanel.testFailed': '测试 Ntfy 失败',
'admin.notifications.adminNtfyPanel.alwaysOnHint': '配置主题后管理员 Ntfy 始终触发',
'admin.notifications.adminNotificationsHint': '配置哪些渠道发送管理员通知(如版本更新提醒)。设置管理员 Webhook URL 后,Webhook 将自动触发。',
'admin.tabs.notifications': '通知',
'notifications.versionAvailable.title': '有可用更新',
@@ -2134,6 +2182,50 @@ const zh: Record<string, string> = {
'oauth.scope.geo:read.description': '搜索位置、解析地图 URL 和反向地理编码坐标',
'oauth.scope.weather:read.label': '天气预报',
'oauth.scope.weather:read.description': '获取行程地点和日期的天气预报',
// System notices
'system_notice.welcome_v1.title': '欢迎使用 TREK',
'system_notice.welcome_v1.body': '您的全能旅行规划器。制定行程、与朋友分享旅行,随时保持井然有序——在线或离线均可。',
'system_notice.welcome_v1.cta_label': '规划行程',
'system_notice.welcome_v1.hero_alt': '风景优美的旅游目的地与 TREK 界面',
'system_notice.welcome_v1.highlight_plan': '逐日行程规划',
'system_notice.welcome_v1.highlight_share': '与旅行伙伴协作',
'system_notice.welcome_v1.highlight_offline': '移动端支持离线使用',
'system_notice.dev_test_modal.title': '[Dev] Test notice',
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
'system_notice.pager.prev': '上一条通知',
'system_notice.pager.next': '下一条通知',
'system_notice.pager.counter': '{current} / {total}',
'system_notice.pager.goto': '转到通知 {n}',
'system_notice.pager.position': '通知 {current}/{total}',
// System notices — 3.0.0 upgrade
'system_notice.v3_photos.title': '3.0 版照片已迁移',
'system_notice.v3_photos.body': '行程规划器中的​**照片**标签已被移除。您的照片安全无虑 — TREK 从未修改您的 Immich 或 Synology 相册。\n\n照片现在位于 **Journey** 插件中。Journey 是可选的 — 如果尚未启用,请联系管理员在 Admin → 插件 中开启。',
'system_notice.v3_journey.title': '认识 Journey — 旅行日记',
'system_notice.v3_journey.body': '将您的旅程记录为展示时间线、照片画廊和互动地图的丰富旅行故事。',
'system_notice.v3_journey.cta_label': '打开 Journey',
'system_notice.v3_journey.highlight_timeline': '每日时间线与画廊',
'system_notice.v3_journey.highlight_photos': '从 Immich 或 Synology 导入',
'system_notice.v3_journey.highlight_share': '公开分享 — 无需登录',
'system_notice.v3_journey.highlight_export': '导出为 PDF 相册书',
'system_notice.v3_features.title': '3.0 版更多亮点',
'system_notice.v3_features.body': '此版本还有一些其他值得了解的新功能。',
'system_notice.v3_features.highlight_dashboard': '移动优先仪表板重设计',
'system_notice.v3_features.highlight_offline': '作为 PWA 的完整离线模式',
'system_notice.v3_features.highlight_search': '地点搜索实时自动补全',
'system_notice.v3_features.highlight_import': '从 KMZ/KML 文件导入地点',
// System notices — MCP OAuth 2.1 upgrade
'system_notice.v3_mcp.title': 'MCPOAuth 2.1 升级',
'system_notice.v3_mcp.body': 'MCP 集成已全面重构。OAuth 2.1 现为推荐的身份验证方式。静态令牌(trek_…)已弃用,将在未来版本中移除。',
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 推荐(mcp-remote',
'system_notice.v3_mcp.highlight_scopes': '24 个细粒度权限范围',
'system_notice.v3_mcp.highlight_deprecated': '静态 trek_ 令牌已弃用',
'system_notice.v3_mcp.highlight_tools': '扩展工具集与提示词',
// System notices — personal thank you
'system_notice.v3_thankyou.title': '来自我的一封私人信',
'system_notice.v3_thankyou.body': '在你继续之前——我想停下来说几句。\n\nTREK 最初只是我为自己的旅行而做的一个业余项目。我从未想过它会成长为 4,000 人信赖的冒险规划工具。每一颗星标、每一个 issue、每一个功能请求——我都会读,它们在全职工作和大学学业之间的深夜里支撑着我继续前行。\n\n我想让你们知道:TREK 将永远开源,永远可自托管,永远属于你们。没有追踪,没有订阅,没有任何附加条件。只是一个热爱旅行的人为同样热爱旅行的你们打造的工具。\n\n特别感谢 [jubnl](https://github.com/jubnl)——你已经成为一位不可思议的合作者。3.0 版本中许多精彩之处都留下了你的印记。感谢你在这个项目还很粗糙的时候就选择了相信它。\n\n也感谢你们每一位——报告了 bug、翻译了文本、向朋友分享了 TREK,或者只是用它规划了一次旅行——**谢谢你们**。你们是这一切存在的原因。\n\n愿我们一起踏上更多的冒险旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社区](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 让你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能让这盏灯一直亮着。',
}
export default zh
+93 -1
View File
@@ -4,6 +4,7 @@ const zhTw: Record<string, string> = {
'common.showMore': '顯示更多',
'common.showLess': '收起',
'common.cancel': '取消',
'common.clear': '清除',
'common.delete': '刪除',
'common.edit': '編輯',
'common.add': '新增',
@@ -186,15 +187,27 @@ const zhTw: Record<string, string> = {
'settings.notificationPreferences.email': '電子郵件',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': '應用程式內',
'settings.notificationPreferences.ntfy': 'Ntfy',
'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.ntfyUrl.topicLabel': 'Ntfy 主題',
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
'settings.ntfyUrl.serverLabel': 'Ntfy 伺服器 URL(選填)',
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
'settings.ntfyUrl.hint': '輸入您的 Ntfy 主題以接收推播通知。將伺服器留空以使用管理員設定的預設值。',
'settings.ntfyUrl.tokenLabel': '存取權杖(選填)',
'settings.ntfyUrl.tokenHint': '受密碼保護的主題需要此項目。',
'settings.ntfyUrl.saved': 'Ntfy 設定已儲存',
'settings.ntfyUrl.test': '測試',
'settings.ntfyUrl.testSuccess': '測試 Ntfy 通知傳送成功',
'settings.ntfyUrl.testFailed': '測試 Ntfy 通知失敗',
'settings.ntfyUrl.tokenCleared': '存取權杖已清除',
'settings.notificationsDisabled': '通知尚未配置。請聯絡管理員啟用電子郵件或 Webhook 通知。',
'settings.notificationsActive': '活躍頻道',
'settings.notificationsManagedByAdmin': '通知事件由管理員配置。',
@@ -218,6 +231,25 @@ const zhTw: Record<string, string> = {
'admin.notifications.adminWebhookPanel.testSuccess': '測試 Webhook 傳送成功',
'admin.notifications.adminWebhookPanel.testFailed': '測試 Webhook 傳送失敗',
'admin.notifications.adminWebhookPanel.alwaysOnHint': '配置 URL 後,管理員 Webhook 始終觸發',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': '允許使用者設定自己的 ntfy 主題以接收推播通知。在下方設定預設伺服器以預先填入使用者設定。',
'admin.notifications.testNtfy': '傳送測試 Ntfy',
'admin.notifications.testNtfySuccess': '測試 Ntfy 傳送成功',
'admin.notifications.testNtfyFailed': '測試 Ntfy 失敗',
'admin.notifications.adminNtfyPanel.title': '管理員 Ntfy',
'admin.notifications.adminNtfyPanel.hint': '此 Ntfy 主題專用於管理員通知(例如版本提醒)。它與每位使用者的主題分開,設定後始終會觸發。',
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy 伺服器 URL',
'admin.notifications.adminNtfyPanel.serverHint': '同時用作使用者 ntfy 通知的預設伺服器。留空則預設使用 ntfy.sh。使用者可在自己的設定中覆寫此項。',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': '管理員主題',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': '存取權杖(選填)',
'admin.notifications.adminNtfyPanel.tokenCleared': '管理員存取權杖已清除',
'admin.notifications.adminNtfyPanel.saved': '管理員 Ntfy 設定已儲存',
'admin.notifications.adminNtfyPanel.test': '傳送測試 Ntfy',
'admin.notifications.adminNtfyPanel.testSuccess': '測試 Ntfy 傳送成功',
'admin.notifications.adminNtfyPanel.testFailed': '測試 Ntfy 失敗',
'admin.notifications.adminNtfyPanel.alwaysOnHint': '設定主題後管理員 Ntfy 始終觸發',
'admin.notifications.adminNotificationsHint': '配置哪些渠道傳遞僅管理員通知(例如版本提醒)。',
'admin.smtp.title': '郵件與通知',
'admin.smtp.hint': '用於傳送電子郵件通知的 SMTP 配置。',
@@ -572,7 +604,21 @@ const zhTw: Record<string, string> = {
'admin.bagTracking.title': '行李追蹤',
'admin.bagTracking.subtitle': '為打包物品啟用重量和行李分配',
'admin.collab.chat.title': '聊天',
'admin.collab.chat.subtitle': '即時訊息協作',
'admin.collab.notes.title': '筆記',
'admin.collab.notes.subtitle': '共享筆記和文件',
'admin.collab.polls.title': '投票',
'admin.collab.polls.subtitle': '群組投票和表決',
'admin.collab.whatsnext.title': '下一步',
'admin.collab.whatsnext.subtitle': '活動建議和後續步驟',
'admin.tabs.config': '配置',
'admin.tabs.defaults': '用戶預設設定',
'admin.defaultSettings.title': '用戶預設設定',
'admin.defaultSettings.description': '設定整個執行個體的預設值。未更改設定的用戶將看到這些值。用戶自己的更改始終優先。',
'admin.defaultSettings.saved': '預設值已儲存',
'admin.defaultSettings.reset': '重設為內建預設值',
'admin.defaultSettings.resetToBuiltIn': '重設',
'admin.tabs.templates': '打包模板',
'admin.packingTemplates.title': '打包模板',
'admin.packingTemplates.subtitle': '建立可複用的旅行打包清單',
@@ -1027,6 +1073,7 @@ const zhTw: Record<string, string> = {
'reservations.meta.platform': '站臺',
'reservations.meta.seat': '座位',
'reservations.meta.checkIn': '入住',
'reservations.meta.checkInUntil': '入住截止',
'reservations.meta.checkOut': '退房',
'reservations.meta.linkAccommodation': '住宿',
'reservations.meta.pickAccommodation': '關聯住宿',
@@ -1511,6 +1558,7 @@ const zhTw: Record<string, string> = {
'day.noPlacesForHotel': '請先在旅行中新增地點',
'day.allDays': '全部',
'day.checkIn': '入住',
'day.checkInUntil': '截止',
'day.checkOut': '退房',
'day.confirmation': '確認號',
'day.editAccommodation': '編輯住宿',
@@ -2135,6 +2183,50 @@ const zhTw: Record<string, string> = {
'oauth.scope.geo:read.description': '搜尋地點、解析地圖 URL 及反向地理編碼坐標',
'oauth.scope.weather:read.label': '天氣預報',
'oauth.scope.weather:read.description': '取得行程地點及日期的天氣預報',
// System notices
'system_notice.welcome_v1.title': '歡迎使用 TREK',
'system_notice.welcome_v1.body': '您的全方位旅遊規劃器。建立行程、與朋友分享旅遊,隨時保持條理分明——無論線上或離線皆可。',
'system_notice.welcome_v1.cta_label': '規劃行程',
'system_notice.welcome_v1.hero_alt': '風景優美的旅遊目的地與 TREK 介面',
'system_notice.welcome_v1.highlight_plan': '逐日行程規劃',
'system_notice.welcome_v1.highlight_share': '與旅伴協作規劃',
'system_notice.welcome_v1.highlight_offline': '行動裝置支援離線使用',
'system_notice.dev_test_modal.title': '[Dev] Test notice',
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
'system_notice.pager.prev': '上一則通知',
'system_notice.pager.next': '下一則通知',
'system_notice.pager.counter': '{current} / {total}',
'system_notice.pager.goto': '前往通知 {n}',
'system_notice.pager.position': '通知 {current}/{total}',
// System notices — 3.0.0 upgrade
'system_notice.v3_photos.title': '3.0 版相片已移至',
'system_notice.v3_photos.body': '行程規劃器中的​**相片**標籤已被移除。您的相片安全— TREK 從未修改您的 Immich 或 Synology 相簿。\n\n相片現在位於 **Journey** 附加元件中。Journey 為選用 — 若尚未啟用,請聯絡管理員於 Admin → 附加元件 中開啟。',
'system_notice.v3_journey.title': '認識 Journey — 旅行日記',
'system_notice.v3_journey.body': '將您的旅程記錄為具有時間軸、相片畫庫與互動地圖的豐富旅行故事。',
'system_notice.v3_journey.cta_label': '開啟 Journey',
'system_notice.v3_journey.highlight_timeline': '每日時間軸與畫庫',
'system_notice.v3_journey.highlight_photos': '從 Immich 或 Synology 匯入',
'system_notice.v3_journey.highlight_share': '公開分享 — 無需登入',
'system_notice.v3_journey.highlight_export': '匯出為 PDF 相簿书',
'system_notice.v3_features.title': '3.0 版更多亮點',
'system_notice.v3_features.body': '這個版本還有一些其他專項值得了解。',
'system_notice.v3_features.highlight_dashboard': '行動先行儀表板重設計',
'system_notice.v3_features.highlight_offline': '作為 PWA 的完整離線模式',
'system_notice.v3_features.highlight_search': '地點搜尋即時自動補全',
'system_notice.v3_features.highlight_import': '從 KMZ/KML 檔案匯入地點',
// System notices — MCP OAuth 2.1 upgrade
'system_notice.v3_mcp.title': 'MCPOAuth 2.1 升級',
'system_notice.v3_mcp.body': 'MCP 整合已全面重構。OAuth 2.1 現為建議的身份驗證方式。靜態令牌(trek_…)已棄用,將於未來版本移除。',
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 建議(mcp-remote',
'system_notice.v3_mcp.highlight_scopes': '24 個細粒度權限範圍',
'system_notice.v3_mcp.highlight_deprecated': '靜態 trek_ 令牌已棄用',
'system_notice.v3_mcp.highlight_tools': '擴展工具集與提示詞',
// System notices — personal thank you
'system_notice.v3_thankyou.title': '來自我的一封私人信',
'system_notice.v3_thankyou.body': '在你繼續之前——我想停下來說幾句。\n\nTREK 最初只是我為自己的旅行而做的一個業餘專案。我從未想過它會成長為 4,000 人信賴的冒險規劃工具。每一顆星標、每一個 issue、每一個功能請求——我都會讀,它們在全職工作和大學學業之間的深夜裡支撐著我繼續前行。\n\n我想讓你們知道:TREK 將永遠開源,永遠可自託管,永遠屬於你們。沒有追蹤,沒有訂閱,沒有任何附加條件。只是一個熱愛旅行的人為同樣熱愛旅行的你們打造的工具。\n\n特別感謝 [jubnl](https://github.com/jubnl)——你已經成為一位不可思議的合作者。3.0 版本中許多精彩之處都留下了你的印記。感謝你在這個專案還很粗糙的時候就選擇了相信它。\n\n也感謝你們每一位——回報了 bug、翻譯了文字、向朋友分享了 TREK,或者只是用它規劃了一次旅行——**謝謝你們**。你們是這一切存在的原因。\n\n願我們一起踏上更多的冒險旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社群](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 讓你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能讓這盞燈一直亮著。',
}
export default zhTw
+7 -9
View File
@@ -323,7 +323,7 @@ body {
display: none;
}
/* Scrollbalken */
/* Scrollbars — styled on desktop, hidden on mobile */
::-webkit-scrollbar {
width: 6px;
height: 6px;
@@ -333,21 +333,23 @@ body {
height: 0;
width: 0;
}
::-webkit-scrollbar-track {
background: var(--scrollbar-track);
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-hover);
}
@media (max-width: 767px) {
* { scrollbar-width: none; }
::-webkit-scrollbar { width: 0; height: 0; }
}
.route-info-pill { background: none !important; border: none !important; box-shadow: none !important; width: auto !important; height: auto !important; margin: 0 !important; }
.chat-scroll { overflow-y: auto !important; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
.chat-scroll::-webkit-scrollbar { width: 0; background: transparent; }
@@ -405,6 +407,7 @@ img[alt="TREK"] {
}
.scroll-container {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
@@ -447,11 +450,6 @@ img[alt="TREK"] {
color-scheme: dark;
}
/* Scroll-Container */
.scroll-container {
scrollbar-width: thin;
scrollbar-color: #d1d5db #f1f5f9;
}
/* Toast-Animationen */
@keyframes slideUp {
+138 -6
View File
@@ -2,6 +2,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 DefaultUserSettingsTab from '../components/Admin/DefaultUserSettingsTab'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { useAddonStore } from '../store/addonStore'
@@ -66,6 +67,7 @@ const ADMIN_CHANNEL_LABEL_KEYS: Record<string, string> = {
inapp: 'settings.notificationPreferences.inapp',
email: 'settings.notificationPreferences.email',
webhook: 'settings.notificationPreferences.webhook',
ntfy: 'settings.notificationPreferences.ntfy',
}
function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast: ReturnType<typeof useToast> }) {
@@ -78,7 +80,7 @@ function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast
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 => {
const visibleChannels = (['inapp', 'email', 'webhook', 'ntfy'] as const).filter(ch => {
if (!matrix.available_channels[ch]) return false
return matrix.event_types.some((evt: string) => matrix.implemented_combos[evt]?.includes(ch))
})
@@ -168,6 +170,7 @@ export default function AdminPage(): React.ReactElement {
const TABS = [
{ id: 'users', label: t('admin.tabs.users') },
{ id: 'config', label: t('admin.tabs.config') },
{ id: 'defaults', label: t('admin.tabs.defaults') },
{ id: 'addons', label: t('admin.tabs.addons') },
{ id: 'settings', label: t('admin.tabs.settings') },
{ id: 'notifications', label: t('admin.tabs.notifications') },
@@ -191,6 +194,10 @@ export default function AdminPage(): React.ReactElement {
const [bagTrackingEnabled, setBagTrackingEnabled] = useState<boolean>(false)
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
// Collab features
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true })
useEffect(() => { adminApi.getCollabFeatures().then(d => setCollabFeatures(d)).catch(() => {}) }, [])
// OIDC config
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', discovery_url: '' })
const [savingOidc, setSavingOidc] = useState<boolean>(false)
@@ -796,6 +803,10 @@ export default function AdminPage(): React.ReactElement {
const next = !bagTrackingEnabled
setBagTrackingEnabled(next)
try { await adminApi.updateBagTracking(next) } catch { setBagTrackingEnabled(!next) }
}} collabFeatures={collabFeatures} onToggleCollabFeature={async (key: string) => {
const next = { ...collabFeatures, [key]: !collabFeatures[key] }
setCollabFeatures(next)
try { await adminApi.updateCollabFeatures({ [key]: next[key] }) } catch { setCollabFeatures(collabFeatures) }
}} />
</div>
)}
@@ -1168,15 +1179,16 @@ export default function AdminPage(): React.ReactElement {
const activeChans = rawChannels === 'none' ? [] : rawChannels.split(',').map((c: string) => c.trim())
const emailActive = activeChans.includes('email')
const webhookActive = activeChans.includes('webhook')
const ntfyActive = activeChans.includes('ntfy')
const setChannels = async (email: boolean, webhook: boolean) => {
const chans = [email && 'email', webhook && 'webhook'].filter(Boolean).join(',') || 'none'
const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => {
const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy'].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'
const reverted = [emailActive && 'email', webhookActive && 'webhook', ntfyActive && 'ntfy'].filter(Boolean).join(',') || 'none'
setSmtpValues(prev => ({ ...prev, notification_channels: reverted }))
toast.error(t('common.error'))
}
@@ -1207,7 +1219,7 @@ export default function AdminPage(): React.ReactElement {
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
</div>
<button
onClick={() => setChannels(!emailActive, webhookActive)}
onClick={() => setChannels(!emailActive, webhookActive, ntfyActive)}
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)' }}
>
@@ -1283,7 +1295,7 @@ export default function AdminPage(): React.ReactElement {
<p className="text-xs text-slate-400 mt-1">{t('admin.webhook.hint')}</p>
</div>
<button
onClick={() => setChannels(emailActive, !webhookActive)}
onClick={() => setChannels(emailActive, !webhookActive, ntfyActive)}
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)' }}
>
@@ -1293,6 +1305,24 @@ export default function AdminPage(): React.ReactElement {
</div>
</div>
{/* Ntfy 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.ntfy')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.ntfy.hint') || 'Allow users to configure their own ntfy topics for push notifications.'}</p>
</div>
<button
onClick={() => setChannels(emailActive, webhookActive, !ntfyActive)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
style={{ background: ntfyActive ? '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: ntfyActive ? '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">
@@ -1358,6 +1388,106 @@ export default function AdminPage(): React.ReactElement {
</div>
</div>
{/* Admin Ntfy 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.adminNtfyPanel.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminNtfyPanel.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.adminNtfyPanel.serverLabel')}</label>
<input
type="text"
value={smtpValues.admin_ntfy_server || ''}
onChange={e => setSmtpValues(prev => ({ ...prev, admin_ntfy_server: e.target.value }))}
placeholder={t('admin.notifications.adminNtfyPanel.serverPlaceholder')}
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('admin.notifications.adminNtfyPanel.serverHint')}</p>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminNtfyPanel.topicLabel')}</label>
<input
type="text"
value={smtpValues.admin_ntfy_topic || ''}
onChange={e => setSmtpValues(prev => ({ ...prev, admin_ntfy_topic: e.target.value }))}
placeholder={t('admin.notifications.adminNtfyPanel.topicPlaceholder')}
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-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminNtfyPanel.tokenLabel')}</label>
<div className="flex gap-2">
<input
type="password"
value={smtpValues.admin_ntfy_token === '••••••••' ? '' : smtpValues.admin_ntfy_token || ''}
onChange={e => setSmtpValues(prev => ({ ...prev, admin_ntfy_token: e.target.value }))}
placeholder={smtpValues.admin_ntfy_token === '••••••••' ? '••••••••' : ''}
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
{smtpValues.admin_ntfy_token === '••••••••' && (
<button
onClick={async () => {
try {
await authApi.updateAppSettings({ admin_ntfy_token: '' })
setSmtpValues(prev => ({ ...prev, admin_ntfy_token: '' }))
toast.success(t('admin.notifications.adminNtfyPanel.tokenCleared'))
} catch { toast.error(t('common.error')) }
}}
className="px-3 py-2 border border-red-300 text-red-600 rounded-lg text-sm font-medium hover:bg-red-50 transition-colors"
>
{t('common.clear')}
</button>
)}
</div>
</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_ntfy_server: smtpValues.admin_ntfy_server || '',
admin_ntfy_topic: smtpValues.admin_ntfy_topic || '',
...(smtpValues.admin_ntfy_token && smtpValues.admin_ntfy_token !== '••••••••'
? { admin_ntfy_token: smtpValues.admin_ntfy_token }
: {}),
})
toast.success(t('admin.notifications.adminNtfyPanel.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 topic = smtpValues.admin_ntfy_topic?.trim()
if (!topic) return
try {
const token = smtpValues.admin_ntfy_token && smtpValues.admin_ntfy_token !== '••••••••'
? smtpValues.admin_ntfy_token : null
const result = await notificationsApi.testNtfy({
topic,
server: smtpValues.admin_ntfy_server || null,
token,
})
if (result.success) toast.success(t('admin.notifications.adminNtfyPanel.testSuccess'))
else toast.error(result.error || t('admin.notifications.adminNtfyPanel.testFailed'))
} catch { toast.error(t('admin.notifications.adminNtfyPanel.testFailed')) }
}}
disabled={!smtpValues.admin_ntfy_topic?.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.notifications.adminNtfyPanel.test')}
</button>
</div>
</div>
</div>
<div className="mt-6">
<AdminNotificationsPanel t={t} toast={toast} />
@@ -1373,6 +1503,8 @@ export default function AdminPage(): React.ReactElement {
{activeTab === 'github' && <GitHubPanel isPrerelease={updateInfo?.is_prerelease ?? false} />}
{activeTab === 'defaults' && <DefaultUserSettingsTab />}
{activeTab === 'dev-notifications' && <DevNotificationsPanel />}
</div>
</div>
+9 -1
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { tripsApi } from '../api/client'
import { tripRepo } from '../repo/tripRepo'
import { useAuthStore } from '../store/authStore'
@@ -689,6 +689,7 @@ export default function DashboardPage(): React.ReactElement {
}
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const toast = useToast()
const { t, locale } = useTranslation()
const { demoMode, user } = useAuthStore()
@@ -709,6 +710,13 @@ export default function DashboardPage(): React.ReactElement {
return () => { document.body.style.overflow = '' }
}, [showWidgetSettings])
useEffect(() => {
if (searchParams.get('create') === '1') {
setShowForm(true)
setSearchParams({}, { replace: true })
}
}, [searchParams])
useEffect(() => { loadTrips() }, [])
const loadTrips = async () => {
+4 -1
View File
@@ -674,7 +674,10 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-027 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-027: Shows loading spinner before data loads', () => {
it('renders a spinner while journey data is loading', () => {
// Do NOT await the waitFor -- we check the loading state before data arrives
// Pre-seed the store into a loading state (current: null, loading: true).
// We can't rely on render() timing because RTL wraps in act(), which flushes
// all microtasks including the MSW response before render() returns.
useJourneyStore.setState({ loading: true, current: null });
render(<JourneyDetailPage />);
// The spinner has animate-spin class on a div
const spinner = document.querySelector('.animate-spin');
+167 -74
View File
@@ -20,6 +20,9 @@ import {
Laugh, Smile, Meh, Annoyed, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff,
} from 'lucide-react'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile'
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
const GRADIENTS = [
@@ -84,7 +87,9 @@ export default function JourneyDetailPage() {
const fullMapRef = useRef<JourneyMapHandle>(null)
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
const isMobile = useIsMobile()
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
const [viewingEntry, setViewingEntry] = useState<JourneyEntry | null>(null)
const [editingEntry, setEditingEntry] = useState<JourneyEntry | null>(null)
const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null }[]; index: number } | null>(null)
const [deleteTarget, setDeleteTarget] = useState<JourneyEntry | null>(null)
@@ -202,10 +207,60 @@ export default function JourneyDetailPage() {
const dayGroups = groupByDate(timelineEntries)
const sortedDates = [...dayGroups.keys()].sort()
const showMobileCombined = isMobile && view === 'timeline'
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<Navbar />
<div style={{ paddingTop: 'var(--nav-h, 0px)' }}>
{/* Mobile combined map+timeline (Polarsteps-style) — renders as fullscreen overlay */}
{showMobileCombined && (
<MobileMapTimeline
entries={timelineEntries}
mapEntries={sidebarMapItems}
dark={document.documentElement.classList.contains('dark')}
onEntryClick={(entry) => setViewingEntry(entry)}
onAddEntry={() => {
const today = new Date().toISOString().split('T')[0]
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
}}
/>
)}
{/* Fullscreen entry view (mobile) */}
{viewingEntry && (
<MobileEntryView
entry={viewingEntry}
onClose={() => setViewingEntry(null)}
onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }}
onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
/>
)}
{/* Floating tab toggle on mobile combined view */}
{showMobileCombined && (
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] left-4 z-30">
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
<button
onClick={() => setView('timeline')}
className="flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium bg-zinc-900 dark:bg-white text-white dark:text-zinc-900"
>
<MapPin size={13} />
{t('journey.detail.journeyTab') || 'Journey'}
</button>
<button
onClick={() => setView('gallery')}
className="flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
>
<Grid size={13} />
{t('journey.share.gallery')}
</button>
</div>
</div>
)}
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className={showMobileCombined ? 'hidden' : ''}>
<div className="max-w-[1440px] mx-auto px-0 md:px-8 pt-0 md:py-6">
{/* Back link — desktop */}
@@ -298,11 +353,17 @@ export default function JourneyDetailPage() {
{/* View Controls */}
<div className="flex items-center justify-between mt-5 mb-5">
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden">
{[
{ id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
{ id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
{ id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
].map(v => (
{(isMobile
? [
{ id: 'timeline' as const, icon: MapPin, label: t('journey.detail.journeyTab') || 'Journey' },
{ id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
]
: [
{ id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
{ id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
{ id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
]
).map(v => (
<button
key={v.id}
onClick={() => setView(v.id)}
@@ -317,21 +378,21 @@ export default function JourneyDetailPage() {
</button>
))}
</div>
{view === 'timeline' && (
{(!isMobile ? view === 'timeline' : view !== 'gallery') && (
<button
onClick={() => {
const today = new Date().toISOString().split('T')[0]
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
}}
className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center hover:bg-zinc-800 dark:hover:bg-zinc-100"
className={`w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center hover:bg-zinc-800 dark:hover:bg-zinc-100 ${isMobile && view === 'timeline' ? 'hidden' : ''}`}
>
<Plus size={16} />
</button>
)}
</div>
{/* Timeline */}
{view === 'timeline' && (
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */}
{!isMobile && view === 'timeline' && (
<div className="flex flex-col gap-6 pb-24 md:pb-6">
{sortedDates.length === 0 && (
<div className="text-center py-16">
@@ -398,8 +459,8 @@ export default function JourneyDetailPage() {
/>
)}
{/* Full Map View */}
{view === 'map' && <div className="pb-24 md:pb-6"><MapView
{/* Full Map View (desktop only — mobile uses combined view) */}
{!isMobile && view === 'map' && <div className="pb-24 md:pb-6"><MapView
entries={current.entries}
mapEntries={mapEntries}
sortedDates={sortedDates}
@@ -908,11 +969,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
{allPhotos.map(({ photo, entry }) => (
{allPhotos.map(({ photo, entry }, i) => (
<div
key={photo.id}
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
onClick={() => onPhotoClick(entry.photos, entry.photos.indexOf(photo))}
onClick={() => onPhotoClick(allPhotos.map(a => a.photo), i)}
>
<img
src={photoUrl(photo, 'thumbnail')}
@@ -1423,6 +1484,24 @@ function ScrollTrigger({ onVisible, loading }: { onVisible: () => void; loading:
)
}
// ── Photo date grouping ───────────────────────────────────────────────────
function groupPhotosByDate(photos: any[]): { date: string; label: string; assets: any[] }[] {
const map = new Map<string, any[]>()
for (const asset of photos) {
const key = asset.takenAt ? asset.takenAt.slice(0, 10) : '__unknown__'
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(asset)
}
return [...map.entries()].map(([date, assets]) => ({
date,
label: date === '__unknown__'
? 'Unknown date'
: new Date(date + 'T00:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }),
assets,
}))
}
// ── Provider Picker ───────────────────────────────────────────────────────
function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: {
@@ -1437,8 +1516,9 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
const { t } = useTranslation()
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
const [photos, setPhotos] = useState<any[]>([])
const [albums, setAlbums] = useState<any[]>([])
const [albums, setAlbums] = useState<Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>>([])
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
@@ -1500,13 +1580,14 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
searchPhotos(searchFrom, searchTo, searchPage + 1, true)
}
const loadAlbumPhotos = async (albumId: string) => {
const loadAlbumPhotos = async (album: { id: string; passphrase?: string }) => {
const signal = cancelPending()
setLoading(true)
setPhotos([])
setHasMore(false)
try {
const res = await fetch(`/api/integrations/memories/${provider}/albums/${albumId}/photos`, { credentials: 'include', signal })
const qs = album.passphrase ? `?passphrase=${encodeURIComponent(album.passphrase)}` : ''
const res = await fetch(`/api/integrations/memories/${provider}/albums/${album.id}/photos${qs}`, { credentials: 'include', signal })
if (res.ok) setPhotos((await res.json()).assets || [])
} catch (e: any) { if (e.name !== 'AbortError') {} }
if (!signal.aborted) setLoading(false)
@@ -1547,7 +1628,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
: t('journey.picker.newGallery')
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
@@ -1625,7 +1706,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{albums.map((a: any) => (
<button
key={a.id}
onClick={() => { setSelectedAlbum(a.id); loadAlbumPhotos(a.id) }}
onClick={() => { setSelectedAlbum(a.id); setSelectedAlbumPassphrase(a.passphrase); loadAlbumPhotos(a) }}
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap flex-shrink-0 border ${
selectedAlbum === a.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
@@ -1732,51 +1813,60 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</p>
</div>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5">
{photos.map((asset: any) => {
const isSelected = selected.has(asset.id)
const alreadyAdded = existingAssetIds.has(asset.id)
return (
<div
key={asset.id}
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
className={`relative aspect-square rounded-lg overflow-hidden ${
alreadyAdded
? 'opacity-40 cursor-not-allowed'
: isSelected
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
: 'cursor-pointer'
}`}
>
<img
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail`}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={e => {
const img = e.currentTarget
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original`
if (!img.src.includes('/original')) img.src = original
}}
/>
{alreadyAdded && (
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
<Check size={12} />
</div>
)}
{isSelected && !alreadyAdded && (
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
<Check size={12} />
</div>
)}
{asset.city && (
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
<p className="text-[8px] text-white truncate">{asset.city}</p>
</div>
)}
<div>
{groupPhotosByDate(photos).map(group => (
<div key={group.date}>
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 mb-2 mt-4 first:mt-0">
{group.label}
</p>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5 mb-1">
{group.assets.map((asset: any) => {
const isSelected = selected.has(asset.id)
const alreadyAdded = existingAssetIds.has(asset.id)
return (
<div
key={asset.id}
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
className={`relative aspect-square rounded-lg overflow-hidden ${
alreadyAdded
? 'opacity-40 cursor-not-allowed'
: isSelected
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
: 'cursor-pointer'
}`}
>
<img
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={e => {
const img = e.currentTarget
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`
if (!img.src.includes('/original')) img.src = original
}}
/>
{alreadyAdded && (
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
<Check size={12} />
</div>
)}
{isSelected && !alreadyAdded && (
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
<Check size={12} />
</div>
)}
{asset.city && (
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
<p className="text-[8px] text-white truncate">{asset.city}</p>
</div>
)}
</div>
)
})}
</div>
)
})}
</div>
))}
{/* Infinite scroll trigger */}
{hasMore && !selectedAlbum && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
</div>
@@ -2000,8 +2090,9 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
}
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden">
<div className="fixed inset-0 z-[9999] flex items-end sm:items-center sm:justify-center sm:p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]">
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}</h2>
@@ -2158,7 +2249,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
</div>
<div className="flex flex-col gap-1.5">
{pros.map((p, i) => (
<div key={i} className="flex items-center gap-2 h-9 px-3 bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-800/30 rounded-[10px]">
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0" />
<input
value={p}
@@ -2192,7 +2283,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
</div>
<div className="flex flex-col gap-1.5">
{cons.map((c, i) => (
<div key={i} className="flex items-center gap-2 h-9 px-3 bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-800/30 rounded-[10px]">
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0" />
<input
value={c}
@@ -2256,7 +2347,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
/>
{locationLat && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<MapPin size={13} className="text-emerald-500" />
<MapPin size={13} className="text-zinc-500 dark:text-zinc-400" />
</div>
)}
</div>
@@ -2303,8 +2394,10 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
const active = mood === key
return (
<button key={key} onClick={() => setMood(active ? '' : key)}
className="flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium border transition-all"
style={{ background: active ? config.bg : 'transparent', color: active ? config.text : '#71717A', borderColor: active ? config.text + '30' : '#E4E4E7' }}>
className={`flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium border transition-all ${
active ? '' : 'border-zinc-200 dark:border-zinc-700 text-zinc-500'
}`}
style={active ? { background: config.bg, color: config.text, borderColor: config.text + '30' } : undefined}>
<Icon size={12} />
{t(config.label)}
</button>
@@ -2334,7 +2427,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50" style={{ paddingBottom: 'max(16px, env(safe-area-inset-bottom, 16px))' }}>
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
<button onClick={handleSave} disabled={saving} className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50">
{saving ? t('common.saving') : t('common.save')}
@@ -2384,7 +2477,7 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
}
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
@@ -2481,7 +2574,7 @@ function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvite
}
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
@@ -2738,7 +2831,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
}
return (
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'var(--bottom-nav-h)' }} onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
+17 -2
View File
@@ -7,6 +7,8 @@ import { List, Grid, MapPin, Camera, BookOpen, Image } from 'lucide-react'
import JourneyMap from '../components/Journey/JourneyMap'
import JournalBody from '../components/Journey/JournalBody'
import PhotoLightbox from '../components/Journey/PhotoLightbox'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import { useIsMobile } from '../hooks/useIsMobile'
interface PublicEntry {
id: number
@@ -62,6 +64,7 @@ export default function JourneyPublicPage() {
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const isMobile = useIsMobile()
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null }[]; index: number } | null>(null)
const { t } = useTranslation()
@@ -202,8 +205,20 @@ export default function JourneyPublicPage() {
</div>
)}
{/* Timeline */}
{view === 'timeline' && perms.share_timeline && (
{/* Mobile combined map+timeline (public, read-only) */}
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
<MobileMapTimeline
entries={entries}
mapEntries={mapEntries.map(e => ({ id: String(e.id), lat: e.location_lat!, lng: e.location_lng!, title: e.title, mood: e.mood, entry_date: e.entry_date }))}
dark={document.documentElement.classList.contains('dark')}
readOnly
onEntryClick={() => {}}
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
/>
)}
{/* Timeline (desktop, or mobile without map permission) */}
{(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && (
<div className="flex flex-col gap-6">
{sortedDates.map(date => {
const dayEntries = groupedEntries.get(date)!
+37 -3
View File
@@ -100,6 +100,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
}, [undo, lastActionLabel, toast])
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true, collab: false })
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true })
const [tripAccommodations, setTripAccommodations] = useState<Accommodation[]>([])
const [allowedFileTypes, setAllowedFileTypes] = useState<string | null>(null)
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
@@ -116,6 +117,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
const map = {}
data.addons.forEach(a => { map[a.id] = true })
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab })
if (data.collabFeatures) setCollabFeatures(data.collabFeatures)
}).catch(() => {})
authApi.getAppConfig().then(config => {
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
@@ -166,6 +168,23 @@ export default function TripPlannerPage(): React.ReactElement | null {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
const connectionsStorageKey = tripId ? `trek:visible-connections:${tripId}` : null
const [visibleConnections, setVisibleConnections] = useState<number[]>(() => {
if (typeof window === 'undefined' || !connectionsStorageKey) return []
try {
const stored = window.localStorage.getItem(connectionsStorageKey)
return stored ? JSON.parse(stored) as number[] : []
} catch { return [] }
})
useEffect(() => {
if (typeof window === 'undefined' || !connectionsStorageKey) return
window.localStorage.setItem(connectionsStorageKey, JSON.stringify(visibleConnections))
}, [connectionsStorageKey, visibleConnections])
const toggleConnection = useCallback((id: number) => {
setVisibleConnections(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}, [])
const [mapTransportDetail, setMapTransportDetail] = useState<Reservation | null>(null)
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
useEffect(() => {
const mq = window.matchMedia('(max-width: 767px)')
@@ -246,7 +265,11 @@ export default function TripPlannerPage(): React.ReactElement | null {
return places.filter(p => {
if (!p.lat || !p.lng) return false
if (mapCategoryFilter.size > 0 && !mapCategoryFilter.has(String(p.category_id))) return false
if (mapCategoryFilter.size > 0) {
if (p.category_id == null) {
if (!mapCategoryFilter.has('uncategorized')) return false
} else if (!mapCategoryFilter.has(String(p.category_id))) return false
}
if (hiddenPlaceIds.has(p.id)) return false
if (plannedIds && plannedIds.has(p.id)) return false
return true
@@ -620,6 +643,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
rightWidth={rightCollapsed ? 0 : rightWidth}
hasInspector={!!selectedPlace}
hasDayDetail={!!showDayDetail && !selectedPlace}
reservations={reservations}
showReservationStats={settings.route_calculation !== false}
visibleConnectionIds={visibleConnections}
onReservationClick={(rid) => {
const r = reservations.find(x => x.id === rid)
if (r) setMapTransportDetail(r)
}}
/>
@@ -666,6 +696,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
onAssignToDay={handleAssignToDay}
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }}
reservations={reservations}
visibleConnectionIds={visibleConnections}
onToggleConnection={toggleConnection}
externalTransportDetail={mapTransportDetail}
onExternalTransportDetailHandled={() => setMapTransportDetail(null)}
onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true) }}
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }}
onRemoveAssignment={handleRemoveAssignment}
@@ -906,7 +940,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
)}
{activeTab === 'buchungen' && (
<div style={{ height: '100%', maxWidth: 1200, margin: '0 auto', width: '100%', display: 'flex', flexDirection: 'column', overflowY: 'auto', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
<div style={{ height: '100%', maxWidth: 1800, margin: '0 auto', width: '100%', display: 'flex', flexDirection: 'column', overflowY: 'auto', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
<ReservationsPanel
tripId={tripId}
reservations={reservations}
@@ -952,7 +986,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{activeTab === 'collab' && (
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
<CollabPanel tripId={tripId} tripMembers={tripMembers} />
<CollabPanel tripId={tripId} tripMembers={tripMembers} collabFeatures={collabFeatures} />
</div>
)}
</div>
+7
View File
@@ -0,0 +1,7 @@
import { registerNoticeAction } from '../../components/SystemNotices/noticeActions.js';
// Opens the new-trip creation modal on DashboardPage via URL param.
// DashboardPage reads ?create=1 on mount and calls setShowForm(true).
registerNoticeAction('open:trip-create', ({ navigate }) => {
navigate('/dashboard?create=1');
});
+9
View File
@@ -6,6 +6,7 @@ import type { User } from '../types'
import { getApiErrorMessage } from '../types'
import { tripSyncManager } from '../sync/tripSyncManager'
import { clearAll } from '../db/offlineDb'
import { useSystemNoticeStore } from './systemNoticeStore.js'
interface AuthResponse {
user: User
@@ -91,6 +92,9 @@ export const useAuthStore = create<AuthState>()(
})
connect()
tripSyncManager.syncAll().catch(console.error)
if (!data.user?.must_change_password) {
useSystemNoticeStore.getState().fetch()
}
return data as AuthResponse
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Login failed')
@@ -112,6 +116,9 @@ export const useAuthStore = create<AuthState>()(
})
connect()
tripSyncManager.syncAll().catch(console.error)
if (!data.user?.must_change_password) {
useSystemNoticeStore.getState().fetch()
}
return data as AuthResponse
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Verification failed')
@@ -133,6 +140,7 @@ export const useAuthStore = create<AuthState>()(
})
connect()
tripSyncManager.syncAll().catch(console.error)
useSystemNoticeStore.getState().fetch()
return data
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Registration failed')
@@ -143,6 +151,7 @@ export const useAuthStore = create<AuthState>()(
logout: () => {
disconnect()
useSystemNoticeStore.getState().reset()
// Tell server to clear the httpOnly cookie
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
// Clear service worker caches containing sensitive data
+41
View File
@@ -314,6 +314,47 @@ describe('journeyStore', () => {
expect(storedEntry?.photos[0].id).toBe(201);
});
// ── loadJourney silent refresh ───────────────────────────────────────────
it('FE-STORE-JOURNEY-016: loadJourney does not set loading when refreshing same journey', async () => {
const existing = buildJourneyDetail({ id: 5, title: 'Old' });
useJourneyStore.setState({ current: existing, loading: false });
const loadingValues: boolean[] = [];
const unsub = useJourneyStore.subscribe(s => loadingValues.push(s.loading));
const refreshed = buildJourneyDetail({ id: 5, title: 'Refreshed' });
server.use(
http.get('/api/journeys/5', () => HttpResponse.json(refreshed))
);
await useJourneyStore.getState().loadJourney(5);
unsub();
expect(loadingValues.every(v => v === false)).toBe(true);
expect(useJourneyStore.getState().current?.title).toBe('Refreshed');
});
it('FE-STORE-JOURNEY-017: loadJourney sets loading on cold load (different journey)', async () => {
const existing = buildJourneyDetail({ id: 5 });
useJourneyStore.setState({ current: existing, loading: false });
const loadingValues: boolean[] = [];
const unsub = useJourneyStore.subscribe(s => loadingValues.push(s.loading));
const other = buildJourneyDetail({ id: 99 });
server.use(
http.get('/api/journeys/99', () => HttpResponse.json(other))
);
await useJourneyStore.getState().loadJourney(99);
unsub();
expect(loadingValues).toContain(true);
expect(useJourneyStore.getState().current?.id).toBe(99);
expect(useJourneyStore.getState().loading).toBe(false);
});
// ── clear ────────────────────────────────────────────────────────────────
it('FE-STORE-JOURNEY-015: clear resets state', () => {
+3 -2
View File
@@ -124,7 +124,8 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
},
loadJourney: async (id) => {
set({ loading: true, notFound: false })
const cold = get().current?.id !== id
if (cold) set({ loading: true, notFound: false })
try {
const data = await journeyApi.get(id)
set({ current: data })
@@ -134,7 +135,7 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
}
throw err
} finally {
set({ loading: false })
if (cold) set({ loading: false })
}
},
+72
View File
@@ -0,0 +1,72 @@
import { create } from 'zustand';
import axios from '../api/client.js';
// Type mirrors SystemNoticeDTO from the server (copy here to avoid cross-package import)
export interface SystemNoticeDTO {
id: string;
display: 'modal' | 'banner' | 'toast';
severity: 'info' | 'warn' | 'critical';
titleKey: string;
bodyKey: string;
bodyParams?: Record<string, string>;
icon?: string;
media?: {
src: string;
srcDark?: string;
altKey: string;
placement?: 'hero' | 'inline';
aspectRatio?: string;
};
highlights?: Array<{ labelKey: string; iconName?: string }>;
cta?: (
| { kind: 'nav'; labelKey: string; href: string }
| { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean }
);
dismissible: boolean;
}
interface SystemNoticeState {
notices: SystemNoticeDTO[];
loaded: boolean;
fetching: boolean;
fetch: () => Promise<void>;
dismiss: (id: string) => void;
reset: () => void;
}
export const useSystemNoticeStore = create<SystemNoticeState>()((set, get) => ({
notices: [],
loaded: false,
fetching: false,
async fetch() {
if (get().fetching || get().loaded) return;
set({ fetching: true });
try {
const res = await axios.get<SystemNoticeDTO[]>('/system-notices/active');
set({ notices: res.data, loaded: true, fetching: false });
} catch (err) {
// Notices are non-critical. Fail silently; set loaded so UI doesn't hang.
console.warn('[systemNotices] failed to fetch:', err);
set({ loaded: true, fetching: false });
}
},
reset() {
set({ notices: [], loaded: false, fetching: false });
},
dismiss(id: string) {
// Optimistic: remove immediately
const prev = get().notices;
set({ notices: prev.filter(n => n.id !== id) });
// POST in background; retry once on error
const post = () => axios.post(`/system-notices/${id}/dismiss`);
post().catch(() => {
setTimeout(() => {
post().catch(e => console.warn('[systemNotices] dismiss failed:', e));
}, 2000);
});
},
}));
+17
View File
@@ -137,6 +137,20 @@ export interface BudgetMember {
paid: boolean
}
export interface ReservationEndpoint {
id?: number
reservation_id?: number
role: 'from' | 'to' | 'stop'
sequence: number
name: string
code: string | null
lat: number
lng: number
timezone: string | null
local_time: string | null
local_date: string | null
}
export interface Reservation {
id: number
trip_id: number
@@ -158,6 +172,8 @@ export interface Reservation {
accommodation_id?: number | null
day_plan_position?: number | null
metadata?: Record<string, string> | string | null
needs_review?: number
endpoints?: ReservationEndpoint[]
created_at: string
}
@@ -241,6 +257,7 @@ export interface Accommodation {
name: string
address: string | null
check_in: string | null
check_in_end: string | null
check_out: string | null
confirmation_number: string | null
notes: string | null
@@ -61,6 +61,10 @@ export const notificationHandlers = [
return HttpResponse.json({ success: true });
}),
http.post('/api/notifications/test-ntfy', async () => {
return HttpResponse.json({ success: true });
}),
http.post('/api/notifications/in-app/:id/respond', async ({ request, params }) => {
const body = await request.json() as { response: string };
return HttpResponse.json({
+1 -1
View File
@@ -91,7 +91,7 @@ describe('isRtlLanguage', () => {
describe('SUPPORTED_LANGUAGES', () => {
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
expect(SUPPORTED_LANGUAGES).toHaveLength(14)
expect(SUPPORTED_LANGUAGES).toHaveLength(15)
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
})
+754
View File
@@ -0,0 +1,754 @@
# System Notices — Technical Documentation & Dev Guide
System notices are server-evaluated, user-targeted messages shown in the TREK UI as modals, banners, or toasts. They are used for onboarding, upgrade announcements, breaking change warnings, and time-boxed campaigns. Every aspect — targeting, display, copy, and dismissal — is controlled from one place: the server-side registry.
---
## Table of Contents
1. [Architecture overview](#1-architecture-overview)
2. [Data flow](#2-data-flow)
3. [Database schema](#3-database-schema)
4. [The notice registry](#4-the-notice-registry)
5. [Notice fields reference](#5-notice-fields-reference)
6. [Condition system](#6-condition-system)
7. [Display types](#7-display-types)
8. [CTAs (call to action)](#8-ctas-call-to-action)
9. [i18n — translation keys](#9-i18n--translation-keys)
10. [Client store & dismissal](#10-client-store--dismissal)
11. [Sorting & priority](#11-sorting--priority)
12. [How-to recipes](#12-how-to-recipes)
13. [Testing](#13-testing)
14. [Rules & constraints](#14-rules--constraints)
---
## 1. Architecture overview
```
server/src/systemNotices/
├── types.ts — TypeScript types (SystemNotice, NoticeCondition, …)
├── registry.ts — Authoritative list of all notices (edit here to add/change/remove)
├── conditions.ts — Condition evaluators + custom predicate registry
└── service.ts — Queries DB, evaluates conditions, sorts, strips server-only fields
server/src/routes/systemNotices.ts — REST endpoints
client/src/store/systemNoticeStore.ts — Zustand store (fetch + optimistic dismiss)
client/src/components/SystemNotices/
├── SystemNoticeHost.tsx — Renders all three channels (modal / banner / toast)
├── SystemNoticeModal.tsx — Modal renderer (pager, animations, keyboard nav)
├── SystemNoticeBanner.tsx — Banner + toast renderers
└── noticeActions.ts — Client-side action registry for action-kind CTAs
client/src/pages/Trips/noticeActions.ts — Example domain action registration
```
There are **no database rows for notice definitions**. The registry is code-only. The database only stores which notices a user has dismissed.
---
## 2. Data flow
```
1. User authenticates
2. authStore.loadUser() completes
3. SystemNoticeHost mounts → calls useSystemNoticeStore.fetch()
│ (also triggered on cold page reload if store not yet loaded)
4. GET /api/system-notices/active
5. service.getActiveNoticesFor(userId)
├── reads user row (login_count, first_seen_version, role)
├── counts user trips
├── reads user_notice_dismissals
├── filters SYSTEM_NOTICES:
not dismissed
not expired (expiresAt)
all conditions pass (AND logic)
├── sorts by priority → severity → publishedAt (desc)
└── strips server-only fields (conditions, publishedAt, expiresAt, priority)
6. Client receives SystemNoticeDTO[]
7. SystemNoticeHost partitions by display type
├── modal → ModalRenderer (multi-page pager, slide transitions)
├── banner → BannerRenderer (sticky top bar, max 2)
└── toast → ToastRenderer (fires window.__addToast, auto-dismisses)
8. User dismisses → POST /api/system-notices/:id/dismiss
├── Server: INSERT OR IGNORE into user_notice_dismissals
└── Client: optimistic remove from store (retry once on failure)
```
---
## 3. Database schema
Added in **migration 101** (`server/src/db/migrations.ts`).
### `users` columns (added by migration 101)
| Column | Type | Default | Purpose |
|---|---|---|---|
| `first_seen_version` | `TEXT` | `'0.0.0'` | App version at account creation. Used by `existingUserBeforeVersion` condition. Backfilled users get `'0.0.0'`. |
| `login_count` | `INTEGER` | `0` | Incremented on each successful login. Used by `firstLogin` condition. |
### `user_notice_dismissals`
| Column | Type | Notes |
|---|---|---|
| `user_id` | `INTEGER` | FK → `users.id` CASCADE DELETE |
| `notice_id` | `TEXT` | Matches `SystemNotice.id` from registry |
| `dismissed_at` | `INTEGER` | Unix ms timestamp |
Primary key: `(user_id, notice_id)` — dismissals are idempotent.
---
## 4. The notice registry
**`server/src/systemNotices/registry.ts`** is the single source of truth. Add, change, or retire notices here.
```typescript
export const SYSTEM_NOTICES: SystemNotice[] = [
{
id: 'my-notice', // ← globally unique, never reuse
display: 'modal',
severity: 'info',
titleKey: 'system_notice.my_notice.title',
bodyKey: 'system_notice.my_notice.body',
dismissible: true,
conditions: [{ kind: 'firstLogin' }],
publishedAt: '2026-05-01T00:00:00Z',
priority: 50,
},
];
```
### The golden rule for IDs
**Never remove or renumber an entry. Never reuse an ID.**
Dismissals are stored in the database keyed by `id`. Removing an entry means dismissed users would see it again if you ever add a notice with the same ID. If a notice is no longer needed, add `expiresAt` to stop it from being shown — do not delete the entry.
---
## 5. Notice fields reference
### Required fields
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Globally unique, stable identifier. Use kebab-case, descriptive, version-scoped when appropriate (`v3-photos`, `welcome-v1`). Max recommended length: 40 chars. |
| `display` | `'modal' \| 'banner' \| 'toast'` | How the notice is rendered. See [§7 Display types](#7-display-types). |
| `severity` | `'info' \| 'warn' \| 'critical'` | Affects colour scheme and accessibility role. `critical` notices cannot be toasts. |
| `titleKey` | `string` | i18n key for the title. |
| `bodyKey` | `string` | i18n key for the body. Markdown supported in modals; plain text only in banners/toasts. |
| `dismissible` | `boolean` | If `false`, the X button and ESC key are hidden/blocked. Use only for `critical` notices that require action before proceeding. |
| `conditions` | `NoticeCondition[]` | Empty array (`[]`) means always shown (same as `[{ kind: 'always' }]`). All conditions must pass (AND logic). |
| `publishedAt` | `string` | ISO 8601 date. Used as a tiebreaker in sorting. Set to the deployment date. |
### Optional fields
| Field | Type | Description |
|---|---|---|
| `priority` | `number` | Higher number = shown first. Primary sort key. Default: `0`. |
| `expiresAt` | `string` | ISO 8601 date. Notice is automatically hidden after this date. Preferred over deleting entries. |
| `icon` | `string` | Lucide icon name (e.g. `'Sparkles'`, `'ImageOff'`). Shown in the modal's severity icon circle. Falls back to the severity default icon if absent or unrecognised. |
| `bodyParams` | `Record<string, string>` | Interpolation parameters for `bodyKey`. Values replace `{key}` placeholders in the translated string. **Never hardcode version numbers or dates directly in translation strings — use this instead.** |
| `media` | `NoticeMedia` | Image to display in the modal. See below. |
| `highlights` | `Array<{ labelKey: string; iconName?: string }>` | Bullet-point feature list rendered below the body in modals. Each entry is a translation key + optional Lucide icon name. |
| `cta` | `NoticeCta` | Primary action button. See [§8 CTAs](#8-ctas-call-to-action). |
### `NoticeMedia`
```typescript
interface NoticeMedia {
src: string; // URL or path
srcDark?: string; // Optional dark-mode variant
altKey: string; // i18n key for alt text
placement?: 'hero' | 'inline'; // default: 'hero' (full-width above body)
aspectRatio?: string; // CSS aspect-ratio value, default '16/9'
}
```
### Character limits
| Field | Modal | Banner | Toast |
|---|---|---|---|
| Title | ≤ 40 chars | ≤ 40 chars | ≤ 40 chars |
| Body | ≤ 400 chars (markdown) | ≤ 140 chars (plain) | ≤ 80 chars (plain) |
| CTA label | ≤ 20 chars, a verb | ≤ 20 chars | ≤ 20 chars |
---
## 6. Condition system
Conditions are evaluated **server-side** on every `GET /api/system-notices/active` call. The client never sees conditions — only the filtered result.
All conditions in `conditions[]` must pass (AND logic). To implement OR logic, create multiple notices with overlapping IDs is not possible — instead use a `custom` predicate with internal OR logic.
### Built-in conditions
#### `always`
```typescript
{ kind: 'always' }
```
Always passes. Equivalent to an empty `conditions` array.
---
#### `firstLogin`
```typescript
{ kind: 'firstLogin' }
```
Passes when `users.login_count <= 1`. The counter is incremented during login, so this fires on the first fetch after the very first login. Useful for onboarding notices.
---
#### `noTrips`
```typescript
{ kind: 'noTrips' }
```
Passes when the user has zero trips. Often combined with `firstLogin`.
---
#### `existingUserBeforeVersion`
```typescript
{ kind: 'existingUserBeforeVersion', version: '3.0.0' }
```
Passes when:
- `users.first_seen_version < version` (user existed before this version)
- AND the running app version `>= version` (the version has been deployed)
Backfilled/legacy users have `first_seen_version = '0.0.0'` and always pass the first condition. Use this for upgrade announcements targeting users who were around before a breaking change.
---
#### `dateWindow`
```typescript
{ kind: 'dateWindow', startsAt: '2026-06-01T00:00:00Z', endsAt: '2026-07-01T00:00:00Z' }
```
Passes when the current server time is inside `[startsAt, endsAt]`. `endsAt` is optional (open-ended). Use for campaigns, maintenance banners, and time-limited promotions.
---
#### `role`
```typescript
{ kind: 'role', roles: ['admin'] }
// or both roles:
{ kind: 'role', roles: ['admin', 'user'] }
```
Passes when the user's role is in the given list.
---
#### `addonEnabled`
```typescript
{ kind: 'addonEnabled', addonId: 'journey' }
```
Passes when the named addon is enabled in admin settings. Addon IDs are the string values in `server/src/addons.ts` (`ADDON_IDS`). Use this to gate notices that promote features behind an addon.
---
#### `custom`
```typescript
{ kind: 'custom', id: 'my-predicate-id' }
```
Delegates evaluation to a predicate registered server-side with `registerPredicate`. This is the escape hatch for logic not covered by the built-in conditions.
**Registering a custom predicate:**
```typescript
// server/src/systemNotices/conditions.ts exports registerPredicate
import { registerPredicate } from '../systemNotices/conditions.js';
registerPredicate('has-immich-configured', (ctx) => {
// ctx.user = { login_count, first_seen_version, role, noTrips }
// ctx.currentAppVersion = string
// ctx.now = Date
return someDbCheck(ctx.user);
});
```
Register predicates at application startup before the first `getActiveNoticesFor` call.
---
### Combining conditions (AND)
```typescript
conditions: [
{ kind: 'existingUserBeforeVersion', version: '3.0.0' },
{ kind: 'addonEnabled', addonId: 'journey' },
]
// Only shows to pre-3.0 users AND only if the journey addon is enabled.
```
---
## 7. Display types
### `modal`
Full-screen overlay with backdrop. On mobile: bottom sheet with drag-to-dismiss. On desktop: centered card.
**Features:**
- Markdown body (via `react-markdown` + `remark-gfm` + `rehype-sanitize`)
- Optional hero or inline image
- Optional highlights list (icon + label bullets)
- Optional CTA button + "Not now" link
- OK button when no CTA is defined
- **Multi-page pager**: when multiple modal notices are active simultaneously, they are rendered as a paginated single modal with prev/next arrows, dot indicators, `N / M` counter, and keyboard arrow navigation
- Slide transition between pages
- ESC to dismiss all (if current notice is dismissible)
- CTA and OK dismiss **all** active modal notices, not just the current page
- "Not now" dismisses only the current page
**Non-dismissible modals** (`dismissible: false`): X button, ESC key, and pager navigation are all disabled until the user acts on the CTA. Use only for `critical` severity.
---
### `banner`
Sticky top bar below the navigation. Slides in with a translate-Y animation.
**Constraints:**
- Maximum 2 banners shown simultaneously (the 2 highest-priority active banners)
- Plain text only (no markdown)
- RTL-aware left-border accent
- Reports its height via a CSS variable `--banner-stack-h` for layout reflow
---
### `toast`
Fires the global `window.__addToast` toast system. Auto-dismisses after 6 s (`info`) or 9 s (`warn`). The notice is dismissed from the store after the toast expires.
**Constraints:**
- `critical` severity is not allowed as a toast — the renderer logs a warning and auto-dismisses it instead
- Plain text only
- No interaction (no CTA rendered via toast)
---
## 8. CTAs (call to action)
A CTA renders as the primary blue button in modals and as an underline link in banners. There are two kinds.
### `nav` — navigate to a route
```typescript
cta: {
kind: 'nav',
labelKey: 'system_notice.my_notice.cta_label',
href: '/journey',
}
```
On click: navigates to `href` using React Router, then **dismisses all active modal notices** (or the current banner notice). The label is resolved through the i18n system.
---
### `action` — run a registered client-side handler
```typescript
cta: {
kind: 'action',
labelKey: 'system_notice.my_notice.cta_label',
actionId: 'open:trip-create',
dismissOnAction: true, // default true — set false to keep notice open after action
}
```
On click: looks up `actionId` in the client-side action registry and calls the handler, then **dismisses all active modal notices**.
**To add a new action:**
1. Create (or extend) a `noticeActions.ts` file in the relevant feature directory:
```typescript
// client/src/pages/MyFeature/noticeActions.ts
import { registerNoticeAction } from '../../components/SystemNotices/noticeActions.js';
registerNoticeAction('open:my-feature', ({ navigate }) => {
navigate('/my-feature?from=notice');
});
```
2. Import it as a side-effect in `client/src/App.tsx`:
```typescript
import './pages/MyFeature/noticeActions.js'
```
3. The registry integrity test (`server/tests/unit/systemNotices/registry.test.ts`) automatically scans all `noticeActions.ts` files and verifies that every `actionId` in the registry is registered. The test will fail if you add an `actionId` to the registry without registering it on the client.
**Action handler signature:**
```typescript
(ctx: NoticeActionContext) => void | Promise<void>
interface NoticeActionContext {
navigate: NavigateFunction; // React Router navigate function
}
```
### Dismiss behaviour summary
| Trigger | What is dismissed |
|---|---|
| X button (modal) | All active modal notices |
| ESC key | All active modal notices (if current is dismissible) |
| CTA button | All active modal notices |
| OK button (no CTA) | All active modal notices |
| "Not now" link | Current page only |
| Banner dismiss (X) | That banner only |
| Backdrop click (modal) | Current page only |
| Swipe down (mobile) | Current page only |
| Toast expires | That toast only |
---
## 9. i18n — translation keys
Every notice field that is user-visible (`titleKey`, `bodyKey`, CTA `labelKey`, highlight `labelKey`, media `altKey`) is an i18n key resolved through `useTranslation().t()`. The key string is what gets stored in the registry; the display value lives in the translation files.
**Translation files location:** `client/src/i18n/translations/` (15 files: `en`, `de`, `fr`, `es`, `it`, `nl`, `pl`, `cs`, `hu`, `ru`, `zh`, `zhTw`, `ar`, `br`, `id`)
### Key naming convention
```
system_notice.<notice_id_snake>.<field>
```
Examples:
```
system_notice.welcome_v1.title
system_notice.welcome_v1.body
system_notice.welcome_v1.cta_label
system_notice.welcome_v1.highlight_plan
system_notice.welcome_v1.hero_alt
```
### Adding keys
Add the English key to `client/src/i18n/translations/en.ts` first, then replicate to the other 14 files. Group related notice keys together with a comment:
```typescript
// System notices — my feature
'system_notice.my_notice.title': 'My feature is here',
'system_notice.my_notice.body': 'Here is what changed.',
'system_notice.my_notice.cta_label': 'Explore',
```
### `bodyParams` interpolation
For values that vary at runtime (version numbers, dates, counts), use `{placeholder}` syntax in the translation string and pass `bodyParams` in the registry entry:
```typescript
// In registry:
bodyKey: 'system_notice.my_notice.body',
bodyParams: { version: '3.1.0', date: '1 May 2026' },
// In en.ts:
'system_notice.my_notice.body': 'TREK {version} was released on {date}.',
```
**Never hardcode dynamic values directly in translation strings.** The interpolation runs client-side in `ModalRenderer` before rendering.
### Multiline bodies (modals only)
Use `\n\n` (escaped, not literal newlines) for paragraph breaks in modal body strings:
```typescript
'system_notice.my_notice.body': 'First paragraph.\n\nSecond paragraph.',
```
Literal newlines in single-quoted TypeScript strings cause a parse error.
### Pager i18n keys
The pager UI uses its own keys (already present in all 15 files):
```
system_notice.pager.prev → "Previous notice"
system_notice.pager.next → "Next notice"
system_notice.pager.counter → "{current} / {total}"
system_notice.pager.goto → "Go to notice {n}"
system_notice.pager.position → "Notice {current} of {total}" (aria-live)
```
---
## 10. Client store & dismissal
`client/src/store/systemNoticeStore.ts` (Zustand, no persistence).
| Action | Behaviour |
|---|---|
| `fetch()` | `GET /api/system-notices/active`. Fails silently (non-critical). Sets `loaded = true` regardless. |
| `dismiss(id)` | Optimistic: removes notice from store immediately. POSTs to `/api/system-notices/{id}/dismiss` in background with one retry on failure. |
`SystemNoticeHost` triggers `fetch()` on mount if `loaded === false`. Auth store also triggers it after login, so on a fresh login the fetch happens exactly once.
---
## 11. Sorting & priority
Notices are sorted before being sent to the client. The sort order is:
1. **`priority`** (descending) — primary key. Higher number appears first.
2. **`severity`** (descending) — tiebreaker: `critical` (2) > `warn` (1) > `info` (0).
3. **`publishedAt`** (descending) — final tiebreaker: more recent notices first.
This means `priority` always wins over severity. Assign priorities deliberately so the intended reading order is preserved when multiple notices are active simultaneously.
Current priority allocations in the registry:
| Range | Use |
|---|---|
| 100 | Onboarding / first-login |
| 8090 | Major version upgrade notices |
| 5070 | Feature announcements |
| 1040 | Campaigns, banners |
| 0 (default) | Miscellaneous |
---
## 12. How-to recipes
### Add a new modal notice
1. **Registry** — add an entry to `SYSTEM_NOTICES` in `server/src/systemNotices/registry.ts`:
```typescript
{
id: 'my-feature-v2',
display: 'modal',
severity: 'info',
icon: 'Zap',
titleKey: 'system_notice.my_feature_v2.title',
bodyKey: 'system_notice.my_feature_v2.body',
highlights: [
{ labelKey: 'system_notice.my_feature_v2.highlight_one', iconName: 'Check' },
],
cta: {
kind: 'nav',
labelKey: 'system_notice.my_feature_v2.cta_label',
href: '/my-feature',
},
dismissible: true,
conditions: [{ kind: 'existingUserBeforeVersion', version: '2.0.0' }],
publishedAt: '2026-06-01T00:00:00Z',
priority: 60,
},
```
2. **i18n** — add keys to `client/src/i18n/translations/en.ts` and the 14 other language files.
3. **Test** — run `cd server && npx vitest run tests/unit/systemNotices/` to verify registry integrity.
---
### Add a notice with an action CTA
1. Create the action handler in the relevant feature directory:
```typescript
// client/src/pages/MyFeature/noticeActions.ts
import { registerNoticeAction } from '../../components/SystemNotices/noticeActions.js';
registerNoticeAction('open:my-feature-dialog', ({ navigate }) => {
navigate('/my-feature?dialog=welcome');
});
```
2. Import it in `client/src/App.tsx`:
```typescript
import './pages/MyFeature/noticeActions.js'
```
3. Reference the `actionId` in the registry:
```typescript
cta: {
kind: 'action',
labelKey: 'system_notice.my_notice.cta_label',
actionId: 'open:my-feature-dialog',
},
```
The registry integrity test will catch any `actionId` that appears in the registry but lacks a `registerNoticeAction` call.
---
### Retire a notice (stop showing it)
**Do not delete the entry.** Set `expiresAt`:
```typescript
{
id: 'old-campaign',
// ... all existing fields unchanged ...
expiresAt: '2026-07-01T00:00:00Z',
}
```
After the expiry date the service filters it out automatically. The database row for dismissed users remains harmless.
---
### Show a notice only during a campaign window
Combine `dateWindow` with any other targeting conditions:
```typescript
conditions: [
{ kind: 'dateWindow', startsAt: '2026-06-15T00:00:00Z', endsAt: '2026-06-30T23:59:59Z' },
{ kind: 'role', roles: ['admin'] },
],
```
---
### Show a notice only if an addon is enabled
```typescript
conditions: [
{ kind: 'addonEnabled', addonId: 'journey' },
],
```
Addon IDs are the string values in `server/src/addons.ts``ADDON_IDS`.
---
### Add a custom condition
```typescript
// server/src/startup.ts (or wherever your bootstrap code runs)
import { registerPredicate } from './systemNotices/conditions.js';
registerPredicate('has-no-profile-photo', (ctx) => {
const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(ctx.user.id);
return !row?.avatar;
});
```
Then reference it in the registry:
```typescript
conditions: [{ kind: 'custom', id: 'has-no-profile-photo' }],
```
---
### Create a multipage upgrade announcement
Give multiple notices the same `conditions` and adjacent `priority` values. The pager groups all active modal notices together automatically — no extra wiring required.
```typescript
// Page 1 — breaking change (higher priority, warn severity)
{ id: 'v4-breaking', priority: 90, severity: 'warn', conditions: [{ kind: 'existingUserBeforeVersion', version: '4.0.0' }], ... },
// Page 2 — new feature (lower priority, info severity)
{ id: 'v4-feature', priority: 80, severity: 'info', conditions: [{ kind: 'existingUserBeforeVersion', version: '4.0.0' }], ... },
```
Users who have already dismissed page 1 will only see page 2 on their next session.
---
## 13. Testing
### Server unit tests
**`server/tests/unit/systemNotices/conditions.test.ts`**
Tests each condition kind in isolation using `evaluate()` directly. No DB required.
**`server/tests/unit/systemNotices/registry.test.ts`**
Validates registry integrity:
- No duplicate `id` values
- All `action` CTA `actionId`s have a corresponding `registerNoticeAction()` call in the client source (scanned via regex — no JSON file needed)
- All `publishedAt` values parse as valid ISO dates
Run: `cd server && npx vitest run tests/unit/systemNotices/`
**`server/tests/integration/systemNotices.test.ts`**
Integration tests against a real in-memory SQLite database:
- `GET /api/system-notices/active` returns 401 without auth, returns correct notices per user state
- `POST /api/system-notices/:id/dismiss` stores the dismissal and filters on subsequent requests
- Dismissing an unknown ID returns 404
Run: `cd server && npx vitest run tests/integration/systemNotices.test.ts`
---
### Client unit tests
**`client/src/components/SystemNotices/SystemNoticeModal.test.tsx`**
Tests `ModalRenderer` with fake timers (`vi.useFakeTimers()`) and MSW for the dismiss endpoint. Key helpers:
```typescript
// Flush the 500 ms grace delay that gates the modal's visible state
async function flushGraceDelay() {
await act(async () => { vi.runAllTimers(); });
}
// Minimal notice factory
function makeNotice(overrides?: Partial<SystemNoticeDTO>): SystemNoticeDTO
```
Covered cases (FE-SN-MODAL-001 to 018):
- Grace delay before visibility
- Dismiss button, X button, ESC key
- Non-dismissible notices (all affordances blocked)
- CTA nav button — dismisses all notices
- Body param interpolation
- Pager: counter, dots, prev/next buttons, keyboard arrows, dot click, non-dismissible lock
- Dismiss-does-not-skip regression
- X and ESC dismiss all in multipage scenario
- Last notice close
Run: `cd client && npm run test -- SystemNoticeModal`
---
### Running all notice tests
```bash
cd server && npx vitest run tests/unit/systemNotices/ tests/integration/systemNotices.test.ts
cd client && npm run test -- SystemNoticeModal
```
---
## 14. Rules & constraints
| Rule | Reason |
|---|---|
| Never delete or reuse a notice `id` | Dismissal records are keyed by `id`. Deletion causes dismissed users to see the notice again. |
| Never use literal newlines in translation strings | Single-quoted TS strings with literal newlines cause esbuild parse errors. Use `\n\n` (escaped). |
| Never hardcode version numbers or dates in translation strings | Use `bodyParams` so strings stay translatable without retranslation per release. |
| `critical` severity must have `dismissible: false` | `critical` toasts are auto-dismissed with a warning; a dismissible critical modal is inconsistent UX. |
| `critical` must not use `display: 'toast'` | The toast renderer logs a warning and auto-dismisses critical toasts rather than showing them. |
| CTA labels ≤ 20 chars, sentence case, a verb | Consistent button copy across the app. |
| Priorities must be set explicitly for upgrade notices | Adjacent notices form a multipage group; ordering matters for the reading flow. |
| `action` CTA `actionId` must be registered client-side | The registry integrity test enforces this. Add both the registry entry and the `registerNoticeAction` call in the same PR. |
| `expiresAt` over deletion for retiring notices | See above. |
File diff suppressed because one or more lines are too long
+22 -54
View File
@@ -1,12 +1,12 @@
{
"name": "trek-server",
"version": "2.9.13",
"version": "2.9.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-server",
"version": "2.9.13",
"version": "2.9.14",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1",
@@ -24,6 +24,7 @@
"nodemailer": "^8.0.5",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
"semver": "^7.7.4",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"undici": "^7.0.0",
@@ -45,6 +46,7 @@
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.11",
"@types/qrcode": "^1.5.5",
"@types/semver": "^7.7.1",
"@types/supertest": "^6.0.3",
"@types/unzipper": "^0.10.11",
"@types/uuid": "^10.0.0",
@@ -52,6 +54,7 @@
"@vitest/coverage-v8": "^3.2.4",
"nodemon": "^3.1.0",
"supertest": "^7.2.2",
"tz-lookup": "^6.1.25",
"vitest": "^3.2.4"
}
},
@@ -1187,9 +1190,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1204,9 +1204,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1221,9 +1218,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1238,9 +1232,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1255,9 +1246,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1272,9 +1260,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1289,9 +1274,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1306,9 +1288,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1323,9 +1302,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1340,9 +1316,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1357,9 +1330,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1374,9 +1344,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1391,9 +1358,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1590,7 +1554,6 @@
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
@@ -1721,6 +1684,13 @@
"@types/node": "*"
}
},
"node_modules/@types/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
@@ -2152,7 +2122,6 @@
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -3164,7 +3133,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -3668,11 +3636,10 @@
}
},
"node_modules/hono": {
"version": "4.12.12",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
"version": "4.12.14",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -5730,7 +5697,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5805,7 +5771,6 @@
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -5864,6 +5829,13 @@
"node": ">=14.17"
}
},
"node_modules/tz-lookup": {
"version": "6.1.25",
"resolved": "https://registry.npmjs.org/tz-lookup/-/tz-lookup-6.1.25.tgz",
"integrity": "sha512-fFewT9o1uDzsW1QnUU1ValqaihFnwiUiiHr1S79/fxOzKXYYvX+EHeRnpvQJ9B3Qg67wPXT6QF2Esc4pFOrvLg==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -5961,7 +5933,6 @@
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -6078,7 +6049,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6092,7 +6062,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -6331,7 +6300,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "trek-server",
"version": "2.9.13",
"version": "2.9.14",
"main": "src/index.ts",
"scripts": {
"start": "node --import tsx src/index.ts",
@@ -26,12 +26,13 @@
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"node-cron": "^4.2.1",
"undici": "^7.0.0",
"nodemailer": "^8.0.5",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
"semver": "^7.7.4",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"undici": "^7.0.0",
"unzipper": "^0.12.3",
"uuid": "^9.0.0",
"ws": "^8.19.0",
@@ -54,6 +55,7 @@
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.11",
"@types/qrcode": "^1.5.5",
"@types/semver": "^7.7.1",
"@types/supertest": "^6.0.3",
"@types/unzipper": "^0.10.11",
"@types/uuid": "^10.0.0",
@@ -61,6 +63,7 @@
"@vitest/coverage-v8": "^3.2.4",
"nodemon": "^3.1.0",
"supertest": "^7.2.2",
"tz-lookup": "^6.1.25",
"vitest": "^3.2.4"
}
}
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env node
// Build server/data/airports.json from OurAirports (davidmegginson.github.io/ourairports-data).
// License: Public Domain. Keeps large/medium airports with an IATA code; timezone derived from coords via tz-lookup.
import fs from 'node:fs'
import path from 'node:path'
import https from 'node:https'
import { fileURLToPath } from 'node:url'
import tzLookup from 'tz-lookup'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const OUT = path.join(__dirname, '..', 'data', 'airports.json')
const SRC = 'https://davidmegginson.github.io/ourairports-data/airports.csv'
function fetchText(url) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`))
let data = ''
res.setEncoding('utf8')
res.on('data', chunk => { data += chunk })
res.on('end', () => resolve(data))
}).on('error', reject)
})
}
function parseCsv(text) {
const rows = []
let row = []
let cur = ''
let inQuotes = false
for (let i = 0; i < text.length; i++) {
const ch = text[i]
if (inQuotes) {
if (ch === '"') {
if (text[i + 1] === '"') { cur += '"'; i++ } else { inQuotes = false }
} else {
cur += ch
}
} else {
if (ch === '"') inQuotes = true
else if (ch === ',') { row.push(cur); cur = '' }
else if (ch === '\n') { row.push(cur); rows.push(row); row = []; cur = '' }
else if (ch === '\r') { /* skip */ }
else cur += ch
}
}
if (cur.length > 0 || row.length > 0) { row.push(cur); rows.push(row) }
return rows
}
const raw = await fetchText(SRC)
const rows = parseCsv(raw)
const header = rows[0]
const idx = (name) => header.indexOf(name)
const TYPE = idx('type')
const NAME = idx('name')
const LAT = idx('latitude_deg')
const LNG = idx('longitude_deg')
const COUNTRY = idx('iso_country')
const MUNICIPALITY = idx('municipality')
const SERVICE = idx('scheduled_service')
const ICAO = idx('icao_code')
const IATA = idx('iata_code')
const KEEP = new Set(['large_airport', 'medium_airport'])
const airports = []
let skippedNoTz = 0
for (let i = 1; i < rows.length; i++) {
const r = rows[i]
if (!r || r.length < header.length) continue
if (!KEEP.has(r[TYPE])) continue
const iata = r[IATA]?.trim().toUpperCase()
if (!iata || iata.length !== 3) continue
if (r[SERVICE] !== 'yes') continue
const lat = Number(r[LAT])
const lng = Number(r[LNG])
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
let tz = null
try { tz = tzLookup(lat, lng) } catch { skippedNoTz++; continue }
if (!tz) { skippedNoTz++; continue }
airports.push({
iata,
icao: r[ICAO]?.trim().toUpperCase() || null,
name: r[NAME],
city: r[MUNICIPALITY] || '',
country: r[COUNTRY] || '',
lat: Math.round(lat * 1e6) / 1e6,
lng: Math.round(lng * 1e6) / 1e6,
tz,
})
}
const seen = new Map()
for (const a of airports) {
const existing = seen.get(a.iata)
if (!existing) { seen.set(a.iata, a); continue }
if (existing.icao && !a.icao) continue
if (!existing.icao && a.icao) seen.set(a.iata, a)
}
const unique = Array.from(seen.values()).sort((a, b) => a.iata.localeCompare(b.iata))
fs.writeFileSync(OUT, JSON.stringify(unique))
const size = fs.statSync(OUT).size
console.log(`Wrote ${unique.length} airports to ${OUT} (${(size / 1024).toFixed(1)} KB); skipped ${skippedNoTz} without timezone`)
+1
View File
@@ -6,6 +6,7 @@ export const ADDON_IDS = {
VACAY: 'vacay',
ATLAS: 'atlas',
COLLAB: 'collab',
JOURNEY: 'journey',
} as const;
export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS];
+12 -1
View File
@@ -23,6 +23,7 @@ import tagsRoutes from './routes/tags';
import categoriesRoutes from './routes/categories';
import adminRoutes from './routes/admin';
import mapsRoutes from './routes/maps';
import airportsRoutes from './routes/airports';
import filesRoutes from './routes/files';
import reservationsRoutes from './routes/reservations';
import dayNotesRoutes from './routes/dayNotes';
@@ -42,9 +43,13 @@ import shareRoutes from './routes/share';
import journeyRoutes from './routes/journey';
import journeyPublicRoutes from './routes/journeyPublic';
import publicConfigRoutes from './routes/publicConfig';
import systemNoticesRoutes from './routes/systemNotices';
import { mcpHandler } from './mcp';
import { Addon } from './types';
import { getPhotoProviderConfig } from './services/memories/helpersService';
import { getCollabFeatures } from './services/adminService';
import { isAddonEnabled } from './services/adminService';
import { ADDON_IDS } from './addons';
export function createApp(): express.Application {
const app = express();
@@ -236,6 +241,7 @@ export function createApp(): express.Application {
}
res.json({
collabFeatures: getCollabFeatures(),
addons: [
...addons.map(a => ({ ...a, enabled: !!a.enabled })),
...providers.map(p => ({
@@ -265,13 +271,18 @@ export function createApp(): express.Application {
// Addon routes
app.use('/api/addons/vacay', vacayRoutes);
app.use('/api/addons/atlas', atlasRoutes);
app.use('/api/journeys', journeyRoutes);
app.use('/api/journeys', (req, res, next) => {
if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return res.status(404).json({ error: 'Journey addon is not enabled' });
next();
}, journeyRoutes);
app.use('/api/public/journey', journeyPublicRoutes);
app.use('/api/integrations/memories', memoriesRoutes);
app.use('/api/photos', photoRoutes);
app.use('/api/maps', mapsRoutes);
app.use('/api/airports', airportsRoutes);
app.use('/api/weather', weatherRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/system-notices', systemNoticesRoutes);
app.use('/api/backup', backupRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api', shareRoutes);
+7
View File
@@ -128,4 +128,11 @@ function isOwner(tripId: number | string, userId: number): boolean {
return !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
}
try {
const { backfillFlightEndpoints } = require('../services/airportService');
backfillFlightEndpoints();
} catch (err) {
console.error('[DB] Flight endpoint backfill failed:', err);
}
export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner };
+50
View File
@@ -1605,6 +1605,56 @@ function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at);
`);
},
// Migration 101: Enable naver_list_import by default
() => {
db.prepare("UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'").run();
},
// Migration 102: Add check_in_end column for check-in time ranges
() => {
try { db.exec('ALTER TABLE day_accommodations ADD COLUMN check_in_end TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
},
// Migration 103: System notices — user tracking columns + dismissals table
() => {
db.exec(`ALTER TABLE users ADD COLUMN first_seen_version TEXT NOT NULL DEFAULT '0.0.0'`);
db.exec(`ALTER TABLE users ADD COLUMN login_count INTEGER NOT NULL DEFAULT 0`);
db.exec(`
CREATE TABLE IF NOT EXISTS user_notice_dismissals (
user_id INTEGER NOT NULL,
notice_id TEXT NOT NULL,
dismissed_at INTEGER NOT NULL,
PRIMARY KEY (user_id, notice_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
},
// Migration 104: Passphrase support for Synology shared-album links (#689)
() => {
try { db.exec('ALTER TABLE trip_album_links ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec('ALTER TABLE trek_photos ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
},
// Migration 105: Reservation endpoints (from/to points for flights, trains, ferries, car rentals) — #384 + #587
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS reservation_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
sequence INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL,
code TEXT,
lat REAL NOT NULL,
lng REAL NOT NULL,
timezone TEXT,
local_time TEXT,
local_date TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_reservation_endpoints_reservation_id ON reservation_endpoints(reservation_id)');
try { db.exec('ALTER TABLE reservations ADD COLUMN needs_review INTEGER NOT NULL DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
},
];
if (currentVersion < migrations.length) {
+1
View File
@@ -334,6 +334,7 @@ function createTables(db: Database.Database): void {
start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
check_in TEXT,
check_in_end TEXT,
check_out TEXT,
confirmation TEXT,
notes TEXT,
+1 -1
View File
@@ -92,7 +92,7 @@ function seedAddons(db: Database.Database): void {
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
{ id: 'mcp', name: 'MCP', description: 'Model Context Protocol for AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 },
{ id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from shared Naver Maps lists', type: 'trip', icon: 'Link2', enabled: 0, sort_order: 13 },
{ id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from shared Naver Maps lists', type: 'trip', icon: 'Link2', enabled: 1, sort_order: 13 },
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
];
+44
View File
@@ -3,6 +3,7 @@ import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest } from '../types';
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
import * as svc from '../services/adminService';
import { getAdminUserDefaults, setAdminUserDefaults } from '../services/settingsService';
import { invalidateMcpSessions } from '../mcp';
import { getPreferencesMatrix, setAdminPreferences } from '../services/notificationPreferencesService';
@@ -200,6 +201,24 @@ router.put('/bag-tracking', (req: Request, res: Response) => {
res.json(result);
});
// ── Collab Features ───────────────────────────────────────────────────────
router.get('/collab-features', (_req: Request, res: Response) => {
res.json(svc.getCollabFeatures());
});
router.put('/collab-features', (req: Request, res: Response) => {
const result = svc.updateCollabFeatures(req.body);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.collab_features',
ip: getClientIp(req),
details: result,
});
res.json(result);
});
// ── Packing Templates ──────────────────────────────────────────────────────
router.get('/packing-templates', (_req: Request, res: Response) => {
@@ -346,6 +365,31 @@ router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
res.json({ success: true });
});
// ── Default User Settings ──────────────────────────────────────────────────────
router.get('/default-user-settings', (_req: Request, res: Response) => {
res.json(getAdminUserDefaults());
});
router.put('/default-user-settings', (req: Request, res: Response) => {
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
return res.status(400).json({ error: 'Object body required' });
}
try {
setAdminUserDefaults(req.body);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.default_user_settings_update',
ip: getClientIp(req),
details: req.body,
});
res.json(getAdminUserDefaults());
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// ── Dev-only: test notification endpoints ──────────────────────────────────────
if (process.env.NODE_ENV === 'development') {
const { send } = require('../services/notificationService');
+19
View File
@@ -0,0 +1,19 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { searchAirports, findByIata } from '../services/airportService';
const router = express.Router();
router.get('/search', authenticate, (req: Request, res: Response) => {
const q = typeof req.query.q === 'string' ? req.query.q : '';
if (!q) return res.json([]);
res.json(searchAirports(q));
});
router.get('/:iata', authenticate, (req: Request, res: Response) => {
const airport = findByIata(req.params.iata);
if (!airport) return res.status(404).json({ error: 'Airport not found' });
res.json(airport);
});
export default router;
+4 -4
View File
@@ -73,7 +73,7 @@ accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, r
return res.status(403).json({ error: 'No permission' });
const { tripId } = req.params;
const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = req.body;
const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = req.body;
if (!place_id || !start_day_id || !end_day_id) {
return res.status(400).json({ error: 'place_id, start_day_id, and end_day_id are required' });
@@ -82,7 +82,7 @@ accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, r
const errors = dayService.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
if (errors.length > 0) return res.status(404).json({ error: errors[0].message });
const accommodation = dayService.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
const accommodation = dayService.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
res.status(201).json({ accommodation });
broadcast(tripId, 'accommodation:created', { accommodation }, req.headers['x-socket-id'] as string);
broadcast(tripId, 'reservation:created', {}, req.headers['x-socket-id'] as string);
@@ -98,12 +98,12 @@ accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request,
const existing = dayService.getAccommodation(id, tripId);
if (!existing) return res.status(404).json({ error: 'Accommodation not found' });
const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = req.body;
const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = req.body;
const errors = dayService.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
if (errors.length > 0) return res.status(404).json({ error: errors[0].message });
const accommodation = dayService.updateAccommodation(id, existing, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
const accommodation = dayService.updateAccommodation(id, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
res.json({ accommodation });
broadcast(tripId, 'accommodation:updated', { accommodation }, req.headers['x-socket-id'] as string);
});
+5 -9
View File
@@ -60,16 +60,12 @@ router.get('/browse', authenticate, async (req: Request, res: Response) => {
router.post('/search', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { from, to, size } = req.body;
const { from, to, size, page } = req.body;
const pageNum = Math.max(1, Number(page) || 1);
const pageSize = Math.min(Number(size) || 50, 200);
const allAssets: any[] = [];
for (let page = 1; page <= 20; page++) {
const result = await searchPhotos(authReq.user.id, from, to, page, pageSize);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.assets) allAssets.push(...result.assets);
if (!result.hasMore) break;
}
res.json({ assets: allAssets });
const result = await searchPhotos(authReq.user.id, from, to, pageNum, pageSize);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ assets: result.assets || [], hasMore: !!result.hasMore });
});
// ── Asset Details ──────────────────────────────────────────────────────────
+8 -5
View File
@@ -80,7 +80,8 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId));
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId, passphrase));
});
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
@@ -100,8 +101,8 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
const page = _parseNumberBodyField(body.page, 1) - 1;
let limit = _parseNumberBodyField(body.limit, 100);
const size = _parseNumberBodyField(body.size, 0);
if(page > 0) offset = page*limit;
if(size > 0) limit = size;
if (size > 0) limit = size;
if (page > 0) offset = page * limit;
handleServiceResult(res, await searchSynologyPhotos(
authReq.user.id,
@@ -115,12 +116,13 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, photoId, ownerId } = req.params;
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
handleServiceResult(res, fail('You don\'t have access to this photo', 403));
}
else {
handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId)));
handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId), passphrase));
}
});
@@ -130,6 +132,7 @@ router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req:
const VALID_SIZES = ['sm', 'm', 'xl'] as const;
const rawSize = String(req.query.size ?? 'sm');
const size = VALID_SIZES.includes(rawSize as any) ? rawSize : 'sm';
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
if (kind !== 'thumbnail' && kind !== 'original') {
return handleServiceResult(res, fail('Invalid asset kind', 400));
@@ -139,7 +142,7 @@ router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req:
handleServiceResult(res, fail('You don\'t have access to this photo', 403));
}
else{
await streamSynologyAsset(res, authReq.user.id, Number(ownerId), photoId, kind as 'thumbnail' | 'original', String(size));
await streamSynologyAsset(res, authReq.user.id, Number(ownerId), photoId, kind as 'thumbnail' | 'original', String(size), passphrase);
}
});
+2 -1
View File
@@ -84,7 +84,8 @@ router.get('/unified/trips/:tripId/album-links', authenticate, (req: Request, re
router.post('/unified/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name);
const passphrase = req.body?.passphrase ? String(req.body.passphrase) : undefined;
const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name, passphrase);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true });
});
+21 -1
View File
@@ -1,7 +1,7 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import { testSmtp, testWebhook, getAdminWebhookUrl, getUserWebhookUrl } from '../services/notifications';
import { testSmtp, testWebhook, testNtfy, getAdminWebhookUrl, getUserWebhookUrl, getUserNtfyConfig, getAdminNtfyConfig } from '../services/notifications';
import {
getNotifications,
getUnreadCount,
@@ -47,6 +47,26 @@ router.post('/test-webhook', authenticate, async (req: Request, res: Response) =
res.json(await testWebhook(url));
});
router.post('/test-ntfy', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { topic, server, token } = req.body as { topic?: string; server?: string; token?: string };
// Always load saved config for fallbacks (token may be masked or absent in request)
const userCfg = getUserNtfyConfig(authReq.user.id);
const adminCfg = getAdminNtfyConfig();
const resolvedTopic = topic || userCfg?.topic || undefined;
const resolvedServer = server || userCfg?.server || adminCfg.server || undefined;
// Reuse saved token when request sends null, empty, or the masked placeholder
const resolvedToken = (token && token !== '••••••••')
? token
: (userCfg?.token ?? adminCfg.token ?? null);
if (!resolvedTopic) return res.status(400).json({ error: 'No ntfy topic configured' });
res.json(await testNtfy({ topic: resolvedTopic, server: resolvedServer ?? null, token: resolvedToken }));
});
// ── In-app notifications ──────────────────────────────────────────────────────
// GET /in-app — list notifications (paginated)
-5
View File
@@ -5,7 +5,6 @@ import { requireTripAccess } from '../middleware/tripAccess';
import { broadcast } from '../websocket';
import { validateStringLengths } from '../middleware/validate';
import { checkPermission } from '../services/permissions';
import { isAddonEnabled } from '../services/adminService';
import { AuthRequest } from '../types';
import {
listPlaces,
@@ -135,10 +134,6 @@ router.post('/import/naver-list', authenticate, requireTripAccess, async (req: R
const authReq = req as AuthRequest;
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!isAddonEnabled('naver_list_import')) {
return res.status(403).json({ error: 'Naver list import addon is disabled' });
}
const { tripId } = req.params;
const { url } = req.body;
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
+6 -4
View File
@@ -31,7 +31,7 @@ router.get('/', authenticate, (req: Request, res: Response) => {
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry } = req.body;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = req.body;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
@@ -44,7 +44,8 @@ router.post('/', authenticate, (req: Request, res: Response) => {
const { reservation, accommodationCreated } = createReservation(tripId, {
title, reservation_time, reservation_end_time, location,
confirmation_number, notes, day_id, place_id, assignment_id,
status, type, accommodation_id, metadata, create_accommodation
status, type, accommodation_id, metadata, create_accommodation,
endpoints, needs_review
});
if (accommodationCreated) {
@@ -101,7 +102,7 @@ router.put('/positions', authenticate, (req: Request, res: Response) => {
router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry } = req.body;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = req.body;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
@@ -115,7 +116,8 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const { reservation, accommodationChanged } = updateReservation(id, tripId, {
title, reservation_time, reservation_end_time, location,
confirmation_number, notes, day_id, place_id, assignment_id,
status, type, accommodation_id, metadata, create_accommodation
status, type, accommodation_id, metadata, create_accommodation,
endpoints, needs_review
}, current);
if (accommodationChanged) {
+29
View File
@@ -0,0 +1,29 @@
import { Router } from 'express';
import { authenticate } from '../middleware/auth.js';
import { getActiveNoticesFor, dismissNotice } from '../systemNotices/service.js';
import type { AuthRequest } from '../types.js';
const router = Router();
// GET /api/system-notices/active
// Returns notices active for the authenticated user.
router.get('/active', authenticate, (req, res) => {
const userId = (req as AuthRequest).user!.id;
const notices = getActiveNoticesFor(userId);
res.json(notices);
});
// POST /api/system-notices/:id/dismiss
// Marks a notice as dismissed for the authenticated user. Idempotent.
router.post('/:id/dismiss', authenticate, (req, res) => {
const userId = (req as AuthRequest).user!.id;
const noticeId = req.params.id;
const ok = dismissNotice(userId, noticeId);
if (!ok) {
res.status(404).json({ error: 'NOTICE_NOT_FOUND' });
return;
}
res.status(204).end();
});
export default router;
+25
View File
@@ -459,6 +459,31 @@ export function updateBagTracking(enabled: boolean) {
return { enabled: !!enabled };
}
// ── Collab Features ───────────────────────────────────────────────────────
const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const;
export function getCollabFeatures() {
const rows = db.prepare("SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')").all() as { key: string; value: string }[];
const map: Record<string, string> = {};
for (const r of rows) map[r.key] = r.value;
return {
chat: map['collab_chat_enabled'] !== 'false',
notes: map['collab_notes_enabled'] !== 'false',
polls: map['collab_polls_enabled'] !== 'false',
whatsnext: map['collab_whatsnext_enabled'] !== 'false',
};
}
export function updateCollabFeatures(features: { chat?: boolean; notes?: boolean; polls?: boolean; whatsnext?: boolean }) {
const mapping: Record<string, string> = { chat: 'collab_chat_enabled', notes: 'collab_notes_enabled', polls: 'collab_polls_enabled', whatsnext: 'collab_whatsnext_enabled' };
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
for (const [feat, key] of Object.entries(mapping)) {
if (features[feat] !== undefined) stmt.run(key, features[feat] ? 'true' : 'false');
}
return getCollabFeatures();
}
// ── Packing Templates ──────────────────────────────────────────────────────
export function listPackingTemplates() {

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