Compare commits

..

108 Commits

Author SHA1 Message Date
Maurice a3f52ebd7b trim mobile labels in journey picker + guard JourneyMap flyTo
- mobile-shorten 'Alle Fotos' → 'Alle' in MemoriesPanel picker and the
  Journey ProviderPicker filter tabs (four tabs no longer wrap)
- mobile-shorten 'Datum wählen' → 'Datum' in the entry-editor DatePicker
  placeholder
- guard JourneyMap.tsx flyTo: getZoom() throws "Set map center and zoom
  first" when activeMarkerId arrives before fitBounds has set a view —
  wrap in try/catch and fall back to setView.
2026-04-18 19:28:19 +02:00
Maurice 4974013995 fix journey bugs reported by roel-de-vries (#722-#736)
Mobile UI:
- #722 timeline carousel no longer cut off by BottomNav (uses --bottom-nav-h var)
- #723 scroll-snap-type relaxed to proximity so small swipes no longer skip entries
- #724 defensive padding-bottom fix in JourneySettingsDialog for iOS PWA
- #725 add back/settings buttons + journey title subtitle to mobile activity view
- #726 active entry re-centers after scroll settle; tap inactive card activates
  it (does not jump straight into editor)

Entry editor flow:
- #727 photo uploads queue locally until Save for existing entries too
  (previously fired upload immediately; Cancel silently kept the new photo)
- #728 Cancel/Close with unsaved changes now requires confirm (window.confirm)
- #729 linking a Gallery photo into an entry now copies the row (old MOVE
  behavior meant Remove-from-Entry also nuked the Gallery original)
- #731 addPhoto / addProviderPhoto / linkPhotoToEntry promote skeleton
  entries to concrete 'entry' type when content is added

Permissions:
- #732 updateJourney switched from canEdit to isOwner — editors can still
  edit entries and photos, just not the journey shell (title, cover, status)
- #733 Contributors list gains a per-row remove (X) control with confirm
- #734 my_role is computed server-side and returned with the journey; UI
  gates Settings/Add/Edit/Delete controls based on role
- #736 createOrUpdateJourneyShareLink + deleteJourneyShareLink now require
  isOwner (previously NO permission check at all — anyone authenticated
  could publish or unpublish a journey)

Immich upload (#730):
- migration 111: add users.immich_auto_upload (default 0)
- migration 112: seed provider_field for the toggle (idempotent, FK-safe)
- journey photo upload only mirrors to Immich when the user has opted in
- Settings UI gets a "Mirror journey photos to Immich on upload" checkbox

Test updates:
- JOURNEY-SVC-019 inverted to assert editor cannot update journey settings
- JOURNEY-SHARE-007 now passes userId (owner) to deleteJourneyShareLink
- FE-PAGE-JOURNEYDETAIL-148 inverted to assert photos stay pending until Save
- client/tests still green (2676/2676)

Also fixed en route: gallery entry title is now the literal 'Gallery' on the
wire (used to send the translated label, which broke server-side title === 'Gallery'
checks in non-English locales); confirm interpolation uses {username} single
braces matching the existing i18n runtime; Settings footer uses icon-only
delete/archive buttons on mobile so the row doesn't wrap.
2026-04-18 19:11:16 +02:00
Maurice bc192d3106 Merge pull request #738 from mauriceboe/feat/visual-features
UI polish pass: animations, transitions, shared components
2026-04-18 17:46:10 +02:00
Maurice 4db6cbef22 add Emil-style UI polish pass (animations, shared components, feel) 2026-04-18 17:39:15 +02:00
Maurice f79385cf2a Merge pull request #720 from mauriceboe/feat/pkpass-mime
Support Apple Wallet (.pkpass) file handoff
2026-04-18 12:25:02 +02:00
Maurice db2c11e4a5 support Apple Wallet pkpass files
- add "pkpass" to the default allowed upload extensions
- on download, set Content-Type: application/vnd.apple.pkpass and
  Content-Disposition: inline for .pkpass files so Safari (iOS/macOS)
  hands them off to Apple Wallet instead of downloading as a blob
2026-04-18 12:19:27 +02:00
Maurice e57c6773fc Merge pull request #719 from mauriceboe/feat/places-sidebar-polish
Places sidebar polish: filter counts, compact select mode, tooltip component
2026-04-18 11:59:13 +02:00
Maurice 4bdc032f97 de: navbar tab 'Transporte' -> 'Transport' (singular) 2026-04-18 11:48:29 +02:00
Maurice 777b68f87b fix tests for sidebar/settings refactor + weather archive fallback
- DayPlanSidebar: add aria-label to undo button, replace title with aria-label
  so tests can still locate buttons by accessible name after tooltip refactor
- tests: switch getByTitle("Add Note") to getByLabelText
- tests: find undo button via aria-label (new expand/collapse button also uses
  width:30, breaking the old style-based lookup)
- PlacesSidebar tests: loosen "All" button regex to account for count badge
- DisplaySettingsTab tests: use getByRole for Auto button (two "Auto" spans
  coexist for mobile/desktop); handle multiple English matches in lang test
- weatherService tests: past-date case now expects an archive fetch instead
  of an immediate no_forecast error
2026-04-18 11:45:19 +02:00
Maurice 66a7de09c1 dayplan toolbar polish + weather archive fallback
- weather: add archive API branch in getWeather for past dates
  (previously returned no_forecast, making the day-strip widget show "—")
- dayplan: add expand/collapse-all toggle between ICS and Undo with
  animated icon swap (ChevronsUpDown <-> ChevronsDownUp)
- dayplan: drop the trip title + date range block from the sidebar header
  (already shown in the page header), toolbar now right-aligned
2026-04-18 11:34:57 +02:00
Maurice a19ae9e653 mobile settings polish
- settings: hide color-mode icons on mobile, shorten "Automatisch" -> "Auto"
- settings: language picker as custom dropdown on mobile
- admin permissions: reset button icon-only on mobile, sized to match save
- admin places toggles: add flex-shrink-0 + row gap so switches don't collapse
- de: settings.notifications label "Benachrichtigungen" -> "Mitteilungen"
2026-04-18 11:21:08 +02:00
Maurice 38f4c9aecb refine places sidebar: filter counts, compact select UI, tooltip component
- replace "Auswählen" button with small Check↔X toggle next to category dropdown
- move bulk-action bar below search, icon-only buttons (Select all, Delete)
- filter tabs as pill buttons with per-filter count badges
- shared Tooltip component (portaled, delayed) replaces native title
- apply tooltip to select toggle, bulk actions, add note, add transport
- rename places.importFile: "Datei importieren" -> "Dateimport"
2026-04-18 11:10:33 +02:00
Julien G. 802d78b577 Merge pull request #714 from mauriceboe/feat/selective-file-import-perf
feat(import): selective GPX/KML element import and performance improvements
2026-04-18 06:15:45 +02:00
jubnl 3f61e1ca38 feat: add multi-day transport reservations with dedicated modal and route segmentation
Introduces a TransportModal for creating/editing flight, train, car, and cruise
reservations that span multiple days. Transport entries now break the map route
into disconnected segments so the polyline reflects actual travel legs.

- Add TransportModal with airport/location pickers, multi-day date range, and all transport types
- Extend DB schema with end_day_id on reservations (migration 110) and backfill from existing dates
- Refactor useRouteCalculation to emit [][][number,number] segments split at transport boundaries
- Update MapView, DayPlanSidebar, ReservationsPanel, TripPlannerPage to wire up transport flow
- Add transport i18n keys across all 15 languages
2026-04-18 06:10:33 +02:00
Julien G. 8e04deb0f5 Merge pull request #716 from mauriceboe/dev
Dev
2026-04-18 02:08:16 +02:00
Maurice 160bd02f13 Merge pull request #715 from mauriceboe/feat/per-trip-map-fit
feat(map): auto-fit planner map to trip places on load (#510)
2026-04-18 02:07:27 +02:00
Maurice 68a3036909 refactor: move airports.json out of server/data into server/assets
server/data is for runtime state (SQLite, backups, logs, tmp) — the
airports snapshot is a shipped dataset, not user data, and it being in
there forced us to poke a hole in both .dockerignore and .gitignore.
Move it to server/assets/ and drop the exceptions; service and build
script point at the new path.
2026-04-18 02:02:09 +02:00
Maurice ec4aaa628f fix(docker): include server/data/airports.json in the image
The existing 'data' entry in .dockerignore hid the committed
airports.json snapshot from the build context, so every Docker
deployment ended up without it. The airport service then logged the
"missing" warning and the autocomplete silently returned no results —
the dropdown flashed a loading spinner and disappeared. Add an
exception that keeps the SQLite DB, logs and tmp excluded but lets
the airports snapshot through.
2026-04-18 01:57:01 +02:00
Maurice 2c0894b330 fix(types): add missing map_booking_labels to Settings interface
The booking-labels toggle from the transport-routes-on-map change was
reading and writing settings.map_booking_labels without the key being
declared on the Settings type, so the store typing was inconsistent.
Adds it as an optional boolean to match the other display toggles.
2026-04-18 01:48:53 +02:00
Maurice bd2bdebc33 feat(map): auto-fit the planner map to the trip's places on load
Closes the annoyance from discussion #510 — the planner opened every
trip centered on the global default, even when the trip's places were
on the other side of the world. We already have a BoundsController
that fits the map on the current places when fitKey changes, so
nudging fitKey once per trip (after the places have loaded) gives each
trip its own starting view without any new settings or UI. If a trip
has no places with coordinates yet, the global default still applies.
2026-04-18 01:43:55 +02:00
Maurice 2857ff594c Merge pull request #713 from mauriceboe/feat/dashboard-unified-toolbar
feat(dashboard): unify desktop header with planner toolbar
2026-04-18 01:38:50 +02:00
Julien G. 4f01a10277 Merge branch 'dev' into feat/selective-file-import-perf 2026-04-18 01:32:09 +02:00
Maurice ee805369d1 test(dashboard): loosen settings-button matcher for the new toolbar
The unified toolbar gives the gear button a title attribute for a11y,
which broke the previous "no title, no text" matcher. Matching on the
lucide-settings icon plus an empty text node is enough to identify it
uniquely on this page.
2026-04-18 01:30:55 +02:00
jubnl 6a718fccea feat(import): selective GPX/KML element import and performance improvements
Add type-selector UI in the file import modal letting users choose which
GPX elements (waypoints, routes, tracks) or KML/KMZ elements (points,
paths) to import. KML LineString placemarks are now imported as path
places with route_geometry.

Performance improvements:
- Extract MemoPlaceRow with React.memo and contentVisibility:auto to cut
  unnecessary re-renders in PlacesSidebar
- Add weatherQueue to cap concurrent weather fetches at 3
- Replace sequential per-place deletes with a single bulkDelete API call
  (new DELETE /places/bulk endpoint + deletePlacesMany service)
- Memoize atlas/photo/weather service calls to avoid redundant requests
- Add multi-select mode to PlacesSidebar for bulk operations

Add large GPX/KML/KMZ fixtures for integration/perf testing and two
profiler analysis scripts under scripts/.
2026-04-18 01:28:37 +02:00
Maurice 01ed60e2d5 refactor(vacay,journey): drop redundant buttons from the new toolbar
Vacay: remove the filter-sidebar toggle from the desktop bar and shift
the breakpoint so the pre-existing mobile/tablet header (which still
has the toggle) handles everything below the lg threshold where the
sidebar is always visible anyway.

Journey: drop the desktop search toggle and inline search input from
the bar. Mobile search UI is untouched.
2026-04-18 01:16:18 +02:00
Maurice 8042db8d7a feat(vacay,journey): apply the same unified toolbar header
Wraps the Vacay and Journey desktop headers in the shared rounded
bg-tertiary bar (title + divider + subtitle, actions grouped on the
right, border and light shadow for contrast). Vacay keeps its filters
sidebar-toggle inside the bar on tablet widths; Journey keeps the
search-toggle and the primary "Create journey" action. Mobile headers
are unchanged.
2026-04-18 01:13:33 +02:00
Maurice 21649d3cf0 feat(dashboard): unify desktop header with the planner toolbar style
Brings the dashboard header in line with the Bookings/Lists/Budget/Files
toolbars: a single rounded bg-tertiary bar that groups the title, the
active/archived trip counters, and the view-toggle + widgets + new-trip
actions. Added a border and light shadow so the bar stands out against
the dashboard background in both light and dark mode. Mobile header is
untouched.
2026-04-18 01:08:02 +02:00
Maurice b9395e1e36 Merge pull request #706 from mauriceboe/dev-maurice
feat(bookings): show transport routes on map (#384, #587)
2026-04-18 00:29:54 +02:00
Maurice 10d1f8d428 test(todo): update add-task tests for toolbar button migration
The "Add new task..." button moved from the panel into the shared
toolbar and is triggered via addItemSignal. Rewrite the three affected
tests to drive that signal through a rerender instead of clicking the
removed in-panel button.
2026-04-18 00:25:06 +02:00
Maurice 0c00f8e0b3 feat(about): add monthly supporters section with 5 tiers
- Tier cards (Hostel Bunkmate through No Return Ticket) with gradient
  icons and placeholder state for empty tiers
- Animated shimmer badge and subtle radial glow behind the card
- Mobile-responsive layout, name chips show just the month on small
  screens to avoid overflow
- Copy + translations for all 15 supported languages
2026-04-18 00:22:00 +02:00
Maurice 71637a8483 fix(tests): restore packing panel inline header + update tests for ui changes
- PackingListPanel accepts inlineHeader prop (default true) to keep its
  legacy title and inline import button; ListsContainer passes
  inlineHeader={false} since the toolbar now owns those controls
- ReservationModal tests look for the renamed 'Car' button (was 'Rental Car')
- Budget total-budget test asserts against the split integer/decimal
  spans that replaced the single text node
2026-04-17 23:56:42 +02:00
Maurice 189b257254 Merge remote-tracking branch 'origin/dev' into dev-maurice
# Conflicts:
#	client/src/components/Todo/TodoListPanel.tsx
#	server/src/db/migrations.ts
2026-04-17 23:44:53 +02:00
Maurice cd2f50bc89 chore: trigger CI 2026-04-17 23:36:31 +02:00
Maurice 530550455d feat(ui): unified toolbar design + redesigned budget widgets + polish
Trip planner now has a consistent rounded toolbar across bookings, lists,
budget and files. Each panel shows title, inline filter pills (with
counts where useful) and an accent action button on the right. Moved
per-tab controls into the toolbar — lists import, todo add, budget
currency/add-category, files trash/filters — and dropped the redundant
in-panel headers.

Budget sidebar redesigned: total-budget card with indigo-ringed avatars
and coloured split bar; settlement flows as paired avatar cards;
by-category donut rebuilt in SVG with per-category gradients. Both cards
now follow dark/light mode via a widgetTheme helper.

Todo: add-new-task is a portalled modal on desktop, the add-task input
bar is gone; new SORT BY section in the sidebar; inline category
creation in the task editor.

Reservations: pending / confirmed sections remember their collapsed
state per trip (localStorage).

Misc: per-trip connections toggle moved into the day-plan sidebar,
booking endpoints fixed to show on map for trains/cruises/cars as well,
label localStorage persistence, RESMODAL test updated to the new
airport-select flow.

i18n: the new booking / map / todo / budget strings are translated into
all 15 supported languages.
2026-04-17 23:25:38 +02:00
Julien G. 9a31fcac7b Merge pull request #710 from mauriceboe/feat/photo-thumbnail-cache-686
feat(photos): 1h disk cache for remote thumbnails + fix tab-switch redundant requests
2026-04-17 21:28:42 +02:00
jubnl 677157de1d test(journey): fix getByText assertions broken by keep-mounted tab change
Tabs are now always mounted (visibility toggled via hidden class), so
the same entry title can appear in multiple tab views simultaneously.
Replace getByText with getAllByText for presence checks; scope the
FE-086 click target to the cursor-pointer container.
2026-04-17 21:02:46 +02:00
jubnl b5b1d32b31 feat(photos): add 1h disk cache for remote thumbnails and keep tabs mounted
Closes #686

- Add trekPhotoCache service: SHA1-keyed disk cache under uploads/photos/trek/,
  1h TTL, in-flight dedup map to prevent stampedes on concurrent requests
- Add migration 108: trek_photo_cache_meta table
- Hook cache into streamPhoto for Immich/Synology thumbnail path;
  originals bypass cache
- Add fetchImmichThumbnailBytes / fetchSynologyThumbnailBytes returning
  Buffer instead of piping, used by the cache layer
- Add scheduler entry (every 2h + startup sweep) to evict expired disk
  files and DB rows via sweepExpired()
- Client: convert journey tab conditional-mount to hidden-toggle so
  img elements stay in DOM across tab switches, preventing redundant
  thumbnail requests on rapid tab changes
- Expose invalidateSize() on JourneyMapHandle; call it on map tab
  activation to fix Leaflet rendering in previously-hidden container
2026-04-17 20:49:38 +02:00
jubnl ae4dfc48cc fix(pdf): add allow-scripts to iframe sandbox to suppress CSP warning 2026-04-17 20:22:31 +02:00
Julien G. 3b487519a5 Merge pull request #709 from mauriceboe/feat/system-notice-version-gate
feat(system-notices): replace expiresAt with [minVersion, maxVersion) version gate
2026-04-17 20:15:19 +02:00
Julien G. 1425c4e05b Update maxVersion explanation in system-notices.md
Clarified the explanation regarding setting maxVersion for notices.
2026-04-17 20:09:34 +02:00
Julien G. a84aedc3b4 Fix range notation for app version filtering 2026-04-17 20:07:34 +02:00
jubnl 4b7ba6cb3f feat(system-notices): apply version gates to v3 upgrade notices 2026-04-17 20:04:54 +02:00
jubnl 5952e02971 feat(system-notices): replace expiresAt with [minVersion, maxVersion) version gate
Prevents users who upgrade across multiple versions from seeing all
interim notices at once. Version bounds are evaluated server-side using
semver.coerce so prerelease builds compare as their base release.
Range is lower-inclusive, upper-exclusive: maxVersion: '4.0.0' hides
the notice once 4.0.0 ships.
2026-04-17 20:03:23 +02:00
jubnl 8cd5aa0d23 fix(synology): correct multi-album passphrase assignment and stale trek_photos
- ProviderPicker now tracks per-asset album passphrase in a Map; on confirm,
  assets are grouped by passphrase and submitted as separate batches so each
  asset receives its own album's passphrase instead of the last-selected one
- getOrCreateTrekPhoto unconditionally overwrites the stored passphrase when
  a fresh one is supplied, allowing re-adds to heal a stuck bad passphrase
- deleteTrekPhotoIfOrphan purges the trek_photos row for provider assets when
  no trip_photos or journey_photos reference it anymore; wired into
  removeTripPhoto, removeAlbumLink, and deletePhoto so remove + re-add is a
  clean slate
- Three new integration tests: SYNO-090 (passphrase overwrite), SYNO-091
  (orphan cleanup), SYNO-092 (remove + re-add restores correct passphrase)
2026-04-17 19:48:12 +02:00
Julien G. c0aa252f9a Merge pull request #708 from mauriceboe/fix/google-places-api-quota-reduction
fix(maps): reduce Google Places API quota with persistent caching and kill-switch
2026-04-17 19:33:51 +02:00
jubnl 8a58ce51c0 feat(maps): add kill switches for Google Places autocomplete and details
Add admin toggles for places_autocomplete_enabled and places_details_enabled
alongside the existing places_photos_enabled, all default ON.

- adminService: getPlacesAutocomplete/updatePlacesAutocomplete, getPlacesDetails/updatePlacesDetails
- admin routes: GET/PUT /admin/places-autocomplete, /admin/places-details
- maps routes: autocomplete returns { suggestions: [], source: 'disabled' } when off;
  details returns { place: null, disabled: true } when off
- authService: both flags included in getAppConfig() response
- authStore: placesAutocompleteEnabled + placesDetailsEnabled state and setters
- App.tsx: wire both flags from app-config on load
- AdminPage: two new toggle rows using var(--text-primary)/var(--border-primary) consistent with rest of UI
- i18n: all 15 locales (en, de, ar, br, cs, es, fr, hu, id, it, nl, pl, ru, zh, zhTw)
2026-04-17 19:28:40 +02:00
jubnl 9c2decb095 fix(maps): reduce Google Places API quota usage with persistent caching
P0 — stop the bleeding:
- Honor place.image_url in MapView and TripPlannerPage to skip redundant fetchPhoto calls
- Trim Place Details field mask (drop reviews/editorialSummary from default; new getPlaceDetailsExpanded for inspector)
- Admin toggle places_photos_enabled (default ON) to kill Google photo fetches under quota pressure; Wikimedia unaffected
- Return { photoUrl: null } instead of 204 so client handles disabled state cleanly

P1 — structural fix:
- New placePhotoCache service: persistent disk cache at uploads/photos/google/<sha1>.jpg, atomic writes, stampede dedup via in-flight Map
- Migrations 105-107: google_place_photo_meta table, place_details_cache table, backfill signed Google URLs to stable proxy URLs
- getPlacePhoto rewrites to fetch image bytes directly, store on disk, return /api/maps/place-photo/:id/bytes proxy URL
- Stable proxy URLs written to places.image_url — survive container restarts, no expiry
- New GET /api/maps/place-photo/:placeId/bytes route serving cached files with long-lived Cache-Control
- Place Details DB row cache with 7-day TTL; ?refresh=1 escape hatch
- photoService fast-path: proxy URLs bypass the mapsApi round-trip and go straight to urlToBase64

Bug fixes:
- MapView now requests base64 thumbs for places with proxy image_url (markers were showing color fallback)
- createPlaceIcon accepts /api/maps/place-photo/ URLs as interim fallback while thumb generates
- setSelectedAssignmentId ReferenceError in mobile day-detail handler (use selectAssignment)
- Remove redundant decodeURIComponent on already-decoded Express route param
- Use SHA1 hash for disk filenames to prevent coords:lat:lng pseudo-ID collisions
- Add checkSsrf guard to Wikimedia byte fetch
- Tighten migration 107 LIKE filter to avoid rewriting manually-pasted Google image URLs
- Validate enabled is boolean on PUT /admin/places-photos
- Drop aggressive iconCache.clear() on every thumb arrival

Observability:
- googleFetch() wrapper counts and debug-logs every outbound Google API call with running total
2026-04-17 19:07:39 +02:00
Maurice 5e9c8d2c43 fix(bookings): client test failures after map overlay refactor
- Make useEndpointPane tolerant when map mock lacks getPane/createPane
- Add useMapEvents to react-leaflet mock in MapView.test
- Rewrite RESMODAL-042 to use the new AirportSelect flow (airline and
  flight number only; airport codes are now saved as endpoints, not
  metadata)
2026-04-17 19:03:21 +02:00
Julien G. 39f13881c5 Merge pull request #707 from mauriceboe/fix/journey-page-bugs
fix(journey): fix issue #704 — active logic, archive, places rename, search & trip reminders
2026-04-17 17:05:43 +02:00
jubnl 3b94727c07 fix(journey): fix issue #704 — active logic, archive, places rename, search, trip reminders
- Derive journey lifecycle from linked trip dates (live/upcoming/completed/draft)
  instead of relying solely on status field; status=archived always wins
- Add Archive/Restore Journey action in journey settings dialog
- Rename cities → places end-to-end (SQL alias, TS types, stats field, all locales)
- Wire up search icon: toggles inline input, filters by title+subtitle client-side
- Fix channelConfigured check: trip reminders enabled by default since inapp is
  always available; remove channel check, controlled solely by admin setting
- Expose notify_trip_reminder toggle in Admin → Settings → Notifications
- Add trip_date_min/trip_date_max to listJourneys SQL for client-side lifecycle
- Add archived status to Journey type (server + client)
- Update all 15 locale files with new keys (search, archive, places, trip reminders)
2026-04-17 16:59:23 +02:00
Julien G. 4a5a461d25 Merge pull request #701 from mauriceboe/fix/mobile-overlay-bottom-nav
fix(mobile): account for bottom navbar in overlays and improve system notices UX
2026-04-17 15:40:57 +02:00
jubnl 1963573db4 fix(synology): use Thumbnail API with size xl for originals to avoid HEIC
Replace SYNO.Foto.Download with SYNO.Foto.Thumbnail (size=xl) for the
original kind, mirroring the Immich approach. Synology's download endpoint
returns the raw file (HEIC for iPhone photos), while the Thumbnail API
always serves a browser-compatible JPEG render.
2026-04-17 15:35:42 +02:00
jubnl 5046e1a2e0 fix(synology): wire shared-album passphrase through journey-entry add flow
Thread selectedAlbumPassphrase from ProviderPicker through onAdd →
journeyApi.addProviderPhotos → POST /entries/:entryId/provider-photos →
addProviderPhoto service → getOrCreateTrekPhoto so shared-album photos
have their passphrase encrypted and persisted on trek_photos at add-time,
enabling streamPhoto to forward it to Synology correctly (#689).
2026-04-17 15:33:05 +02:00
jubnl a1f3b4476e fix(system-notices): overhaul mobile bottom sheet UX
- Replace "Next Notice >" CTA with proper < > pager buttons
- Fix shared scroll container: each slot now scrolls independently
- Sheet uses fixed h-[85dvh] so height is consistent across all notices
- Sticky footer (pager + CTA) always anchored at bottom of each slot
- Content area vertically centered when shorter than available space
- Dismiss-drag suppressed when slot is scrolled down (pan-up to scroll back)
- Scroll position resets on navigation via per-slot refs
- Adjacent slot scroll cleared on horizontal gesture classification
- OK button navigates to next notice on non-last pages, dismisses on last
- OK button only shown when dismissible or on last notice
2026-04-17 15:06:23 +02:00
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
jubnl b2a39a3071 Merge dev into fix/mobile-overlay-bottom-nav, resolve conflicts 2026-04-17 00:01:18 +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
jubnl e078a9d9e1 fix: getAppVersion now getting 1st from environment, fallback to package.json, fallback to 0.0.0 if all failed 2026-04-16 23:36:33 +02:00
jubnl fef12b0e8b fix(mobile): account for bottom navbar in overlays and improve system notices UX
- Add paddingBottom: var(--bottom-nav-h) to all mobile overlays that were
  clipping content behind the bottom navbar: EntryEditor, SystemNoticeModal,
  JourneyPage create modal, TodoListPanel sheets, TripPlannerPage
  PlaceInspector, PackingListPanel bag modal, both PhotoLightboxes,
  FileManager viewer, and shared Modal primitive
- Replace single-notice mobile bottom sheet with a 3-slot horizontal strip
  so adjacent notices are physically present during drag
- Add live-follow swipe left/right to navigate between notices with
  spring-back when under threshold and flushSync to eliminate blink on commit
- Add live-follow swipe down to dismiss all notices with spring-back;
  backdrop tap also triggers the slide-down animation
- Normalize notice height with useLayoutEffect minHeight on strip and
  align-items: stretch so all slots are always the tallest notice height
- Pin CTA button at consistent Y across notices via flex-1 + mt-auto;
  always render invisible Not now placeholder to equalise CTA section height
- Move pager dots/counter below CTA buttons
2026-04-16 22:49:20 +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
188 changed files with 37058 additions and 2584 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:
+1 -1
View File
@@ -16,7 +16,7 @@ client/public/icons/*.png
*.sqlite-wal
# User data
server/data/
server/data/*
server/uploads/
# Environment
+40 -99
View File
@@ -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"
}
@@ -2369,9 +2367,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2389,9 +2384,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2409,9 +2401,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2429,9 +2418,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2449,9 +2435,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2469,9 +2452,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2489,9 +2469,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2515,9 +2492,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2541,9 +2515,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2567,9 +2538,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2593,9 +2561,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2619,9 +2584,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3401,9 +3363,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3418,9 +3377,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3435,9 +3391,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3452,9 +3405,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3469,9 +3419,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3486,9 +3433,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3503,9 +3447,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3520,9 +3461,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3537,9 +3475,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3554,9 +3489,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3571,9 +3503,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3588,9 +3517,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3605,9 +3531,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3825,7 +3748,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 +3890,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 +3901,6 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@@ -4221,7 +4143,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 +4170,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -4649,7 +4571,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -5397,7 +5318,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 +6259,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 +7070,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -7261,8 +7197,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 +7332,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -8437,7 +8373,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@inquirer/confirm": "^5.0.0",
"@mswjs/interceptors": "^0.41.2",
@@ -8823,7 +8758,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8985,6 +8919,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 +9020,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 +9032,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 +9071,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 +9321,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 +9487,6 @@
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -10658,7 +10604,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -10908,7 +10853,6 @@
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11227,7 +11171,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -11356,7 +11299,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 +11796,6 @@
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
+1
View File
@@ -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",
+23 -5
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>
@@ -90,8 +100,9 @@ function RootRedirect() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
useEffect(() => {
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
@@ -105,7 +116,7 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
if (config?.demo_mode) setDemoMode(true)
if (config?.dev_mode) setDevMode(true)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
@@ -114,6 +125,9 @@ export default function App() {
if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled)
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
if (config?.version) {
@@ -145,6 +159,7 @@ export default function App() {
useEffect(() => {
if (isAuthenticated) {
loadSettings()
loadAddons()
}
}, [isAuthenticated])
@@ -182,8 +197,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 +271,7 @@ export default function App() {
<Route
path="/journey"
element={
<ProtectedRoute>
<ProtectedRoute addonId="journey">
<JourneyPage />
</ProtectedRoute>
}
@@ -261,7 +279,7 @@ export default function App() {
<Route
path="/journey/:id"
element={
<ProtectedRoute>
<ProtectedRoute addonId="journey">
<JourneyDetailPage />
</ProtectedRoute>
}
+30 -6
View File
@@ -190,18 +190,27 @@ export const placesApi = {
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
importGpx: (tripId: number | string, file: File) => {
const fd = new FormData(); fd.append('file', file)
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
const fd = new FormData()
fd.append('file', file)
if (opts?.waypoints !== undefined) fd.append('importWaypoints', String(opts.waypoints))
if (opts?.routes !== undefined) fd.append('importRoutes', String(opts.routes))
if (opts?.tracks !== undefined) fd.append('importTracks', String(opts.tracks))
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
importMapFile: (tripId: number | string, file: File) => {
const fd = new FormData(); fd.append('file', file)
importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean }) => {
const fd = new FormData()
fd.append('file', file)
if (opts?.points !== undefined) fd.append('importPoints', String(opts.points))
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
importGoogleList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
importNaverList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) =>
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
}
export const assignmentsApi = {
@@ -272,6 +281,14 @@ 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),
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { 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 +316,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 = {
@@ -327,8 +346,8 @@ export const journeyApi = {
// Photos
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data),
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
@@ -361,6 +380,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),
+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>
)
}
+6 -6
View File
@@ -130,7 +130,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://ko-fi.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -148,7 +148,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://buymeacoffee.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -166,7 +166,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
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"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -187,7 +187,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -205,7 +205,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -223,7 +223,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://github.com/mauriceboe/TREK/wiki"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -107,10 +107,12 @@ export default function PermissionsPanel(): React.ReactElement {
<button
onClick={handleReset}
disabled={saving}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
title={t('perm.resetDefaults')}
aria-label={t('perm.resetDefaults')}
className="flex items-center justify-center gap-1.5 px-0 sm:px-3 py-1.5 text-sm w-8 sm:w-auto border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" />
{t('perm.resetDefaults')}
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span>
</button>
<button
onClick={handleSave}
@@ -416,8 +416,8 @@ describe('BudgetPanel', () => {
render(<BudgetPanel tripId={1} />);
await screen.findByText('Flight');
await screen.findByText('Hotel');
// Grand total card shows 300.00
expect(screen.getByText('300.00')).toBeInTheDocument();
// Grand total card shows 300.00 (integer and decimal are rendered in separate spans)
expect(document.body.textContent?.replace(/\s+/g, '')).toMatch(/300[,.]00/);
});
it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => {
+388 -139
View File
@@ -4,7 +4,69 @@ import DOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTranslation } from '../../i18n'
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, GripVertical } from 'lucide-react'
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, GripVertical, TrendingUp, TrendingDown, PieChart as PieChartIcon } from 'lucide-react'
function useIsDark(): boolean {
const [dark, setDark] = useState<boolean>(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'))
useEffect(() => {
if (typeof document === 'undefined') return
const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark')))
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => mo.disconnect()
}, [])
return dark
}
function widgetTheme(dark: boolean) {
if (dark) return {
bg: 'linear-gradient(180deg, #17171d 0%, #0d0d12 100%)',
border: 'rgba(255,255,255,0.07)',
text: '#ffffff',
sub: 'rgba(255,255,255,0.6)',
faint: 'rgba(255,255,255,0.4)',
track: 'rgba(255,255,255,0.04)',
divider: 'rgba(255,255,255,0.07)',
iconBg: 'rgba(255,255,255,0.08)',
iconBorder: 'rgba(255,255,255,0.12)',
iconColor: 'rgba(255,255,255,0.9)',
centerBg: '#17171d',
flowBg: 'rgba(255,255,255,0.05)',
flowBorder: 'rgba(255,255,255,0.07)',
flowHoverBg: 'rgba(255,255,255,0.08)',
flowHoverBorder: 'rgba(255,255,255,0.12)',
rowHover: 'rgba(255,255,255,0.03)',
shadow: '0 20px 50px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.04)',
donutShadow: 'drop-shadow(0 0 20px rgba(0,0,0,0.3))',
}
return {
bg: 'linear-gradient(180deg, #ffffff 0%, #f9fafb 100%)',
border: 'rgba(15,23,42,0.08)',
text: '#111827',
sub: 'rgba(17,24,39,0.6)',
faint: 'rgba(17,24,39,0.4)',
track: 'rgba(15,23,42,0.05)',
divider: 'rgba(15,23,42,0.08)',
iconBg: 'rgba(15,23,42,0.05)',
iconBorder: 'rgba(15,23,42,0.1)',
iconColor: 'rgba(17,24,39,0.75)',
centerBg: '#ffffff',
flowBg: 'rgba(15,23,42,0.03)',
flowBorder: 'rgba(15,23,42,0.08)',
flowHoverBg: 'rgba(15,23,42,0.06)',
flowHoverBorder: 'rgba(15,23,42,0.14)',
rowHover: 'rgba(15,23,42,0.04)',
shadow: '0 12px 32px rgba(15,23,42,0.08), 0 2px 6px rgba(0,0,0,0.04)',
donutShadow: 'drop-shadow(0 4px 18px rgba(15,23,42,0.12))',
}
}
function hexLighten(hex: string, amount: number): string {
const m = hex.replace('#', '').match(/.{2}/g)
if (!m || m.length !== 3) return hex
const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount))
const [r, g, b] = m.map(x => parseInt(x, 16))
return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}`
}
import CustomSelect from '../shared/CustomSelect'
import { budgetApi } from '../../api/client'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
@@ -361,9 +423,47 @@ interface PerPersonInlineProps {
locale: string
}
function PerPersonInline({ tripId, budgetItems, currency, locale }: PerPersonInlineProps) {
const [data, setData] = useState(null)
const fmt = (v) => fmtNum(v, locale, currency)
const SPLIT_COLORS = [
{ solid: '#6366f1', gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)' },
{ solid: '#ec4899', gradient: 'linear-gradient(135deg, #ec4899, #f43f5e)' },
{ solid: '#10b981', gradient: 'linear-gradient(135deg, #10b981, #22c55e)' },
{ solid: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b, #f97316)' },
{ solid: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4, #3b82f6)' },
{ solid: '#a855f7', gradient: 'linear-gradient(135deg, #a855f7, #d946ef)' },
]
export function splitColorFor(userId: number, order: number) {
return SPLIT_COLORS[order % SPLIT_COLORS.length]
}
function colorForUserId(userId: number) {
return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
}
function RingAvatar({ userId, username, avatarUrl, size = 34, innerBg = '#17171d', textColor = '#fff' }: { userId: number; username?: string; avatarUrl?: string | null; size?: number; innerBg?: string; textColor?: string }) {
const color = colorForUserId(userId)
return (
<div style={{
width: size, height: size, borderRadius: '50%', flexShrink: 0,
padding: 2, background: color.gradient,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{
width: '100%', height: '100%', borderRadius: '50%',
background: innerBg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
fontSize: size < 28 ? 10 : 12, fontWeight: 600, color: textColor,
}}>
{avatarUrl ? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : username?.[0]?.toUpperCase()}
</div>
</div>
)
}
function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, theme }: PerPersonInlineProps & { grandTotal: number; theme: ReturnType<typeof widgetTheme> }) {
const [data, setData] = useState<any[] | null>(null)
const fmt = (v: number) => fmtNum(v, locale, currency)
useEffect(() => {
budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
@@ -371,25 +471,38 @@ function PerPersonInline({ tripId, budgetItems, currency, locale }: PerPersonInl
if (!data || data.length === 0) return null
const people = data.map((p: any) => ({ ...p, color: colorForUserId(p.user_id) }))
return (
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map(person => (
<div key={person.user_id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
width: 22, height: 22, borderRadius: '50%', background: 'rgba(255,255,255,0.1)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, fontWeight: 700,
color: 'rgba(255,255,255,0.7)', overflow: 'hidden', flexShrink: 0,
}}>
{person.avatar_url
? <img src={person.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: person.username?.[0]?.toUpperCase()
}
</div>
<span style={{ flex: 1, fontSize: 12, fontWeight: 500, color: 'rgba(255,255,255,0.7)' }}>{person.username}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#fff' }}>{fmt(person.total_assigned)}</span>
<>
{grandTotal > 0 && (
<div style={{ display: 'flex', height: 6, borderRadius: 999, overflow: 'hidden', marginTop: 8, marginBottom: 4, gap: 3 }}>
{people.map(p => (
<div key={p.user_id} style={{
height: '100%', borderRadius: 999,
flex: Math.max(p.total_assigned || 0, 0.01),
background: p.color.gradient,
}} />
))}
</div>
))}
</div>
)}
<div style={{ marginTop: 14, paddingTop: 14, borderTop: `1px solid ${theme.divider}`, display: 'flex', flexDirection: 'column', gap: 2 }}>
{people.map(p => {
const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0
return (
<div key={p.user_id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '6px 0' }}>
<RingAvatar userId={p.user_id} username={p.username} avatarUrl={p.avatar_url} size={34} innerBg={theme.centerBg} textColor={theme.text} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text }}>{p.username}</div>
<div style={{ fontSize: 11, color: theme.faint, marginTop: 1 }}>{percent}%</div>
</div>
<div style={{ fontSize: 13.5, fontWeight: 600, color: theme.text, letterSpacing: '-0.01em' }}>{fmt(p.total_assigned)}</div>
</div>
)
})}
</div>
</>
)
}
@@ -416,11 +529,14 @@ function PieChart({ segments, size = 200, totalLabel }: PieChartProps) {
return (
<div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
<div style={{
width: size, height: size, borderRadius: '50%',
background: `conic-gradient(${stops})`,
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
}} />
<div
className="trek-pie-reveal"
style={{
width: size, height: size, borderRadius: '50%',
background: `conic-gradient(${stops})`,
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
}}
/>
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
@@ -446,6 +562,8 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
const can = useCanDo()
const { t, locale } = useTranslation()
const isDark = useIsDark()
const theme = useMemo(() => widgetTheme(isDark), [isDark])
const [newCategoryName, setNewCategoryName] = useState('')
const [editingCat, setEditingCat] = useState(null) // { name, value }
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
@@ -589,20 +707,69 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
}
// ── Main Layout ──────────────────────────────────────────────────────────
const totalBudget = budgetItems.reduce((s, x) => s + (x.total_price || 0), 0)
return (
<div style={{ fontFamily: "'Poppins', -apple-system, BlinkMacSystemFont, system-ui, sans-serif" }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 16px 12px', flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Calculator size={20} color="var(--text-primary)" />
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
<div>
<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', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap',
}}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
{t('budget.title')}
</h2>
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
<div style={{ width: 150 }}>
<CustomSelect
value={currency}
onChange={setCurrency}
disabled={!canEdit}
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
searchable
/>
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 6, width: 260 }}>
<input
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
placeholder={t('budget.categoryName')}
style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
/>
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
title={t('budget.addCategory')}
style={{
appearance: 'none', border: 'none', cursor: newCategoryName.trim() ? 'pointer' : 'default', 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,
opacity: newCategoryName.trim() ? 1 : 0.4,
transition: 'opacity 0.15s ease',
}}>
<Plus size={14} strokeWidth={2.5} />
</button>
</div>
)}
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
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,
transition: 'opacity 0.15s ease',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
<Download size={14} strokeWidth={2.5} /> CSV
</button>
</div>
</div>
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
<Download size={13} /> CSV
</button>
</div>
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 20, padding: '24px 28px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }} className="max-md:!px-4">
<div style={{ flex: 1, minWidth: 0 }}>
{categoryNames.map((cat, ci) => {
const items = grouped.get(cat) || []
@@ -811,61 +978,57 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
})}
</div>
<div className="w-full md:w-[240px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
<div style={{ marginBottom: 12 }}>
<CustomSelect
value={currency}
onChange={setCurrency}
disabled={!canEdit}
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
searchable
/>
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
<input
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
placeholder={t('budget.categoryName')}
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
/>
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
<Plus size={16} />
</button>
</div>
)}
<div className="w-full md:w-[320px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
<div style={{
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
borderRadius: 16, padding: '24px 20px', color: '#fff', marginBottom: 16,
boxShadow: '0 8px 32px rgba(15,23,42,0.18)',
background: theme.bg,
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
border: `1px solid ${theme.border}`,
boxShadow: theme.shadow,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Wallet size={18} color="rgba(255,255,255,0.8)" />
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
<div style={{
width: 40, height: 40, borderRadius: 12,
background: theme.iconBg,
border: `1px solid ${theme.iconBorder}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: theme.iconColor, flexShrink: 0,
}}>
<Wallet size={20} strokeWidth={2} />
</div>
<div>
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</div>
</div>
</div>
<div style={{ fontSize: 22, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
{(() => {
const decimals = currencyDecimals(currency)
const full = Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
const sep = (0.1).toLocaleString(locale).replace(/\d/g, '')
const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, '']
return (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}>
<span style={{ fontSize: 38, fontWeight: 700 }}>{integerPart}</span>
{decimalPart && <span style={{ fontSize: 22, fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>}
<span style={{ fontSize: 22, fontWeight: 500, color: theme.sub, marginLeft: 2 }}>{SYMBOLS[currency] || currency}</span>
</div>
)
})()}
<div style={{ color: theme.faint, fontSize: 12, marginTop: 8, fontWeight: 500, letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 6 }}>
<span>{currency}</span>
</div>
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} />
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} grandTotal={grandTotal} theme={theme} />
)}
{/* Settlement dropdown inside the total card */}
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 12 }}>
<div style={{ marginTop: 16, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
<button onClick={() => setSettlementOpen(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
color: 'rgba(255,255,255,0.6)', fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
color: theme.sub, fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
}}>
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{t('budget.settlement')}
@@ -890,53 +1053,60 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
</button>
{settlementOpen && (
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
{settlement.flows.map((flow, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
padding: '8px 10px', borderRadius: 10,
background: 'rgba(255,255,255,0.06)',
}}>
<ChipWithTooltip label={flow.from.username} avatarUrl={flow.from.avatar_url} size={28} />
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}></span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#f87171', whiteSpace: 'nowrap' }}>
display: 'flex', alignItems: 'center', gap: 14,
padding: '12px 14px', borderRadius: 14,
background: theme.flowBg,
border: `1px solid ${theme.flowBorder}`,
transition: 'all 0.2s',
}}
onMouseEnter={e => { e.currentTarget.style.background = theme.flowHoverBg; e.currentTarget.style.borderColor = theme.flowHoverBorder }}
onMouseLeave={e => { e.currentTarget.style.background = theme.flowBg; e.currentTarget.style.borderColor = theme.flowBorder }}
>
<RingAvatar userId={flow.from.user_id} username={flow.from.username} avatarUrl={flow.from.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}>
{fmt(flow.amount, currency)}
</span>
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}></span>
<div style={{ width: '100%', height: 2, borderRadius: 2, background: 'linear-gradient(90deg, rgba(239,68,68,0.1), rgba(239,68,68,0.55), rgba(239,68,68,0.3))', position: 'relative' }}>
<div style={{ position: 'absolute', right: -1, top: '50%', transform: 'translateY(-50%)', width: 0, height: 0, borderLeft: '6px solid rgba(239,68,68,0.55)', borderTop: '4px solid transparent', borderBottom: '4px solid transparent' }} />
</div>
</div>
<ChipWithTooltip label={flow.to.username} avatarUrl={flow.to.avatar_url} size={28} />
<RingAvatar userId={flow.to.user_id} username={flow.to.username} avatarUrl={flow.to.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
</div>
))}
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
<div style={{ marginTop: 4, borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 8 }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'rgba(255,255,255,0.35)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
<div style={{ marginTop: 8, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}>
{t('budget.netBalances')}
</div>
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => (
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '2px 0' }}>
<div style={{
width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 8, fontWeight: 700, color: 'rgba(255,255,255,0.6)', overflow: 'hidden',
}}>
{b.avatar_url
? <img src={b.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: b.username?.[0]?.toUpperCase()
}
</div>
<span style={{ flex: 1, fontSize: 11, color: 'rgba(255,255,255,0.6)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{b.username}
</span>
<span style={{
fontSize: 11, fontWeight: 600, flexShrink: 0,
color: b.balance > 0 ? '#4ade80' : '#f87171',
}}>
{b.balance > 0 ? '+' : ''}{fmt(b.balance, currency)}
</span>
</div>
))}
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => {
const positive = b.balance > 0
const Trend = positive ? TrendingUp : TrendingDown
return (
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 0' }}>
<RingAvatar userId={b.user_id} username={b.username} avatarUrl={b.avatar_url} size={26} innerBg={theme.centerBg} textColor={theme.text} />
<span style={{ flex: 1, fontSize: 13, color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{b.username}
</span>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '4px 10px', borderRadius: 8,
fontSize: 12, fontWeight: 700, letterSpacing: '-0.01em',
background: positive ? 'rgba(16,185,129,0.13)' : 'rgba(239,68,68,0.13)',
color: positive ? '#10b981' : '#ef4444',
}}>
<Trend size={11} strokeWidth={3} />
{positive ? '+' : ''}{fmt(b.balance, currency)}
</span>
</div>
)
})}
</div>
</div>
)}
</div>
@@ -945,36 +1115,115 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
)}
</div>
{pieSegments.length > 0 && (
<div style={{
background: 'var(--bg-card)', borderRadius: 16, padding: '20px 16px',
border: '1px solid var(--border-primary)',
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
marginBottom: 16,
}}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 16, textAlign: 'center' }}>{t('budget.byCategory')}</div>
{pieSegments.length > 0 && (() => {
const decimals = currencyDecimals(currency)
const total = pieSegments.reduce((s, x) => s + x.value, 0)
const totalFmt = Number(total).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
const decimalSep = (0.1).toLocaleString(locale).replace(/\d/g, '')
const [totalInt, totalDec] = decimals > 0 ? totalFmt.split(decimalSep) : [totalFmt, '']
const R = 80
const CIRC = 2 * Math.PI * R
let dashOffset = 0
return (
<div style={{
background: theme.bg,
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
border: `1px solid ${theme.border}`,
boxShadow: theme.shadow,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
<div style={{
width: 38, height: 38, borderRadius: 11,
background: theme.iconBg,
border: `1px solid ${theme.iconBorder}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: theme.iconColor, flexShrink: 0,
}}>
<PieChartIcon size={18} strokeWidth={2} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div>
</div>
</div>
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 0 }}>
{pieSegments.map((seg, i) => {
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
return (
<div key={seg.name} style={{ padding: '8px 0', borderTop: i > 0 ? '1px solid var(--border-secondary)' : 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
<span style={{ fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>{seg.name}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 3, paddingLeft: 18 }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 500 }}>{fmt(seg.value, currency)}</span>
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 600, background: 'var(--bg-secondary)', padding: '1px 6px', borderRadius: 99 }}>{pct}%</span>
</div>
<div style={{ position: 'relative', display: 'flex', justifyContent: 'center', margin: '4px 0 16px' }}>
<svg width={200} height={200} viewBox="0 0 200 200" style={{ transform: 'rotate(-90deg)', filter: theme.donutShadow }}>
<defs>
{pieSegments.map((seg, i) => {
const c2 = hexLighten(seg.color, 0.2)
return (
<linearGradient key={`grad-${i}`} id={`cat-grad-${i}`} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor={seg.color} />
<stop offset="100%" stopColor={c2} />
</linearGradient>
)
})}
</defs>
<circle cx={100} cy={100} r={R} fill="none" stroke={theme.track} strokeWidth={22} />
{pieSegments.map((seg, i) => {
const segLen = total > 0 ? (seg.value / total) * CIRC : 0
const circle = (
<circle key={i}
cx={100} cy={100} r={R}
fill="none" strokeLinecap="round" strokeWidth={22}
stroke={`url(#cat-grad-${i})`}
strokeDasharray={`${segLen} ${CIRC}`}
strokeDashoffset={-dashOffset}
/>
)
dashOffset += segLen
return circle
})}
</svg>
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, pointerEvents: 'none' }}>
<div style={{ fontSize: 10.5, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{t('budget.total')}</div>
<div style={{ fontSize: 22, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
<span>{totalInt}</span>
{totalDec && <span style={{ fontSize: 13, fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
</div>
)
})}
<div style={{ fontSize: 10.5, color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div>
</div>
</div>
<div style={{ borderTop: `1px solid ${theme.divider}`, paddingTop: 10, display: 'flex', flexDirection: 'column', gap: 2 }}>
{pieSegments.map((seg, i) => {
const pct = total > 0 ? (seg.value / total) * 100 : 0
const pctLabel = pct.toFixed(1).replace('.', decimalSep) + '%'
const c2 = hexLighten(seg.color, 0.2)
const chipColor = isDark ? hexLighten(seg.color, 0.35) : seg.color
return (
<div key={seg.name} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 8px', borderRadius: 12,
transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = theme.rowHover}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<div style={{
width: 10, height: 10, borderRadius: 3, flexShrink: 0,
background: `linear-gradient(135deg, ${seg.color}, ${c2})`,
boxShadow: `0 0 12px ${seg.color}80`,
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{seg.name}</div>
<div style={{ fontSize: 11.5, color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div>
</div>
<span style={{
flexShrink: 0,
padding: '4px 9px', borderRadius: 7,
fontSize: 11, fontWeight: 700, letterSpacing: '-0.01em',
background: `${seg.color}26`,
border: `1px solid ${seg.color}40`,
color: chipColor,
}}>{pctLabel}</span>
</div>
)
})}
</div>
</div>
</div>
)}
)
})()}
</div>
</div>
+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>
)
+78 -22
View File
@@ -94,7 +94,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column' }}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column', paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
onTouchEnd={e => {
@@ -779,25 +779,81 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
document.body
)}
{/* Header */}
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{showTrash
? `${trashFiles.length} ${trashFiles.length === 1 ? 'file' : 'files'}`
: (files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length }))}
</p>
</div>
<button onClick={toggleTrash} style={{
padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)',
background: showTrash ? 'var(--accent)' : 'var(--bg-card)',
color: showTrash ? 'var(--accent-text)' : 'var(--text-muted)',
fontSize: 12, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 5,
fontFamily: 'inherit',
{/* Toolbar */}
<div style={{ padding: '24px 28px 0', flexShrink: 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',
}}>
<Trash2 size={13} /> {t('files.trash') || 'Trash'}
</button>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}
</h2>
{!showTrash && (
<>
<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 }}>
{[
{ id: 'all', label: t('files.filterAll') },
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star } as const] : []),
{ id: 'pdf', label: t('files.filterPdf') },
{ id: 'image', label: t('files.filterImages') },
{ id: 'doc', label: t('files.filterDocs') },
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
].map(tab => {
const active = filterType === tab.id
const TabIcon = 'icon' in tab ? tab.icon : null
const count = tab.id === 'all' ? files.length
: tab.id === 'starred' ? files.filter(f => f.starred).length
: tab.id === 'pdf' ? files.filter(f => (f.mime_type || '').includes('pdf') || /\.pdf$/i.test(f.original_name)).length
: tab.id === 'image' ? files.filter(f => (f.mime_type || '').startsWith('image/')).length
: tab.id === 'doc' ? files.filter(f => /\.(docx?|xlsx?|txt|csv)$/i.test(f.original_name)).length
: tab.id === 'collab' ? files.filter(f => f.note_id).length
: 0
return (
<button key={tab.id} onClick={() => setFilterType(tab.id)}
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',
}}
>
{TabIcon ? <TabIcon size={13} fill={active ? '#facc15' : 'none'} color={active ? '#facc15' : 'currentColor'} /> : null}
{'label' in tab && tab.label}
<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',
}}>{count}</span>
</button>
)
})}
</div>
</>
)}
<button onClick={toggleTrash} 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',
opacity: showTrash ? 1 : 0.88,
transition: 'opacity 0.15s ease',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '1'}
onMouseLeave={e => e.currentTarget.style.opacity = showTrash ? '1' : '0.88'}
>
<Trash2 size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">{t('files.trash') || 'Trash'}</span>
</button>
</div>
</div>
{showTrash ? (
@@ -835,7 +891,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{can('file_upload', trip) && <div
{...getRootProps()}
style={{
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
margin: '16px 28px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
@@ -860,7 +916,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</div>}
{/* Filter tabs */}
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
<div className="md:!hidden" style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
{[
{ id: 'all', label: t('files.filterAll') },
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []),
@@ -883,7 +939,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</div>
{/* File list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 28px 16px' }} className="max-md:!px-4">
{filteredFiles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
+35 -14
View File
@@ -14,6 +14,7 @@ export interface MapMarkerItem {
export interface JourneyMapHandle {
highlightMarker: (id: string | null) => void
focusMarker: (id: string) => void
invalidateSize: () => void
}
interface MapEntry {
@@ -33,6 +34,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 +60,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 +90,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,11 +146,17 @@ 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 */ }
}
}, [])
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker }), [])
const invalidateSize = useCallback(() => {
try { mapRef.current?.invalidateSize() } catch { /* map not yet initialized */ }
}, [])
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [])
useEffect(() => {
if (!containerRef.current) return
@@ -156,7 +170,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 +199,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 +243,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 +260,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(() => {
@@ -254,8 +269,14 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
const timer = setTimeout(() => {
highlightMarker(activeMarkerId)
const marker = markersRef.current.get(activeMarkerId)
if (marker && mapRef.current) {
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
if (!marker || !mapRef.current) return
// fitBounds may still be pending when this fires — getZoom() throws
// "Set map center and zoom first" until the map has a view. Guard it.
try {
const currentZoom = mapRef.current.getZoom()
mapRef.current.flyTo(marker.getLatLng(), Math.max(currentZoom, 12), { duration: 0.5 })
} catch {
mapRef.current.setView(marker.getLatLng(), 12)
}
}, 50)
return () => clearTimeout(timer)
@@ -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,221 @@
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
readOnly?: boolean
onClose: () => void
onEdit: () => void
onDelete: () => void
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
}
export default function MobileEntryView({ entry, readOnly, 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>
{!readOnly && (
<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,236 @@
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())
const activeIndexRef = useRef(activeIndex)
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
// 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])
// Pick the card that's currently closest to the carousel horizontal center.
// More stable than IntersectionObserver thresholds when the active card can
// drift toward the viewport edge with proximity snapping.
const pickNearestCard = useCallback(() => {
const el = carouselRef.current
if (!el) return
const containerCenter = el.getBoundingClientRect().left + el.clientWidth / 2
let bestIdx = 0
let bestDist = Infinity
cardRefs.current.forEach((node, idx) => {
const r = node.getBoundingClientRect()
const cardCenter = r.left + r.width / 2
const d = Math.abs(cardCenter - containerCenter)
if (d < bestDist) { bestDist = d; bestIdx = idx }
})
setActiveIndex(prev => {
if (prev !== bestIdx) syncMapToCarousel(bestIdx)
return bestIdx
})
}, [syncMapToCarousel])
// Track scroll; debounce to re-center the active card when the user stops.
useEffect(() => {
const el = carouselRef.current
if (!el || entries.length === 0) return
let rafId: number | null = null
let settleTimer: number | null = null
const onScroll = () => {
if (rafId != null) return
rafId = requestAnimationFrame(() => {
pickNearestCard()
rafId = null
})
if (settleTimer != null) window.clearTimeout(settleTimer)
settleTimer = window.setTimeout(() => {
// Ensure the active card sits at the center once the user settles.
const card = cardRefs.current.get(activeIndexRef.current)
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
}, 180)
}
el.addEventListener('scroll', onScroll, { passive: true })
return () => {
el.removeEventListener('scroll', onScroll)
if (rafId != null) cancelAnimationFrame(rafId)
if (settleTimer != null) window.clearTimeout(settleTimer)
}
}, [entries.length, pickNearestCard])
// Scroll a given card into the horizontal center of the carousel
const scrollCardIntoCenter = useCallback((idx: number) => {
const card = cardRefs.current.get(idx)
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
}, [])
// 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)
scrollCardIntoCenter(idx)
}, [entries, scrollCardIntoCenter])
// Tap on a card: if it's already active, open the edit view; otherwise
// activate + center it first (don't jump straight into the editor).
const handleCardTap = useCallback((entry: any, idx: number) => {
if (idx === activeIndex) {
onEntryClick(entry)
} else {
setActiveIndex(idx)
scrollCardIntoCenter(idx)
}
}, [activeIndex, onEntryClick, scrollCardIntoCenter])
// 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 right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}>
<button
onClick={onAddEntry}
className="w-12 h-12 rounded-full 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={20} />
</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 left-0 right-0 z-40"
style={{ touchAction: 'pan-x', bottom: 'calc(var(--bottom-nav-h, 84px) + 8px)' }}
>
<div
ref={carouselRef}
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
style={{
scrollSnapType: 'x proximity',
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={() => handleCardTap(entry, i)}
publicPhotoUrl={publicPhotoUrl}
/>
</div>
))}
</div>
</div>
{/* FAB: add entry — bottom right, above the timeline carousel */}
{!readOnly && onAddEntry && (
<div
className="fixed right-4 z-30"
style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 168px)' }}
>
<button
onClick={onAddEntry}
className="w-12 h-12 rounded-full 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={20} />
</button>
</div>
)}
</div>
)
}
@@ -69,6 +69,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
position: 'fixed', inset: 0, zIndex: 500,
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
display: 'flex', flexDirection: 'column',
paddingBottom: 'var(--bottom-nav-h)',
}}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
+33 -8
View File
@@ -34,9 +34,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
const navigate = useNavigate()
const location = useLocation()
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
const [scrolled, setScrolled] = useState<boolean>(false)
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8 || (document.body.scrollTop || 0) > 8)
onScroll()
window.addEventListener('scroll', onScroll, { passive: true })
document.body.addEventListener('scroll', onScroll, { passive: true })
return () => {
window.removeEventListener('scroll', onScroll)
document.body.removeEventListener('scroll', onScroll)
}
}, [])
// Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page
const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled)
@@ -50,7 +62,11 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
}
const toggleDarkMode = () => {
document.documentElement.classList.add('trek-theme-transitioning')
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
window.setTimeout(() => {
document.documentElement.classList.remove('trek-theme-transitioning')
}, 360)
}
const getAddonName = (addon: Addon): string => {
@@ -61,23 +77,29 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
return (
<nav style={{
background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
background: dark
? (scrolled ? 'rgba(9,9,11,0.78)' : 'rgba(9,9,11,0.95)')
: (scrolled ? 'rgba(255,255,255,0.72)' : 'rgba(255,255,255,0.95)'),
backdropFilter: scrolled ? 'blur(28px) saturate(180%)' : 'blur(20px)',
WebkitBackdropFilter: scrolled ? 'blur(28px) saturate(180%)' : 'blur(20px)',
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
boxShadow: dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)',
boxShadow: scrolled
? (dark ? '0 4px 24px rgba(0,0,0,0.35)' : '0 4px 24px rgba(0,0,0,0.08)')
: (dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)'),
touchAction: 'manipulation',
paddingTop: 'env(safe-area-inset-top, 0px)',
height: 'var(--nav-h)',
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
{/* Left side */}
<div className="flex items-center gap-3 min-w-0">
{showBack && (
<button onClick={onBack}
className="p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0"
className="trek-back-btn p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<ArrowLeft className="w-4 h-4" />
<ArrowLeft className="trek-back-icon w-4 h-4" />
<span className="hidden sm:inline">{t('common.back')}</span>
</button>
)}
@@ -161,11 +183,14 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex"
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex relative w-8 h-8 items-center justify-center"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
<Sun className="w-4 h-4 absolute transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ opacity: dark ? 1 : 0, transform: dark ? 'rotate(0deg) scale(1)' : 'rotate(-90deg) scale(0.6)' }} />
<Moon className="w-4 h-4 absolute transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ opacity: dark ? 0 : 1, transform: dark ? 'rotate(90deg) scale(0.6)' : 'rotate(0deg) scale(1)' }} />
</button>
{/* Notification bell — only in trip view on mobile, everywhere on desktop */}
@@ -196,7 +221,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{userMenuOpen && ReactDOM.createPortal(
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setUserMenuOpen(false)} />
<div className="w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="trek-menu-enter w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
+19 -8
View File
@@ -1,7 +1,8 @@
import React from 'react'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import { fireEvent } from '@testing-library/react'
import { fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories'
import * as photoService from '../../services/photoService'
@@ -16,10 +17,13 @@ vi.mock('react-leaflet', () => ({
data-lng={position[1]}
onClick={() => eventHandlers?.click?.()}
>
<button
data-testid="marker-hover-trigger"
onClick={() => eventHandlers?.mouseover?.({ originalEvent: { clientX: 100, clientY: 100 } })}
/>
{children}
</div>
),
Tooltip: ({ children }: any) => <div data-testid="tooltip">{children}</div>,
Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />,
CircleMarker: () => <div data-testid="circle-marker" />,
Circle: () => <div data-testid="circle" />,
@@ -32,6 +36,7 @@ vi.mock('react-leaflet', () => ({
off: vi.fn(),
panBy: vi.fn(),
}),
useMapEvents: () => ({}),
}))
vi.mock('react-leaflet-cluster', () => ({
@@ -100,22 +105,26 @@ describe('MapView', () => {
expect(onMarkerClick).toHaveBeenCalledWith(42)
})
it('FE-COMP-MAPVIEW-004: tooltip shows place name', () => {
it('FE-COMP-MAPVIEW-004: tooltip shows place name', async () => {
const user = userEvent.setup()
const places = [buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })]
render(<MapView places={places} />)
await user.click(screen.getByTestId('marker-hover-trigger'))
expect(screen.getByTestId('tooltip').textContent).toContain('Eiffel Tower')
})
it('FE-COMP-MAPVIEW-005: tooltip shows category name when present', () => {
it('FE-COMP-MAPVIEW-005: tooltip shows category name when present', async () => {
const user = userEvent.setup()
const places = [
buildMapPlace({ name: 'Louvre', lat: 48.86, lng: 2.337, category_name: 'Museum', category_icon: null }),
]
render(<MapView places={places} />)
await user.click(screen.getByTestId('marker-hover-trigger'))
expect(screen.getByTestId('tooltip').textContent).toContain('Museum')
})
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
render(<MapView route={[[48.0, 2.0], [49.0, 3.0]]} />)
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />)
expect(screen.getByTestId('polyline')).toBeTruthy()
})
@@ -125,7 +134,7 @@ describe('MapView', () => {
})
it('FE-COMP-MAPVIEW-008: does not render polyline for single-point route', () => {
render(<MapView route={[[48.0, 2.0]]} />)
render(<MapView route={[[[48.0, 2.0]]]} />)
expect(screen.queryByTestId('polyline')).toBeNull()
})
@@ -144,7 +153,7 @@ describe('MapView', () => {
})
it('FE-COMP-MAPVIEW-011: renders RouteLabel marker when routeSegments provided with route', () => {
const route = [[48.0, 2.0], [49.0, 3.0]] as [number, number][]
const route = [[[48.0, 2.0], [49.0, 3.0]]] as [number, number][][][]
const routeSegments = [
{ mid: [48.5, 2.5] as [number, number], from: 0, to: 1, walkingText: '10 min', drivingText: '3 min' },
]
@@ -190,11 +199,13 @@ describe('MapView', () => {
vi.mocked(photoService.getCached).mockReturnValue(null)
})
it('FE-COMP-MAPVIEW-016: tooltip shows address when present', () => {
it('FE-COMP-MAPVIEW-016: tooltip shows address when present', async () => {
const user = userEvent.setup()
const places = [
buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, address: '5 Av. Anatole France' }),
]
render(<MapView places={places} />)
await user.click(screen.getByTestId('marker-hover-trigger'))
expect(screen.getByTestId('tooltip').textContent).toContain('5 Av. Anatole France')
})
+174 -80
View File
@@ -1,13 +1,15 @@
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
import DOM from 'react-dom'
import { renderToStaticMarkup } from 'react-dom/server'
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import L from 'leaflet'
import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
import { mapsApi } from '../../api/client'
import { getCategoryIcon, 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']
@@ -66,9 +68,9 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
">${label}</span>`
}
// Base64 data URL thumbnails — no external image fetch during zoom
// Only use base64 data URLs for markers — external URLs cause zoom lag
if (place.image_url && place.image_url.startsWith('data:')) {
// Prefer base64 data URLs (no zoom lag); also accept same-origin proxy URLs as a fallback
// while the thumb is still being generated in the background
if (place.image_url && (place.image_url.startsWith('data:') || place.image_url.startsWith('/api/maps/place-photo/'))) {
const imgIcon = L.divIcon({
className: '',
html: `<div style="
@@ -275,6 +277,7 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
// Module-level photo cache shared with PlaceAvatar
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { useAuthStore } from '../../store/authStore'
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
function LocationTracker() {
@@ -366,6 +369,35 @@ function LocationTracker() {
)
}
interface MemoMarkerProps {
place: any
isSelected: boolean
orderNumbers: number[] | null
photoUrl: string | null
onClickPlace: (id: number) => void
onHover: (place: any, x: number, y: number) => void
onHoverOut: () => void
}
const MemoMarker = memo(function MemoMarker({
place, isSelected, orderNumbers, photoUrl, onClickPlace, onHover, onHoverOut,
}: MemoMarkerProps) {
const icon = createPlaceIcon({ ...place, image_url: photoUrl }, orderNumbers, isSelected)
return (
<Marker
position={[place.lat, place.lng]}
icon={icon}
eventHandlers={{
click: () => onClickPlace(place.id),
mouseover: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
mousemove: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
mouseout: onHoverOut,
}}
zIndexOffset={isSelected ? 1000 : 0}
/>
)
})
export const MapView = memo(function MapView({
places = [],
dayPlaces = [],
@@ -384,7 +416,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
@@ -396,22 +437,51 @@ export const MapView = memo(function MapView({
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
const [hoveredPlace, setHoveredPlace] = useState<any>(null)
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null)
const handleMarkerHover = useCallback((place: any, x: number, y: number) => {
setHoveredPlace(place)
setTooltipPos({ x, y })
}, [])
const handleMarkerHoverOut = useCallback(() => {
setHoveredPlace(null)
}, [])
const handleMarkerClick = useCallback((id: number) => {
onMarkerClick?.(id)
}, [onMarkerClick])
// photoUrls: only base64 thumbs for smooth map zoom
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
// Batch photo state updates through a RAF so N simultaneous photo loads
// collapse into a single re-render instead of N separate renders.
const pendingThumbsRef = useRef<Record<string, string>>({})
const thumbRafRef = useRef<number | null>(null)
// Fetch photos via shared service — subscribe to thumb (base64) availability
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
useEffect(() => {
if (!places || places.length === 0) return
if (!places || places.length === 0 || !placesPhotosEnabled) return
const cleanups: (() => void)[] = []
const setThumb = (cacheKey: string, thumb: string) => {
iconCache.clear()
setPhotoUrls(prev => prev[cacheKey] === thumb ? prev : { ...prev, [cacheKey]: thumb })
pendingThumbsRef.current[cacheKey] = thumb
if (thumbRafRef.current !== null) return
thumbRafRef.current = requestAnimationFrame(() => {
thumbRafRef.current = null
const pending = pendingThumbsRef.current
pendingThumbsRef.current = {}
setPhotoUrls(prev => {
const hasChange = Object.entries(pending).some(([k, v]) => prev[k] !== v)
return hasChange ? { ...prev, ...pending } : prev
})
})
}
for (const place of places) {
if (place.image_url && place.image_url.startsWith('data:')) continue
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
if (!cacheKey) continue
@@ -421,20 +491,24 @@ export const MapView = memo(function MapView({
continue
}
// Subscribe for when thumb becomes available
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
// Always fetch through API — returns fresh URL + converts to base64
if (!cached && !isLoading(cacheKey)) {
const photoId = place.google_place_id || place.osm_id
const photoId = place.image_url || place.google_place_id || place.osm_id
if (photoId || (place.lat && place.lng)) {
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
}
}
}
return () => cleanups.forEach(fn => fn())
}, [placeIds])
return () => {
cleanups.forEach(fn => fn())
if (thumbRafRef.current !== null) {
cancelAnimationFrame(thumbRafRef.current)
thumbRafRef.current = null
}
}
}, [placeIds, placesPhotosEnabled])
const clusterIconCreateFunction = useCallback((cluster) => {
const count = cluster.getChildCount()
@@ -446,57 +520,49 @@ export const MapView = memo(function MapView({
})
}, [])
const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0
const markers = useMemo(() => places.map((place) => {
const isSelected = place.id === selectedPlaceId
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
const resolvedPhoto = (pck && photoUrls[pck]) || (place.image_url?.startsWith('data:') ? place.image_url : null) || null
const photoUrl = (pck && photoUrls[pck]) || place.image_url || null
const orderNumbers = dayOrderMap[place.id] ?? null
const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected)
return (
<Marker
<MemoMarker
key={place.id}
position={[place.lat, place.lng]}
icon={icon}
eventHandlers={{
click: () => onMarkerClick && onMarkerClick(place.id),
}}
zIndexOffset={isSelected ? 1000 : 0}
>
<Tooltip
direction="right"
offset={[0, 0]}
opacity={1}
className="map-tooltip"
permanent={isTouchDevice && isSelected}
>
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
{place.name}
</div>
{place.category_name && (() => {
const CatIcon = getCategoryIcon(place.category_icon)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
</div>
)
})()}
{place.address && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{place.address}
</div>
)}
</div>
</Tooltip>
</Marker>
place={place}
isSelected={isSelected}
orderNumbers={orderNumbers}
photoUrl={photoUrl}
onClickPlace={handleMarkerClick}
onHover={handleMarkerHover}
onHoverOut={handleMarkerHoverOut}
/>
)
}), [places, selectedPlaceId, dayOrderMap, photoUrls, onMarkerClick, isTouchDevice])
}), [places, selectedPlaceId, dayOrderMap, photoUrls, handleMarkerClick, handleMarkerHover, handleMarkerHoverOut])
const gpxPolylines = useMemo(() => places.flatMap(place => {
if (!place.route_geometry) return []
try {
const coords = JSON.parse(place.route_geometry) as [number, number][]
if (!coords || coords.length < 2) return []
return [(
<Polyline
key={`gpx-${place.id}`}
positions={coords}
color={place.category_color || '#3b82f6'}
weight={3.5}
opacity={0.75}
/>
)]
} catch { return [] }
}), [places])
const TooltipOverlay = hoveredPlace && tooltipPos && !isTouchDevice
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
return (
<>
<MapContainer
id="trek-map"
center={center}
@@ -537,15 +603,18 @@ export const MapView = memo(function MapView({
{markers}
</MarkerClusterGroup>
{route && route.length > 1 && (
{route && route.length > 0 && (
<>
<Polyline
positions={route}
color="#111827"
weight={3}
opacity={0.9}
dashArray="6, 5"
/>
{route.map((seg, i) => seg.length > 1 && (
<Polyline
key={i}
positions={seg}
color="#111827"
weight={3}
opacity={0.9}
dashArray="6, 5"
/>
))}
{routeSegments.map((seg, i) => (
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
))}
@@ -553,22 +622,47 @@ export const MapView = memo(function MapView({
)}
{/* GPX imported route geometries */}
{places.map((place) => {
if (!place.route_geometry) return null
try {
const coords = JSON.parse(place.route_geometry) as [number, number][]
if (!coords || coords.length < 2) return null
return (
<Polyline
key={`gpx-${place.id}`}
positions={coords}
color={place.category_color || '#3b82f6'}
weight={3.5}
opacity={0.75}
/>
)
} catch { return null }
})}
{gpxPolylines}
<ReservationOverlay
reservations={visibleReservations}
showConnections
showStats={showReservationStats}
onEndpointClick={onReservationClick}
/>
</MapContainer>
{TooltipOverlay && (
<div data-testid="tooltip" style={{
position: 'fixed',
left: tooltipPos.x + 14,
top: tooltipPos.y - 10,
zIndex: 9999,
pointerEvents: 'none',
background: 'white',
borderRadius: 8,
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
padding: '6px 10px',
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
maxWidth: 220,
whiteSpace: 'nowrap',
}}>
<div style={{ fontWeight: 600, fontSize: 12, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{hoveredPlace.name}
</div>
{hoveredPlace.category_name && CatIcon && (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
<CatIcon size={10} style={{ color: hoveredPlace.category_color || '#6b7280', flexShrink: 0 }} />
<span style={{ fontSize: 11, color: '#6b7280' }}>{hoveredPlace.category_name}</span>
</div>
)}
{hoveredPlace.address && (
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{hoveredPlace.address}
</div>
)}
</div>
)}
</>
)
})
@@ -0,0 +1,447 @@
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 (typeof map?.getPane !== 'function' || typeof map?.createPane !== 'function') return
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',
@@ -581,7 +582,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
borderColor: !pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
color: !pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
{t('memories.allPhotos')}
<span className="hidden sm:inline">{t('memories.allPhotos')}</span>
<span className="sm:hidden">{t('common.all')}</span>
</button>
</div>
{selectedIds.size > 0 && (
@@ -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 allow-scripts'
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() }
}
+1 -1
View File
@@ -521,7 +521,7 @@ ${daysHtml}
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
iframe.sandbox = 'allow-same-origin allow-modals'
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
iframe.srcdoc = html
card.appendChild(header)
@@ -0,0 +1,102 @@
import React, { useEffect, useRef, useState } from 'react'
import { Package } from 'lucide-react'
import { adminApi, packingApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
interface Template {
id: number
name: string
item_count: number
}
interface ApplyTemplateButtonProps {
tripId: number
style: React.CSSProperties
className?: string
}
// Dropdown-Button um ein Packing-Template auf den aktuellen Trip anzuwenden.
// Rendert nichts wenn keine Templates existieren.
export default function ApplyTemplateButton({ tripId, style, className }: ApplyTemplateButtonProps): React.ReactElement | null {
const [templates, setTemplates] = useState<Template[]>([])
const [open, setOpen] = useState(false)
const [applying, setApplying] = useState(false)
const dropRef = useRef<HTMLDivElement>(null)
const toast = useToast()
const { t } = useTranslation()
useEffect(() => {
adminApi.packingTemplates().then(d => setTemplates(d.templates || [])).catch(() => {})
}, [tripId])
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (dropRef.current && !dropRef.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const handleApply = async (templateId: number) => {
setApplying(true)
try {
const data = await packingApi.applyTemplate(tripId, templateId)
toast.success(t('packing.templateApplied', { count: data.count }))
setOpen(false)
window.location.reload()
} catch {
toast.error(t('packing.templateError'))
} finally {
setApplying(false)
}
}
if (templates.length === 0) return null
return (
<div ref={dropRef} style={{ position: 'relative' }}>
<button
onClick={() => setOpen(v => !v)}
disabled={applying}
className={className ?? 'hover:opacity-[0.88]'}
style={style}
>
<Package size={14} strokeWidth={2.5} />
<span className="hidden sm:inline">{t('packing.applyTemplate')}</span>
</button>
{open && (
<div
className="trek-menu-enter"
style={{
position: 'absolute', right: 0, top: '100%', marginTop: 6, zIndex: 50,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220,
transformOrigin: 'top right',
}}
>
{templates.map(tmpl => (
<button key={tmpl.id} onClick={() => handleApply(tmpl.id)}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '8px 12px', borderRadius: 8, border: 'none', cursor: 'pointer',
background: 'transparent', fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Package size={13} style={{ color: 'var(--text-faint)' }} />
<div style={{ flex: 1, textAlign: 'left' }}>
<div style={{ fontWeight: 600 }}>{tmpl.name}</div>
<div style={{ fontSize: 10, color: 'var(--text-faint)' }}>
{tmpl.item_count} {t('admin.packingTemplates.items')}
</div>
</div>
</button>
))}
</div>
)}
</div>
)
}
@@ -253,10 +253,23 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
}}
>
<button onClick={handleToggle} style={{
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex',
color: item.checked ? '#10b981' : 'var(--text-faint)', transition: 'color 0.15s',
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, position: 'relative',
width: 18, height: 18,
color: item.checked ? '#10b981' : 'var(--text-faint)',
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
}}>
{item.checked ? <CheckSquare size={18} /> : <Square size={18} />}
<Square size={18} style={{
position: 'absolute', inset: 0,
opacity: item.checked ? 0 : 1,
transform: item.checked ? 'scale(0.7)' : 'scale(1)',
transition: 'opacity 180ms cubic-bezier(0.23,1,0.32,1), transform 180ms cubic-bezier(0.23,1,0.32,1)',
}} />
<CheckSquare size={18} style={{
position: 'absolute', inset: 0,
opacity: item.checked ? 1 : 0,
transform: item.checked ? 'scale(1)' : 'scale(0.5)',
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 220ms cubic-bezier(0.34,1.56,0.64,1)',
}} />
</button>
{editing && canEdit ? (
@@ -274,6 +287,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
flex: 1, fontSize: 13.5,
cursor: !canEdit || item.checked ? 'default' : 'text',
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
textDecoration: item.checked ? 'line-through' : 'none',
}}
>
@@ -729,9 +743,13 @@ function MenuItem({ icon, label, onClick, danger }: MenuItemProps) {
interface PackingListPanelProps {
tripId: number
items: PackingItem[]
openImportSignal?: number
clearCheckedSignal?: number
saveTemplateSignal?: number
inlineHeader?: boolean
}
export default function PackingListPanel({ tripId, items }: PackingListPanelProps) {
export default function PackingListPanel({ tripId, items, openImportSignal = 0, clearCheckedSignal = 0, saveTemplateSignal = 0, inlineHeader = true }: PackingListPanelProps) {
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
const [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('')
@@ -896,6 +914,31 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
const [saveTemplateName, setSaveTemplateName] = useState('')
const [showImportModal, setShowImportModal] = useState(false)
const [importText, setImportText] = useState('')
const lastHandledImportSignal = useRef(openImportSignal)
const lastHandledClearSignal = useRef(clearCheckedSignal)
const lastHandledSaveSignal = useRef(saveTemplateSignal)
useEffect(() => {
if (openImportSignal !== lastHandledImportSignal.current && openImportSignal > 0) {
setShowImportModal(true)
}
lastHandledImportSignal.current = openImportSignal
}, [openImportSignal])
useEffect(() => {
if (clearCheckedSignal !== lastHandledClearSignal.current && clearCheckedSignal > 0) {
handleClearChecked()
}
lastHandledClearSignal.current = clearCheckedSignal
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clearCheckedSignal])
useEffect(() => {
if (saveTemplateSignal !== lastHandledSaveSignal.current && saveTemplateSignal > 0) {
setShowSaveTemplate(true)
}
lastHandledSaveSignal.current = saveTemplateSignal
}, [saveTemplateSignal])
const csvInputRef = useRef<HTMLInputElement>(null)
const templateDropdownRef = useRef<HTMLDivElement>(null)
@@ -999,16 +1042,43 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
{/* ── Header ── */}
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 14 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{items.length === 0 ? t('packing.empty') : t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
</p>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{canEdit && abgehakt > 0 && (
<div style={{ padding: inlineHeader ? '20px 24px 16px' : '0 0 16px', flexShrink: 0, borderBottom: inlineHeader ? '1px solid rgba(0,0,0,0.06)' : undefined }}>
<div style={{ display: 'flex', alignItems: inlineHeader ? 'flex-start' : 'center', justifyContent: 'space-between', gap: 14 }}>
{inlineHeader ? (
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2>
{items.length > 0 && (
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
</p>
)}
</div>
) : <span />}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{canEdit && items.length > 0 && showSaveTemplate && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input
type="text" autoFocus
value={saveTemplateName}
onChange={e => setSaveTemplateName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }}
placeholder={t('packing.templateName')}
style={{ fontSize: 12, padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }}
/>
<button onClick={handleSaveAsTemplate} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#10b981' }}><Check size={14} /></button>
<button onClick={() => { setShowSaveTemplate(false); setSaveTemplateName('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}><X size={14} /></button>
</div>
)}
{inlineHeader && canEdit && (
<button onClick={() => setShowImportModal(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
}}>
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
</button>
)}
{inlineHeader && canEdit && abgehakt > 0 && (
<button onClick={handleClearChecked} style={{
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
@@ -1017,16 +1087,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
</button>
)}
{canEdit && (
<button onClick={() => setShowImportModal(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
}}>
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
</button>
)}
{canEdit && availableTemplates.length > 0 && (
{inlineHeader && canEdit && availableTemplates.length > 0 && (
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
@@ -1065,31 +1126,14 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
)}
</div>
)}
{canEdit && items.length > 0 && (
<div style={{ position: 'relative' }}>
{showSaveTemplate ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input
type="text" autoFocus
value={saveTemplateName}
onChange={e => setSaveTemplateName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }}
placeholder={t('packing.templateName')}
style={{ fontSize: 12, padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }}
/>
<button onClick={handleSaveAsTemplate} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#10b981' }}><Check size={14} /></button>
<button onClick={() => { setShowSaveTemplate(false); setSaveTemplateName('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}><X size={14} /></button>
</div>
) : (
<button onClick={() => setShowSaveTemplate(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
background: 'var(--bg-card)', color: 'var(--text-muted)',
}}>
<FolderPlus size={12} /> <span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span>
</button>
)}
</div>
{inlineHeader && canEdit && items.length > 0 && !showSaveTemplate && (
<button onClick={() => setShowSaveTemplate(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
background: 'var(--bg-card)', color: 'var(--text-muted)',
}}>
<FolderPlus size={12} /> <span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span>
</button>
)}
{bagTrackingEnabled && (
<button onClick={() => setShowBagModal(true)} className="xl:!hidden"
@@ -1107,17 +1151,69 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
</div>
{items.length > 0 && (
<div style={{ marginBottom: 14 }}>
<div style={{ height: 5, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
<div className="hidden sm:block" style={{ marginTop: 14, marginBottom: 14 }}>
<div className="flex items-center" style={{ gap: 14 }}>
{fortschritt === 100 ? (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
fontSize: 16, fontWeight: 700, color: '#10b981',
letterSpacing: '-0.01em', flexShrink: 0,
}}>
<CheckCheck size={18} strokeWidth={2.5} />
<span>{t('packing.allPacked')}</span>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'baseline' }}>
<span style={{
fontSize: 22, fontWeight: 700, color: 'var(--text-primary)',
fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em',
lineHeight: 1,
}}>{abgehakt}</span>
<span style={{
fontSize: 14, fontWeight: 500, color: 'var(--text-faint)',
fontVariantNumeric: 'tabular-nums', lineHeight: 1, marginLeft: 1,
}}>/{items.length}</span>
</div>
<span style={{
fontSize: 11, fontWeight: 600, padding: '2px 7px',
borderRadius: 99, background: 'var(--bg-tertiary)',
color: 'var(--text-muted)',
fontVariantNumeric: 'tabular-nums',
lineHeight: 1.4,
}}>{fortschritt}%</span>
</div>
)}
<div style={{
height: '100%', borderRadius: 99, transition: 'width 0.4s ease',
background: fortschritt === 100 ? '#10b981' : 'linear-gradient(90deg, var(--text-primary) 0%, var(--text-muted) 100%)',
width: `${fortschritt}%`,
}} />
flex: 1,
height: 8,
background: 'var(--bg-tertiary)',
borderRadius: 99,
overflow: 'hidden',
position: 'relative',
width: '100%',
}}>
<div style={{
height: '100%',
borderRadius: 99,
transition: 'width 600ms cubic-bezier(0.23, 1, 0.32, 1), background 400ms ease, box-shadow 400ms ease',
background: fortschritt === 100
? 'linear-gradient(90deg, #10b981 0%, #34d399 100%)'
: 'var(--accent)',
width: `${fortschritt}%`,
boxShadow: fortschritt === 100 ? '0 0 14px rgba(16,185,129,0.45)' : 'none',
position: 'relative',
}}>
<div style={{
position: 'absolute', inset: 0,
background: 'linear-gradient(180deg, rgba(255,255,255,0.22) 0%, rgba(255,255,255,0) 55%)',
borderRadius: 99,
pointerEvents: 'none',
}} />
</div>
</div>
</div>
{fortschritt === 100 && (
<p style={{ fontSize: 11.5, color: '#10b981', marginTop: 4, fontWeight: 600, margin: '4px 0 0' }}>{t('packing.allPacked')}</p>
)}
</div>
)}
@@ -1151,7 +1247,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
{/* ── Filter-Tabs ── */}
{items.length > 0 && (
<div style={{ display: 'flex', gap: 4, padding: '10px 16px 0', flexShrink: 0 }}>
<div style={{ display: 'flex', gap: 4, padding: '10px 0 0', flexShrink: 0 }}>
{[['alle', t('packing.filterAll')], ['offen', t('packing.filterOpen')], ['erledigt', t('packing.filterDone')]].map(([id, label]) => (
<button key={id} onClick={() => setFilter(id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer',
@@ -1165,7 +1261,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
{/* ── Liste + Bags Sidebar ── */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 12px 16px' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
{items.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<Luggage size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 10px' }} />
@@ -1268,7 +1364,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
{/* ── Bag Modal (mobile + click) ── */}
{showBagModal && bagTrackingEnabled && (
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, overflowY: 'auto' }}
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflowY: 'auto' }}
onClick={() => setShowBagModal(false)}>
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: 'calc(100vh - 80px)', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)', flexShrink: 0 }}
onClick={e => e.stopPropagation()}>
@@ -79,6 +79,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
return (
<div
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
>
{/* Main area */}
@@ -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 || []
@@ -187,7 +187,7 @@ describe('DayPlanSidebar', () => {
const assignments = { '10': [assignment] }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />)
// The chevron button immediately follows the "Add Note" button (which has a title attribute)
const addNoteBtn = screen.getByTitle('Add Note')
const addNoteBtn = screen.getByLabelText('Add Note')
const chevron = addNoteBtn.nextElementSibling as HTMLButtonElement
expect(chevron).toBeTruthy()
await user.click(chevron)
@@ -201,7 +201,7 @@ describe('DayPlanSidebar', () => {
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
const assignments = { '10': [assignment] }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />)
const getChevron = () => screen.getByTitle('Add Note').nextElementSibling as HTMLButtonElement
const getChevron = () => screen.getByLabelText('Add Note').nextElementSibling as HTMLButtonElement
await user.click(getChevron()) // collapse
expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument()
await user.click(getChevron()) // re-expand
@@ -362,28 +362,14 @@ describe('DayPlanSidebar', () => {
const user = userEvent.setup()
const onUndo = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({ canUndo: true, lastActionLabel: 'Removed place', onUndo })} />)
// Find the undo button — it has width 30, height 30 and is not disabled
const buttons = screen.getAllByRole('button')
// The undo button is the one with the Undo2 icon and is not disabled
const undoBtn = buttons.find(btn => {
const style = btn.getAttribute('style') || ''
return style.includes('width: 30px') || style.includes('width:30px') || (style.includes('30') && !btn.disabled)
})
if (undoBtn) {
await user.click(undoBtn)
expect(onUndo).toHaveBeenCalled()
}
const undoBtn = screen.getByLabelText('Undo')
await user.click(undoBtn)
expect(onUndo).toHaveBeenCalled()
})
it('FE-PLANNER-DAYPLAN-024: undo button not present when onUndo not provided', () => {
render(<DayPlanSidebar {...makeDefaultProps({ canUndo: false })} />)
// When onUndo is not provided, the undo section is not rendered at all
const buttons = screen.getAllByRole('button')
const undoBtn = buttons.find(btn => {
const style = btn.getAttribute('style') || ''
return style.includes('width: 30px')
})
expect(undoBtn).toBeUndefined()
expect(screen.queryByLabelText('Undo')).toBeNull()
})
// ── PDF export ──────────────────────────────────────────────────────────
@@ -440,26 +426,27 @@ describe('DayPlanSidebar', () => {
type: 'flight',
title: 'Paris to London',
reservation_time: '2025-06-01T08:00:00',
day_id: 10,
})
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation] })} />)
expect(screen.getByText('Paris to London')).toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-031: clicking transport item shows detail modal', async () => {
it('FE-PLANNER-DAYPLAN-031: clicking transport item calls onEditTransport', async () => {
const user = userEvent.setup()
const onEditTransport = vi.fn()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Travel Day' })
const reservation = buildReservation({
id: 200,
type: 'flight',
title: 'Air France 123',
reservation_time: '2025-06-01T08:00:00',
day_id: 10,
})
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation] })} />)
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation], onEditTransport })} />)
await user.click(screen.getByText('Air France 123'))
// Detail modal should appear (shows the title again in the modal)
await waitFor(() => {
const titles = screen.getAllByText('Air France 123')
expect(titles.length).toBeGreaterThan(1)
expect(onEditTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 200 }))
})
})
@@ -664,6 +651,7 @@ describe('DayPlanSidebar', () => {
const reservation = buildReservation({
id: 200, type: 'flight', title: 'CDG to LHR',
reservation_time: '2025-06-01T08:00:00',
day_id: 10,
})
render(<DayPlanSidebar {...makeDefaultProps({
days: [day],
@@ -684,6 +672,8 @@ describe('DayPlanSidebar', () => {
id: 201, type: 'flight', title: 'Transatlantic',
reservation_time: '2025-06-01T22:00:00',
reservation_end_time: '2025-06-02T06:00:00',
day_id: 10,
end_day_id: 11,
} as any)
render(<DayPlanSidebar {...makeDefaultProps({
days: [day1, day2],
@@ -704,6 +694,8 @@ describe('DayPlanSidebar', () => {
id: 300, type: 'car', title: 'Renault Rental',
reservation_time: '2025-06-01T09:00:00',
reservation_end_time: '2025-06-03T17:00:00',
day_id: 10,
end_day_id: 12,
} as any)
render(<DayPlanSidebar {...makeDefaultProps({
days: [day1, day2, day3],
@@ -786,20 +778,22 @@ describe('DayPlanSidebar', () => {
// ── Transport detail modal with metadata ───────────────────────────────
it('FE-PLANNER-DAYPLAN-051: transport detail modal shows flight metadata', async () => {
it('FE-PLANNER-DAYPLAN-051: clicking flight transport calls onEditTransport with reservation', async () => {
const user = userEvent.setup()
const onEditTransport = vi.fn()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Travel' })
const reservation = {
...buildReservation({
id: 202, type: 'flight', title: 'Paris to Berlin',
reservation_time: '2025-06-01T07:30:00',
day_id: 10,
}),
metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH1234', departure_airport: 'CDG', arrival_airport: 'BER' }),
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation as any] })} />)
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation as any], onEditTransport })} />)
await user.click(screen.getByText('Paris to Berlin'))
await waitFor(() => {
expect(screen.getByText('Lufthansa')).toBeInTheDocument()
expect(onEditTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 202, type: 'flight' }))
})
})
@@ -923,7 +917,7 @@ describe('DayPlanSidebar', () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
const addNoteBtn = screen.getByTitle('Add Note')
const addNoteBtn = screen.getByLabelText('Add Note')
await user.click(addNoteBtn)
expect(mockDayNotesState.openAddNote).toHaveBeenCalled()
})
@@ -1124,6 +1118,7 @@ describe('DayPlanSidebar', () => {
const flight = buildReservation({
id: 201, type: 'flight', title: 'Afternoon Flight',
reservation_time: '2025-06-01T14:00:00',
day_id: 10,
})
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [flight],
@@ -1683,4 +1678,42 @@ describe('DayPlanSidebar', () => {
// Optimize button should not be visible when no day is selected
expect(screen.queryByRole('button', { name: /optimize/i })).not.toBeInTheDocument()
})
// ── Edit reservation pencil button ───────────────────────────────────────
it('FE-PLANNER-DAYPLAN-097: pencil button on non-transport reservation calls onEditReservation', async () => {
const user = userEvent.setup()
const place = buildPlace({ id: 1, name: 'Hotel du Lac' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
const res = buildReservation({ id: 77, trip_id: 1, type: 'hotel', status: 'pending', assignment_id: 99 } as any)
const onEditReservation = vi.fn()
const onEditTransport = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
onEditReservation, onEditTransport,
})} />)
const pencil = screen.getByTitle(/edit/i)
await user.click(pencil)
expect(onEditReservation).toHaveBeenCalledWith(res)
expect(onEditTransport).not.toHaveBeenCalled()
})
it('FE-PLANNER-DAYPLAN-098: pencil button on transport reservation calls onEditTransport', async () => {
const user = userEvent.setup()
const place = buildPlace({ id: 1, name: 'Geneva Airport' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
const res = buildReservation({ id: 88, trip_id: 1, type: 'flight', status: 'pending', assignment_id: 99 } as any)
const onEditReservation = vi.fn()
const onEditTransport = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
onEditReservation, onEditTransport,
})} />)
const pencil = screen.getByTitle(/edit/i)
await user.click(pencil)
expect(onEditTransport).toHaveBeenCalledWith(res)
expect(onEditReservation).not.toHaveBeenCalled()
})
})
+396 -129
View File
@@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string }
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' }
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, ChevronsDownUp, ChevronsUpDown, 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'
@@ -23,6 +23,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
import { useDayNotes } from '../../hooks/useDayNotes'
import Tooltip from '../shared/Tooltip'
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
const NOTE_ICONS = [
@@ -170,6 +171,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
@@ -179,6 +184,11 @@ interface DayPlanSidebarProps {
canUndo?: boolean
lastActionLabel?: string | null
onUndo?: () => void
onRouteRefresh?: () => void
onAddTransport?: (dayId: number) => void
onEditTransport?: (reservation: Reservation) => void
onEditReservation?: (reservation: Reservation) => void
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
}
const DayPlanSidebar = React.memo(function DayPlanSidebar({
@@ -189,6 +199,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onReorder, onUpdateDayTitle, onRouteCalculated,
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
reservations = [],
visibleConnectionIds = [],
onToggleConnection,
externalTransportDetail,
onExternalTransportDetailHandled,
onAddReservation,
onAddPlace,
onAddPlaceToDay,
@@ -198,6 +212,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
canUndo = false,
lastActionLabel = null,
onUndo,
onRouteRefresh,
onAddTransport,
onEditTransport,
onEditReservation,
onAddBookingToAssignment,
}: DayPlanSidebarProps) {
const toast = useToast()
const { t, language, locale } = useTranslation()
@@ -227,13 +246,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const [undoHover, setUndoHover] = useState(false)
const [pdfHover, setPdfHover] = useState(false)
const [icsHover, setIcsHover] = useState(false)
const [hoveredAssignmentId, setHoveredAssignmentId] = useState<number | null>(null)
const [dropTargetKey, _setDropTargetKey] = useState(null)
const dropTargetRef = useRef(null)
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
const [dragOverDayId, setDragOverDayId] = useState(null)
const [hoveredId, setHoveredId] = useState(null)
const [transportDetail, setTransportDetail] = useState(null)
const [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
@@ -250,19 +276,21 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
// Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren)
const getDragData = (e) => {
const dt = e?.dataTransfer
// Interner Drag hat Vorrang (Ref wird nur bei assignmentId/noteId gesetzt)
// Interner Drag hat Vorrang (Ref wird nur bei assignmentId/noteId/reservationId gesetzt)
if (dragDataRef.current) {
return {
placeId: '',
assignmentId: dragDataRef.current.assignmentId || '',
noteId: dragDataRef.current.noteId || '',
reservationId: dragDataRef.current.reservationId || '',
fromDayId: parseInt(dragDataRef.current.fromDayId) || 0,
phase: (dragDataRef.current.phase || 'single') as 'single' | 'start' | 'middle' | 'end',
}
}
// Externer Drag (aus PlacesSidebar)
const ext = window.__dragData || {}
const placeId = dt?.getData('placeId') || ext.placeId || ''
return { placeId, assignmentId: '', noteId: '', fromDayId: 0 }
return { placeId, assignmentId: '', noteId: '', reservationId: '', fromDayId: 0, phase: 'single' as const }
}
// Only auto-expand genuinely new days (not on initial load from storage)
@@ -309,26 +337,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
// Determine if a reservation's end_time represents a different date (multi-day)
const getEndDate = (r: Reservation) => {
const endStr = r.reservation_end_time || ''
return endStr.includes('T') ? endStr.split('T')[0] : null
}
// Get span phase: how a reservation relates to a specific day's date
const getSpanPhase = (r: Reservation, dayDate: string): 'single' | 'start' | 'middle' | 'end' => {
if (!r.reservation_time) return 'single'
const startDate = r.reservation_time.split('T')[0]
const endDate = getEndDate(r) || startDate
if (startDate === endDate) return 'single'
if (dayDate === startDate) return 'start'
if (dayDate === endDate) return 'end'
// Get span phase: how a reservation relates to a specific day (by id)
const getSpanPhase = (r: Reservation, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
const startDayId = r.day_id
const endDayId = r.end_day_id ?? startDayId
if (!startDayId || startDayId === endDayId) return 'single'
if (dayId === startDayId) return 'start'
if (dayId === endDayId) return 'end'
return 'middle'
}
// Get the appropriate display time for a reservation on a specific day
const getDisplayTimeForDay = (r: Reservation, dayDate: string): string | null => {
const phase = getSpanPhase(r, dayDate)
const getDisplayTimeForDay = (r: Reservation, dayId: number): string | null => {
const phase = getSpanPhase(r, dayId)
if (phase === 'end') return r.reservation_end_time || null
if (phase === 'middle') return null
return r.reservation_time || null
@@ -342,36 +363,56 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return t(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
}
const getDayOrder = (day: (typeof days)[number]) => (day as any).day_number ?? days.indexOf(day)
const computeMultiDayMove = (r: Reservation, targetDayId: number, phase: 'single' | 'start' | 'middle' | 'end') => {
const startId = r.day_id ?? targetDayId
const endId = r.end_day_id ?? startId
const order = (id: number) => { const d = days.find(x => x.id === id); return d ? getDayOrder(d) : 0 }
if (phase === 'single' || startId === endId) return { day_id: targetDayId, end_day_id: targetDayId }
if (phase === 'start') {
if (order(targetDayId) > order(endId)) return { day_id: targetDayId, end_day_id: targetDayId }
return { day_id: targetDayId, end_day_id: endId }
}
// phase === 'end'
if (order(targetDayId) < order(startId)) return { day_id: targetDayId, end_day_id: targetDayId }
return { day_id: startId, end_day_id: targetDayId }
}
const getTransportForDay = (dayId: number) => {
const day = days.find(d => d.id === dayId)
if (!day?.date) return []
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
return reservations.filter(r => {
if (!r.reservation_time || r.type === 'hotel') return false
if (r.type === 'hotel') return false
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
const startDate = r.reservation_time.split('T')[0]
const endDate = getEndDate(r)
if (endDate && endDate !== startDate) {
// Multi-day: show on any day in range (car middle handled elsewhere)
return day.date >= startDate && day.date <= endDate
} else {
// Single-day: show all non-hotel reservations that match this day's date
return startDate === day.date
const startDayId = r.day_id
const endDayId = r.end_day_id ?? startDayId
if (startDayId == null) return false
if (endDayId !== startDayId) {
const startDay = days.find(d => d.id === startDayId)
const endDay = days.find(d => d.id === endDayId)
const thisDay = days.find(d => d.id === dayId)
if (!startDay || !endDay || !thisDay) return false
return getDayOrder(thisDay) >= getDayOrder(startDay) && getDayOrder(thisDay) <= getDayOrder(endDay)
}
return startDayId === dayId
})
}
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
const getActiveRentalsForDay = (dayId: number) => {
const day = days.find(d => d.id === dayId)
if (!day?.date) return []
return reservations.filter(r => {
if (r.type !== 'car' || !r.reservation_time) return false
const startDate = r.reservation_time.split('T')[0]
const endDate = getEndDate(r)
if (!endDate || endDate === startDate) return false
return day.date > startDate && day.date < endDate
if (r.type !== 'car') return false
const startDayId = r.day_id
const endDayId = r.end_day_id
if (!startDayId || !endDayId || endDayId === startDayId) return false
const startDay = days.find(d => d.id === startDayId)
const endDay = days.find(d => d.id === endDayId)
const thisDay = days.find(d => d.id === dayId)
if (!startDay || !endDay || !thisDay) return false
return getDayOrder(thisDay) > getDayOrder(startDay) && getDayOrder(thisDay) < getDayOrder(endDay)
})
}
@@ -420,11 +461,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
day_plan_position: computeTransportPosition(r, da) + idx * 0.01,
}))
// Mark as initialized immediately to prevent re-entry
for (const p of positions) {
initedTransportIds.current.add(p.id)
const res = reservations.find(x => x.id === p.id)
if (res) res.day_plan_position = p.day_plan_position
}
for (const p of positions) initedTransportIds.current.add(p.id)
// Update store so subscribers see the new positions
useTripStore.setState(state => ({
reservations: state.reservations.map(r => {
const p = positions.find(x => x.id === r.id)
if (!p) return r
return { ...r, day_plan_position: p.day_plan_position }
})
}))
// Persist to server (fire and forget)
reservationsApi.updatePositions(tripId, positions).catch(() => {})
}
@@ -433,7 +478,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const da = getDayAssignments(dayId)
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
const transport = getTransportForDay(dayId)
const dayDate = days.find(d => d.id === dayId)?.date || ''
// Initialize positions for transports that don't have one yet
if (transport.some(r => r.day_plan_position == null)) {
@@ -450,7 +494,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const timedTransports = transport.map(r => ({
type: 'transport' as const,
data: r,
minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayDate)) ?? 0,
minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayId)) ?? 0,
})).sort((a, b) => a.minutes - b.minutes)
if (timedTransports.length === 0) return baseItems
@@ -592,23 +636,27 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
}
try {
// Update transport positions in store FIRST so the useEffect triggered by
// onReorder's optimistic assignment update reads the correct positions.
if (transportUpdates.length) {
useTripStore.setState(state => ({
reservations: state.reservations.map(r => {
const tu = transportUpdates.find(u => u.id === r.id)
if (!tu) return r
const day_positions = { ...(r.day_positions || {}), [dayId]: tu.day_plan_position }
return { ...r, day_plan_position: tu.day_plan_position, day_positions }
})
}))
setTransportPosVersion(v => v + 1)
}
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
if (transportUpdates.length) {
onRouteRefresh?.()
await reservationsApi.updatePositions(tripId, transportUpdates, dayId)
}
for (const n of noteUpdates) {
await tripActions.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
}
if (transportUpdates.length) {
for (const tu of transportUpdates) {
const res = reservations.find(r => r.id === tu.id)
if (res) {
res.day_plan_position = tu.day_plan_position
// Update per-day position for multi-day reservations
if (!res.day_positions) res.day_positions = {}
res.day_positions[dayId] = tu.day_plan_position
}
}
setTransportPosVersion(v => v + 1)
await reservationsApi.updatePositions(tripId, transportUpdates, dayId)
}
if (prevAssignmentIds.length) {
const capturedDayId = dayId
const capturedPrevIds = prevAssignmentIds
@@ -620,13 +668,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
}
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
// Transport bookings themselves cannot be dragged
if (fromType === 'transport') {
toast.error(t('dayplan.cannotReorderTransport'))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
return
}
const m = getMergedItems(dayId)
// Check if a timed place is being moved → would it break chronological order?
@@ -839,7 +880,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
e.preventDefault()
e.stopPropagation()
setDragOverDayId(null)
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
if (fromReservationId && fromDayId !== dayId) {
const r = reservations.find(x => x.id === Number(fromReservationId))
if (r) { const update = computeMultiDayMove(r, dayId, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return
}
if (placeId) {
onAssignToDay?.(parseInt(placeId), dayId)
} else if (assignmentId && fromDayId !== dayId) {
@@ -894,18 +940,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Reise-Titel */}
<div style={{ padding: '16px 16px 12px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', lineHeight: '1.3' }}>{trip?.title}</div>
{(trip?.start_date || trip?.end_date) && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })).join(' ')}
{days.length > 0 && ` · ${days.length} ${t('dayplan.days')}`}
</div>
)}
</div>
{/* Toolbar */}
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
<div style={{ position: 'relative', flexShrink: 0 }}>
<button
onClick={async () => {
@@ -987,11 +1024,57 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div>
)}
</div>
{(() => {
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
return (
<Tooltip label={label} placement="bottom">
<button
onClick={() => {
const next = allExpanded ? new Set() : new Set(days.map(d => d.id))
setExpandedDays(next)
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...next])) } catch {}
}}
aria-label={label}
aria-pressed={allExpanded}
style={{
position: 'relative', flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 30, height: 30, borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'none',
color: 'var(--text-primary)', cursor: 'pointer', fontFamily: 'inherit', padding: 0,
transition: 'color 0.15s, border-color 0.15s, background 0.15s',
overflow: 'hidden',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<span style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'opacity 0.2s ease, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)',
opacity: allExpanded ? 0 : 1,
transform: allExpanded ? 'translateY(-8px) scale(0.6)' : 'translateY(0) scale(1)',
}}>
<ChevronsUpDown size={14} strokeWidth={2} />
</span>
<span style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'opacity 0.2s ease, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)',
opacity: allExpanded ? 1 : 0,
transform: allExpanded ? 'translateY(0) scale(1)' : 'translateY(8px) scale(0.6)',
}}>
<ChevronsDownUp size={14} strokeWidth={2} />
</span>
</button>
</Tooltip>
)
})()}
{onUndo && (
<div style={{ position: 'relative', flexShrink: 0 }}>
<button
onClick={onUndo}
disabled={!canUndo}
aria-label={t('undo.button')}
onMouseEnter={() => setUndoHover(true)}
onMouseLeave={() => setUndoHover(false)}
style={{
@@ -1023,7 +1106,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 trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{days.map((day, index) => {
const isSelected = selectedDayId === day.id
const isExpanded = expandedDays.has(day.id)
@@ -1097,6 +1180,29 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
>
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
</button>}
{canEditDays && onAddTransport && (
<Tooltip label={t('transport.addTransport')} placement="top">
<button
onClick={e => { e.stopPropagation(); onAddTransport(day.id) }}
aria-label={t('transport.addTransport')}
style={{
flexShrink: 0,
background: 'none',
border: 'none',
padding: '4px',
cursor: 'pointer',
opacity: 0.45,
display: 'flex',
alignItems: 'center',
borderRadius: 4,
}}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.opacity = '1' }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = '0.45' }}
>
<Plus size={15} strokeWidth={1.8} color="var(--text-secondary)" />
</button>
</Tooltip>
)}
{(() => {
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
// Sort: check-out first, then ongoing stays, then check-in last
@@ -1151,15 +1257,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div>
</div>
{canEditDays && <button
{canEditDays && <Tooltip label={t('dayplan.addNote')} placement="top"><button
onClick={e => openAddNote(day.id, e)}
title={t('dayplan.addNote')}
aria-label={t('dayplan.addNote')}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
>
<FileText size={16} strokeWidth={2} />
</button>}
</button></Tooltip>}
<button
onClick={e => toggleDay(day.id, e)}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
@@ -1176,7 +1282,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onDrop={e => {
e.preventDefault()
e.stopPropagation()
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
// Drop on transport card (detected via dropTargetRef for sync accuracy)
if (dropTargetRef.current?.startsWith('transport-')) {
const isAfter = dropTargetRef.current.startsWith('transport-after-')
@@ -1185,6 +1291,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id)
} else if (fromReservationId && fromDayId !== day.id) {
const r = reservations.find(x => x.id === Number(fromReservationId))
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
} else if (fromReservationId) {
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter)
} else if (assignmentId && fromDayId !== day.id) {
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (assignmentId) {
@@ -1198,6 +1309,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return
}
if (fromReservationId && fromDayId !== day.id) {
const r = reservations.find(x => x.id === Number(fromReservationId))
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
if (!assignmentId && !noteId && !placeId) { dragDataRef.current = null; window.__dragData = null; return }
if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id)
@@ -1244,7 +1360,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const cat = categories.find(c => c.id === place.category_id)
const isPlaceSelected = selectedAssignmentId ? assignment.id === selectedAssignmentId : place.id === selectedPlaceId
const isDraggingThis = draggingId === assignment.id
const isHovered = hoveredId === assignment.id
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
const arrowMove = (direction: 'up' | 'down') => {
@@ -1297,11 +1412,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); if (dropTargetKey !== `place-${assignment.id}`) setDropTargetKey(`place-${assignment.id}`) }}
onDrop={e => {
e.preventDefault(); e.stopPropagation()
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
if (placeId) {
const pos = placeItems.findIndex(i => i.data.id === assignment.id)
onAssignToDay?.(parseInt(placeId), day.id, pos >= 0 ? pos : undefined)
setDropTargetKey(null); window.__dragData = null
} else if (fromReservationId && fromDayId !== day.id) {
const r = reservations.find(x => x.id === Number(fromReservationId))
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (fromReservationId) {
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'place', assignment.id)
} else if (fromAssignmentId && fromDayId !== day.id) {
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
@@ -1328,15 +1449,27 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{ divider: true },
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])}
onMouseEnter={() => setHoveredId(assignment.id)}
onMouseLeave={() => setHoveredId(null)}
onMouseEnter={e => {
if (!isPlaceSelected && !lockedIds.has(assignment.id))
e.currentTarget.style.background = 'var(--bg-hover)'
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
if (grip) grip.style.opacity = '1'
setHoveredAssignmentId(assignment.id)
}}
onMouseLeave={e => {
if (!isPlaceSelected && !lockedIds.has(assignment.id))
e.currentTarget.style.background = 'transparent'
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
if (grip) grip.style.opacity = '0.3'
setHoveredAssignmentId(null)
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px 7px 10px',
cursor: 'pointer',
background: lockedIds.has(assignment.id)
? 'rgba(220,38,38,0.08)'
: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
: isPlaceSelected ? 'var(--bg-hover)' : 'transparent',
borderLeft: lockedIds.has(assignment.id)
? '3px solid #dc2626'
: '3px solid transparent',
@@ -1344,7 +1477,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
opacity: isDraggingThis ? 0.4 : 1,
}}
>
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>}
<div
@@ -1405,26 +1538,74 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const res = reservations.find(r => r.assignment_id === assignment.id)
if (!res) return null
const confirmed = res.status === 'confirmed'
const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2
const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false
return (
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 9, fontWeight: 600,
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)',
color: confirmed ? '#16a34a' : '#d97706',
}}>
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
{res.reservation_time?.includes('T') && (
<span style={{ fontWeight: 400 }}>
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
{res.reservation_end_time && ` ${res.reservation_end_time}`}
</span>
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 9, fontWeight: 600,
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)',
color: confirmed ? '#16a34a' : '#d97706',
}}>
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
{res.reservation_time?.includes('T') && (
<span style={{ fontWeight: 400 }}>
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
{res.reservation_end_time && ` ${res.reservation_end_time}`}
</span>
)}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta) return null
if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
if (meta.train_number) return <span style={{ fontWeight: 400 }}>{meta.train_number}</span>
return null
})()}
</div>
{hasEndpoints && (
<button
type="button"
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
background: active ? '#3b82f6' : 'transparent',
color: active ? '#fff' : 'var(--text-faint)',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
>
<RouteIcon size={11} />
</button>
)}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta) return null
if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
if (meta.train_number) return <span style={{ fontWeight: 400 }}>{meta.train_number}</span>
return null
{canEditDays && (() => {
const isTransport = ['flight','train','car','cruise','bus'].includes(res.type)
const handler = isTransport ? onEditTransport : onEditReservation
if (!handler) return null
return (
<button
type="button"
onClick={e => { e.stopPropagation(); handler(res) }}
title={t('common.edit')}
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none', background: 'transparent',
color: 'var(--text-faint)',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Pencil size={11} />
</button>
)
})()}
</div>
)
@@ -1447,7 +1628,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div>
)}
</div>
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
<button onClick={moveUp} disabled={idx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', color: idx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
<ChevronUp size={12} strokeWidth={2} />
</button>
@@ -1455,6 +1636,32 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
<ChevronDown size={12} strokeWidth={2} />
</button>
</div>}
{canEditDays && onAddBookingToAssignment && hoveredAssignmentId === assignment.id && (
<button
onClick={e => {
e.stopPropagation()
onAddBookingToAssignment(day.id, assignment.id)
}}
title={t('reservations.addBooking')}
style={{
flexShrink: 0,
background: 'none',
border: '1px solid var(--border-primary)',
borderRadius: 5,
padding: '2px 6px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 3,
fontSize: 10,
fontWeight: 500,
color: 'var(--text-muted)',
fontFamily: 'inherit',
}}
>
<Plus size={11} strokeWidth={2} />
</button>
)}
</div>
</React.Fragment>
)
@@ -1463,7 +1670,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
// Transport booking (flight, train, bus, car, cruise)
if (item.type === 'transport') {
const res = item.data
const spanPhase = getSpanPhase(res, day.date)
const spanPhase = getSpanPhase(res, day.id)
// Car "active" (middle) days are shown in the day header, skip here
if (res.type === 'car' && spanPhase === 'middle') return null
@@ -1471,7 +1678,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const TransportIcon = RES_ICONS[res.type] || Ticket
const color = '#3b82f6'
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
const isTransportHovered = hoveredId === `transport-${res.id}`
// Subtitle aus Metadaten zusammensetzen
let subtitle = ''
@@ -1486,13 +1692,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
// Multi-day span phase
const spanLabel = getSpanLabel(res, spanPhase)
const displayTime = getDisplayTimeForDay(res, day.date)
const displayTime = getDisplayTimeForDay(res, day.id)
return (
<React.Fragment key={`transport-${res.id}-${day.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div
onClick={() => setTransportDetail(res)}
onClick={() => canEditDays && onEditTransport?.(res)}
onDragOver={e => {
e.preventDefault(); e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
@@ -1500,13 +1706,26 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}`
if (dropTargetRef.current !== key) setDropTargetKey(key)
}}
draggable={canEditDays && spanPhase !== 'middle'}
onDragStart={e => {
if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return }
e.dataTransfer.effectAllowed = 'move'
dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase }
setDraggingId(res.id)
}}
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
onDrop={e => {
e.preventDefault(); e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
const insertAfter = e.clientY > rect.top + rect.height / 2
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id)
} else if (fromReservationId && fromDayId !== day.id) {
const r2 = reservations.find(x => x.id === Number(fromReservationId))
if (r2) { const update = computeMultiDayMove(r2, day.id, phase); tripActions.updateReservation(tripId, r2.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
} else if (fromReservationId) {
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter)
} else if (fromAssignmentId && fromDayId !== day.id) {
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (fromAssignmentId) {
@@ -1518,20 +1737,25 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
}
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
}}
onMouseEnter={() => setHoveredId(`transport-${res.id}`)}
onMouseLeave={() => setHoveredId(null)}
onMouseEnter={e => { e.currentTarget.style.background = `${color}12` }}
onMouseLeave={e => { e.currentTarget.style.background = `${color}08` }}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px 7px 10px',
margin: '1px 8px',
borderRadius: 6,
border: `1px solid ${color}33`,
background: isTransportHovered ? `${color}12` : `${color}08`,
cursor: 'pointer', userSelect: 'none',
background: `${color}08`,
cursor: canEditDays && onEditTransport ? 'pointer' : 'default', userSelect: 'none',
transition: 'background 0.1s',
opacity: spanPhase === 'middle' ? 0.65 : 1,
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
}}
>
{canEditDays && spanPhase !== 'middle' && (
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>
)}
<div style={{
width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: '50%', background: `${color}18`,
@@ -1570,6 +1794,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: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
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>
@@ -1578,7 +1825,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
// Notizkarte
const note = item.data
const isNoteHovered = hoveredId === `note-${note.id}`
const NoteIcon = getNoteIcon(note.icon)
const noteIdx = idx
return (
@@ -1591,8 +1837,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
onDrop={e => {
e.preventDefault(); e.stopPropagation()
const { noteId: fromNoteId, assignmentId: fromAssignmentId, fromDayId } = getDragData(e)
if (fromNoteId && fromDayId !== day.id) {
const { noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
if (fromReservationId && fromDayId !== day.id) {
const r = reservations.find(x => x.id === Number(fromReservationId))
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (fromReservationId) {
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'note', note.id)
} else if (fromNoteId && fromDayId !== day.id) {
const tm = getMergedItems(day.id)
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
@@ -1615,20 +1867,30 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{ divider: true },
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
]) : undefined}
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
onMouseLeave={() => setHoveredId(null)}
onMouseEnter={e => {
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
if (grip) grip.style.opacity = '1'
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
if (editBtns) editBtns.style.opacity = '1'
}}
onMouseLeave={e => {
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
if (grip) grip.style.opacity = '0.3'
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
if (editBtns) editBtns.style.opacity = '0'
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px 7px 2px',
margin: '1px 8px',
borderRadius: 6,
border: '1px solid var(--border-faint)',
background: isNoteHovered ? 'var(--bg-hover)' : 'var(--bg-hover)',
background: 'var(--bg-hover)',
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
}}
>
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>}
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
@@ -1642,11 +1904,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
<div className="collab-note-md" style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{note.time}</Markdown></div>
)}
</div>
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: 0, transition: 'opacity 0.15s' }}>
<button onClick={e => openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Pencil size={10} /></button>
<button onClick={e => deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Trash2 size={10} /></button>
</div>}
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
</div>}
@@ -1661,12 +1923,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `end-${day.id}`) setDropTargetKey(`end-${day.id}`) }}
onDrop={e => {
e.preventDefault(); e.stopPropagation()
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
// Neuer Ort von der Orte-Liste
if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id)
setDropTargetKey(null); window.__dragData = null; return
}
if (fromReservationId && fromDayId !== day.id) {
const r = reservations.find(x => x.id === Number(fromReservationId))
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return
}
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
if (assignmentId && fromDayId !== day.id) {
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
@@ -36,6 +36,8 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [summary, setSummary] = useState<PlacesImportSummary | null>(null)
const [gpxOpts, setGpxOpts] = useState({ waypoints: true, routes: true, tracks: true })
const [kmlOpts, setKmlOpts] = useState({ points: true, paths: true })
const validateFile = (f: File): string | null => {
const ext = f.name.toLowerCase().split('.').pop()
@@ -127,7 +129,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
try {
if (ext === 'gpx') {
const result = await placesApi.importGpx(tripId, file)
const result = await placesApi.importGpx(tripId, file, gpxOpts)
await loadTrip(tripId)
if (result.count === 0 && result.skipped > 0) {
toast.warning(t('places.importAllSkipped'))
@@ -137,15 +139,13 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
if (result.places?.length > 0) {
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
pushUndo?.(t('undo.importGpx'), async () => {
for (const id of importedIds) {
try { await placesApi.delete(tripId, id) } catch {}
}
try { await placesApi.bulkDelete(tripId, importedIds) } catch {}
await loadTrip(tripId)
})
}
handleClose()
} else {
const result = await placesApi.importMapFile(tripId, file)
const result = await placesApi.importMapFile(tripId, file, kmlOpts)
await loadTrip(tripId)
setSummary(result.summary || null)
if (result.count === 0 && (result.summary?.skippedCount ?? 0) > 0) {
@@ -159,9 +159,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
if (result.places?.length > 0) {
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
pushUndo?.(t('undo.importKeyholeMarkup'), async () => {
for (const id of importedIds) {
try { await placesApi.delete(tripId, id) } catch {}
}
try { await placesApi.bulkDelete(tripId, importedIds) } catch {}
await loadTrip(tripId)
})
}
@@ -177,7 +175,12 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
}
}
const canImport = !!file && !loading
const fileExt = file?.name.toLowerCase().split('.').pop() ?? ''
const isGpx = fileExt === 'gpx'
const isKml = fileExt === 'kml' || fileExt === 'kmz'
const gpxNoneSelected = isGpx && !gpxOpts.waypoints && !gpxOpts.routes && !gpxOpts.tracks
const kmlNoneSelected = isKml && !kmlOpts.points && !kmlOpts.paths
const canImport = !!file && !loading && !gpxNoneSelected && !kmlNoneSelected
if (!isOpen) return null
@@ -242,6 +245,58 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
)}
</div>
{isGpx && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t('places.gpxImportTypes')}
</div>
{(['waypoints', 'routes', 'tracks'] as const).map(key => (
<label key={key} onClick={() => setGpxOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer' }}>
<div style={{
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
border: gpxOpts[key] ? 'none' : '1.5px solid var(--border-primary)',
background: gpxOpts[key] ? 'var(--accent)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{gpxOpts[key] && <svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2" stroke="white" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
</div>
<span style={{ fontSize: 12, color: 'var(--text-primary)', userSelect: 'none' }}>
{t(key === 'waypoints' ? 'places.gpxImportWaypoints' : key === 'routes' ? 'places.gpxImportRoutes' : 'places.gpxImportTracks')}
</span>
</label>
))}
{gpxNoneSelected && (
<div style={{ fontSize: 11, color: '#b45309', marginTop: 4 }}>{t('places.gpxImportNoneSelected')}</div>
)}
</div>
)}
{isKml && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t('places.kmlImportTypes')}
</div>
{(['points', 'paths'] as const).map(key => (
<label key={key} onClick={() => setKmlOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer' }}>
<div style={{
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
border: kmlOpts[key] ? 'none' : '1.5px solid var(--border-primary)',
background: kmlOpts[key] ? 'var(--accent)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{kmlOpts[key] && <svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2" stroke="white" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
</div>
<span style={{ fontSize: 12, color: 'var(--text-primary)', userSelect: 'none' }}>
{t(key === 'points' ? 'places.kmlImportPoints' : 'places.kmlImportPaths')}
</span>
</label>
))}
{kmlNoneSelected && (
<div style={{ fontSize: 11, color: '#b45309', marginTop: 4 }}>{t('places.kmlImportNoneSelected')}</div>
)}
</div>
)}
{summary && (
<div style={{
border: '1px solid var(--border-primary)', borderRadius: 10,
@@ -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>
)
}
@@ -195,7 +195,7 @@ describe('Filter tabs', () => {
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
await user.click(screen.getByRole('button', { name: /Unplanned/i }));
await user.click(screen.getByRole('button', { name: /^All$/i }));
await user.click(screen.getByRole('button', { name: /^All/i }));
expect(screen.getByText('Planned Place')).toBeInTheDocument();
expect(screen.getByText('Unplanned Place')).toBeInTheDocument();
});
@@ -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(() => {
+369 -101
View File
@@ -1,7 +1,7 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { useState, useMemo, useEffect, useRef } from 'react'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react'
import { useState, useMemo, useEffect, useRef, useCallback } from 'react'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye, Route } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
@@ -10,9 +10,10 @@ 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'
import ConfirmDialog from '../shared/ConfirmDialog'
import Tooltip from '../shared/Tooltip'
interface PlacesSidebarProps {
tripId: number
@@ -26,6 +27,8 @@ interface PlacesSidebarProps {
onAssignToDay: (placeId: number, dayId: number) => void
onEditPlace: (place: Place) => void
onDeletePlace: (placeId: number) => void
onBulkDeletePlaces?: (ids: number[]) => void
onBulkDeleteConfirm?: (ids: number[]) => void
days: Day[]
isMobile: boolean
onCategoryFilterChange?: (categoryIds: Set<string>) => void
@@ -33,9 +36,115 @@ interface PlacesSidebarProps {
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
}
interface MemoPlaceRowProps {
place: Place
category: Category | undefined
isSelected: boolean
isPlanned: boolean
inDay: boolean
isChecked: boolean
selectMode: boolean
selectedDayId: number | null
canEditPlaces: boolean
isMobile: boolean
t: (key: string, params?: Record<string, any>) => string
onPlaceClick: (id: number | null) => void
onContextMenu: (e: React.MouseEvent, place: Place) => void
onAssignToDay: (placeId: number, dayId?: number) => void
toggleSelected: (id: number) => void
setDayPickerPlace: (place: any) => void
}
const MemoPlaceRow = React.memo(function MemoPlaceRow({
place, category: cat, isSelected, isPlanned, inDay, isChecked,
selectMode, selectedDayId, canEditPlaces, isMobile, t,
onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace,
}: MemoPlaceRowProps) {
const hasGeometry = Boolean(place.route_geometry)
return (
<div
key={place.id}
draggable={!selectMode}
onDragStart={e => {
e.dataTransfer.setData('placeId', String(place.id))
e.dataTransfer.effectAllowed = 'copy'
window.__dragData = { placeId: String(place.id) }
}}
onClick={() => {
if (selectMode) {
toggleSelected(place.id)
} else if (isMobile) {
setDayPickerPlace(place)
} else {
onPlaceClick(isSelected ? null : place.id)
}
}}
onContextMenu={selectMode ? undefined : e => onContextMenu(e, place)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 14px 9px 16px',
cursor: selectMode ? 'pointer' : 'grab',
background: isChecked ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : isSelected ? 'var(--border-faint)' : 'transparent',
borderBottom: '1px solid var(--border-faint)',
transition: 'background 0.1s',
contentVisibility: 'auto',
containIntrinsicSize: '0 52px',
}}
onMouseEnter={e => { if (!isSelected && !isChecked) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!isSelected && !isChecked) e.currentTarget.style.background = 'transparent' }}
>
{selectMode && (
<div style={{
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
border: isChecked ? 'none' : '1.5px solid var(--border-primary)',
background: isChecked ? 'var(--accent)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{isChecked && <Check size={10} strokeWidth={3} color="white" />}
</div>
)}
<PlaceAvatar place={place} category={cat} size={34} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden' }}>
{hasGeometry && <Route size={11} strokeWidth={2} color="var(--text-faint)" style={{ flexShrink: 0 }} title="Track / Route" />}
{cat && (() => {
const CatIcon = getCategoryIcon(cat.icon)
return <CatIcon size={11} strokeWidth={2} color={cat.color || '#6366f1'} style={{ flexShrink: 0 }} title={cat.name} />
})()}
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
{place.name}
</span>
</div>
{(place.description || place.address || cat?.name) && (
<div style={{ marginTop: 2 }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
{place.description || place.address || cat?.name}
</span>
</div>
)}
</div>
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{!selectMode && !inDay && selectedDayId && (
<button
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 20, height: 20, borderRadius: 6,
background: 'var(--bg-hover)', border: 'none', cursor: 'pointer',
color: 'var(--text-faint)', padding: 0, transition: 'background 0.15s, color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-faint)' }}
><Plus size={12} strokeWidth={2.5} /></button>
)}
</div>
</div>
)
})
const PlacesSidebar = React.memo(function PlacesSidebar({
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo,
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, onBulkDeletePlaces, onBulkDeleteConfirm, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo,
}: PlacesSidebarProps) {
const { t } = useTranslation()
const toast = useToast()
@@ -44,7 +153,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)
@@ -111,9 +220,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
if (result.places?.length > 0) {
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
pushUndo?.(t(provider === 'google' ? 'undo.importGoogleList' : 'undo.importNaverList'), async () => {
for (const id of importedIds) {
try { await placesApi.delete(tripId, id) } catch {}
}
try { await placesApi.bulkDelete(tripId, importedIds) } catch {}
await loadTrip(tripId)
})
}
@@ -127,6 +234,28 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
const [search, setSearch] = useState('')
const [filter, setFilter] = useState('all')
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
const [selectMode, setSelectMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
const exitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()) }
// Auto-exit when all selected places have been removed from the store (e.g. after bulk delete)
useEffect(() => {
if (!selectMode || selectedIds.size === 0) return
const placeIdSet = new Set(places.map(p => p.id))
if ([...selectedIds].every(id => !placeIdSet.has(id))) {
setSelectMode(false)
setSelectedIds(new Set())
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [places])
const toggleSelected = useCallback((id: number) => setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
}), [])
const toggleCategoryFilter = (catId: string) => {
setCategoryFiltersLocal(prev => {
@@ -141,13 +270,21 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
const [mobileShowDays, setMobileShowDays] = useState(false)
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
const hasTracks = useMemo(() => places.some(p => p.route_geometry), [places])
useEffect(() => { if (filter === 'tracks' && !hasTracks) setFilter('all') }, [hasTracks, filter])
const plannedIds = useMemo(() => new Set(
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
), [assignments])
const filtered = useMemo(() => places.filter(p => {
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
if (categoryFilters.size > 0 && !categoryFilters.has(String(p.category_id))) return false
if (filter === 'tracks' && !p.route_geometry) 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
@@ -156,6 +293,26 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
const isAssignedToSelectedDay = (placeId) =>
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
const selectedDayIdRef = useRef<number | null>(selectedDayId)
useEffect(() => { selectedDayIdRef.current = selectedDayId }, [selectedDayId])
const inDaySet = useMemo(() => {
if (!selectedDayId) return new Set<number>()
return new Set<number>((assignments[String(selectedDayId)] || []).map((a: any) => a.place?.id).filter(Boolean))
}, [assignments, selectedDayId])
const openContextMenu = useCallback((e: React.MouseEvent, place: Place) => {
const selDayId = selectedDayIdRef.current
ctxMenu.open(e, [
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selDayId) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${(place as any).google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + (place as any).google_place_id : place.lat + ',' + place.lng}`, '_blank') },
{ divider: true },
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])
}, [ctxMenu.open, canEditPlaces, t, onEditPlace, onAssignToDay, onDeletePlace])
return (
<div
onDragEnter={handleSidebarDragEnter}
@@ -217,19 +374,65 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
</button>
</div>
<div style={{ height: 1, background: 'var(--border-primary)', margin: '2px 0 10px' }} />
</>}
{/* Filter-Tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
{[{ id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }].map(f => (
<button key={f.id} onClick={() => { setFilter(f.id); onPlacesFilterChange?.(f.id) }} style={{
padding: '4px 10px', borderRadius: 20, border: 'none', cursor: 'pointer',
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
background: filter === f.id ? 'var(--accent)' : 'var(--bg-tertiary)',
color: filter === f.id ? 'var(--accent-text)' : 'var(--text-muted)',
}}>{f.label}</button>
))}
</div>
{(() => {
const baseFiltered = places.filter(p => {
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
})
const counts = {
all: baseFiltered.length,
unplanned: baseFiltered.filter(p => !plannedIds.has(p.id)).length,
tracks: baseFiltered.filter(p => p.route_geometry).length,
}
const tabs = ([
{ id: 'all', label: t('places.all') },
{ id: 'unplanned', label: t('places.unplanned') },
hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null,
] as const).filter(Boolean) as Array<{ id: 'all' | 'unplanned' | 'tracks'; label: string }>
return (
<div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'wrap' }}>
{tabs.map(f => {
const active = filter === f.id
return (
<button
key={f.id}
onClick={() => { setFilter(f.id); onPlacesFilterChange?.(f.id); setSelectedIds(new Set()) }}
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: '4px 9px', borderRadius: 99,
fontSize: 11, fontWeight: 500, whiteSpace: 'nowrap',
background: active ? 'var(--accent)' : 'var(--bg-card)',
color: active ? 'var(--accent-text)' : 'var(--text-primary)',
boxShadow: active ? 'none' : '0 1px 2px rgba(0,0,0,0.06)',
transition: 'background 0.15s, color 0.15s, box-shadow 0.15s',
}}
>
{f.label}
<span style={{
fontSize: 9, fontWeight: 600, lineHeight: 1,
background: active ? 'color-mix(in srgb, var(--accent-text) 22%, transparent)' : 'var(--bg-tertiary)',
color: active ? 'var(--accent-text)' : 'var(--text-faint)',
padding: '1px 5px', borderRadius: 99, minWidth: 14, textAlign: 'center',
}}>
{counts[f.id]}
</span>
</button>
)
})}
</div>
)
})()}
{/* Suchfeld */}
<div style={{ position: 'relative' }}>
@@ -237,7 +440,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
onChange={e => { setSearch(e.target.value); if (selectMode) setSelectedIds(new Set()) }}
placeholder={t('places.search')}
style={{
width: '100%', padding: '7px 30px 7px 30px', borderRadius: 10,
@@ -257,12 +460,12 @@ 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' }}>
<div style={{ marginTop: 6, position: 'relative', display: 'flex', gap: 6, alignItems: 'stretch' }}>
<button onClick={() => setCatDropOpen(v => !v)} style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
background: 'var(--bg-card)', fontSize: 12, color: 'var(--text-primary)',
cursor: 'pointer', fontFamily: 'inherit',
@@ -270,6 +473,41 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<ChevronDown size={12} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
</button>
{canEditPlaces && (
<Tooltip label={t('common.select')} placement="bottom">
<button
onClick={() => { setSelectMode(v => !v); setSelectedIds(new Set()) }}
aria-label={t('common.select')}
aria-pressed={selectMode}
style={{
position: 'relative', width: 30, flexShrink: 0, borderRadius: 8,
border: `1px solid ${selectMode ? 'var(--accent)' : 'var(--border-primary)'}`,
background: selectMode ? 'color-mix(in srgb, var(--accent) 14%, transparent)' : 'var(--bg-card)',
color: selectMode ? 'var(--accent)' : 'var(--text-faint)',
cursor: 'pointer', fontFamily: 'inherit', padding: 0,
transition: 'background 0.18s, color 0.18s, border-color 0.18s',
overflow: 'hidden',
}}
>
<span style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'opacity 0.18s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)',
opacity: selectMode ? 0 : 1,
transform: selectMode ? 'rotate(-90deg) scale(0.6)' : 'rotate(0) scale(1)',
}}>
<Check size={13} strokeWidth={2.4} />
</span>
<span style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'opacity 0.18s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)',
opacity: selectMode ? 1 : 0,
transform: selectMode ? 'rotate(0) scale(1)' : 'rotate(90deg) scale(0.6)',
}}>
<X size={13} strokeWidth={2.4} />
</span>
</button>
</Tooltip>
)}
{catDropOpen && (
<div style={{
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4,
@@ -300,6 +538,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,
@@ -317,13 +578,65 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
})()}
</div>
{/* Anzahl */}
<div style={{ padding: '6px 16px', flexShrink: 0 }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
</div>
{/* Anzahl / Auswahl-Leiste */}
{selectMode ? (
<div style={{
margin: '6px 16px', padding: '5px 8px 5px 10px', borderRadius: 8,
background: 'color-mix(in srgb, var(--accent) 10%, transparent)',
display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0, fontSize: 11,
}}>
<span style={{ flex: 1, color: 'var(--accent)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{t('places.selectionCount', { count: selectedIds.size })}
</span>
<Tooltip label={selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')} placement="bottom">
<button
onClick={() => {
if (selectedIds.size === filtered.length) setSelectedIds(new Set())
else setSelectedIds(new Set(filtered.map(p => p.id)))
}}
aria-label={selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 24, height: 24, borderRadius: 6, border: 'none',
background: 'transparent', color: 'var(--text-muted)', cursor: 'pointer', padding: 0,
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Check size={13} strokeWidth={2.2} />
</button>
</Tooltip>
<Tooltip label={t('places.deleteSelected')} placement="bottom">
<button
onClick={() => {
if (selectedIds.size === 0) return
if (isMobile) setPendingDeleteIds(Array.from(selectedIds))
else onBulkDeletePlaces?.(Array.from(selectedIds))
}}
disabled={selectedIds.size === 0}
aria-label={t('places.deleteSelected')}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 24, height: 24, borderRadius: 6, border: 'none',
background: 'transparent',
color: selectedIds.size > 0 ? '#ef4444' : 'var(--text-faint)',
cursor: selectedIds.size > 0 ? 'pointer' : 'default', padding: 0,
}}
onMouseEnter={e => { if (selectedIds.size > 0) e.currentTarget.style.background = 'color-mix(in srgb, #ef4444 14%, transparent)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Trash2 size={13} strokeWidth={2} />
</button>
</Tooltip>
</div>
) : (
<div style={{ padding: '6px 16px', flexShrink: 0 }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
</div>
)}
{/* Liste */}
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{filtered.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}>
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
@@ -337,82 +650,29 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
filtered.map(place => {
const cat = categories.find(c => c.id === place.category_id)
const isSelected = place.id === selectedPlaceId
const inDay = isAssignedToSelectedDay(place.id)
const isPlanned = plannedIds.has(place.id)
const inDay = inDaySet.has(place.id)
const isChecked = selectedIds.has(place.id)
return (
<div
<MemoPlaceRow
key={place.id}
draggable
onDragStart={e => {
e.dataTransfer.setData('placeId', String(place.id))
e.dataTransfer.effectAllowed = 'copy'
// Backup in window für Cross-Component Drag (dataTransfer geht bei Re-Render verloren)
window.__dragData = { placeId: String(place.id) }
}}
onClick={() => {
if (isMobile) {
setDayPickerPlace(place)
} else {
onPlaceClick(isSelected ? null : place.id)
}
}}
onContextMenu={e => ctxMenu.open(e, [
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') },
{ divider: true },
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 14px 9px 16px',
cursor: 'grab',
background: isSelected ? 'var(--border-faint)' : 'transparent',
borderBottom: '1px solid var(--border-faint)',
transition: 'background 0.1s',
contentVisibility: 'auto',
containIntrinsicSize: '0 52px',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
>
<PlaceAvatar place={place} category={cat} size={34} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden' }}>
{cat && (() => {
const CatIcon = getCategoryIcon(cat.icon)
return <CatIcon size={11} strokeWidth={2} color={cat.color || '#6366f1'} style={{ flexShrink: 0 }} title={cat.name} />
})()}
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
{place.name}
</span>
</div>
{(place.description || place.address || cat?.name) && (
<div style={{ marginTop: 2 }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
{place.description || place.address || cat?.name}
</span>
</div>
)}
</div>
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{!inDay && selectedDayId && (
<button
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 20, height: 20, borderRadius: 6,
background: 'var(--bg-hover)', border: 'none', cursor: 'pointer',
color: 'var(--text-faint)', padding: 0, transition: 'background 0.15s, color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-faint)' }}
><Plus size={12} strokeWidth={2.5} /></button>
)}
</div>
</div>
place={place}
category={cat}
isSelected={isSelected}
isPlanned={isPlanned}
inDay={inDay}
isChecked={isChecked}
selectMode={selectMode}
selectedDayId={selectedDayId}
canEditPlaces={canEditPlaces}
isMobile={isMobile}
t={t}
onPlaceClick={onPlaceClick}
onContextMenu={openContextMenu}
onAssignToDay={onAssignToDay}
toggleSelected={toggleSelected}
setDayPickerPlace={setDayPickerPlace}
/>
)
})
)}
@@ -576,6 +836,14 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
initialFile={sidebarDropFile}
/>
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
{isMobile && (
<ConfirmDialog
isOpen={!!pendingDeleteIds?.length}
onClose={() => setPendingDeleteIds(null)}
onConfirm={() => { onBulkDeleteConfirm?.(pendingDeleteIds!); setPendingDeleteIds(null) }}
message={t('trip.confirm.deletePlaces', { count: pendingDeleteIds?.length ?? 0 })}
/>
)}
</div>
)
})
@@ -87,7 +87,7 @@ describe('ReservationModal', () => {
});
it('FE-PLANNER-RESMODAL-003: shows "Edit Reservation" title when editing', () => {
const res = buildReservation({ title: 'Flight NY', type: 'flight' });
const res = buildReservation({ title: 'Nice Dinner', type: 'restaurant' });
render(<ReservationModal {...defaultProps} reservation={res} />);
expect(screen.getByText(/Edit Reservation/i)).toBeInTheDocument();
});
@@ -101,49 +101,40 @@ describe('ReservationModal', () => {
expect(onSave).not.toHaveBeenCalled();
});
it('FE-PLANNER-RESMODAL-005: all 9 type buttons are visible', () => {
it('FE-PLANNER-RESMODAL-005: all 5 type buttons are visible (transport types removed)', () => {
render(<ReservationModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /Flight/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Accommodation/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Restaurant/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Train/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Rental Car/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Cruise/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Event/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Tour/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Other/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^Flight$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^Train$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^Car$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^Cruise$/i })).not.toBeInTheDocument();
});
// ── Type selection ──────────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-006: clicking Flight type button shows flight-specific fields', async () => {
it('FE-PLANNER-RESMODAL-006: clicking Event type button activates it', async () => {
render(<ReservationModal {...defaultProps} />);
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
// Flight-specific airline field has placeholder="Lufthansa" (exact, not the title placeholder)
expect(screen.getByPlaceholderText('Lufthansa')).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-007: flight type shows airline/airport fields', async () => {
render(<ReservationModal {...defaultProps} />);
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
expect(screen.getByText(/Airline/i)).toBeInTheDocument();
expect(screen.getByText(/^From$/i)).toBeInTheDocument();
expect(screen.getByText(/^To$/i)).toBeInTheDocument();
const eventBtn = screen.getByRole('button', { name: /Event/i });
await userEvent.click(eventBtn);
expect(eventBtn).toHaveStyle({ background: 'var(--text-primary)' });
});
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();
});
it('FE-PLANNER-RESMODAL-009: train type shows train number/platform/seat fields', async () => {
it('FE-PLANNER-RESMODAL-009: restaurant type shows location field', async () => {
render(<ReservationModal {...defaultProps} />);
await userEvent.click(screen.getByRole('button', { name: /Train/i }));
expect(screen.getByText(/Train No\./i)).toBeInTheDocument();
expect(screen.getByText(/Platform/i)).toBeInTheDocument();
expect(screen.getByText(/Seat/i)).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /Restaurant/i }));
expect(screen.getByPlaceholderText(/Address, Airport/i)).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-010: hotel type hides assignment picker', async () => {
@@ -182,13 +173,10 @@ describe('ReservationModal', () => {
expect(screen.getByDisplayValue('Breakfast included')).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-014: editing pre-fills type — train type does not show flight fields', () => {
const res = buildReservation({ type: 'train' });
it('FE-PLANNER-RESMODAL-014: editing pre-fills type — restaurant type shows location field', () => {
const res = buildReservation({ type: 'restaurant', location: 'Via Roma 1' });
render(<ReservationModal {...defaultProps} reservation={res} />);
// Flight-specific airline input has placeholder="Lufthansa" (exact) — should NOT appear for train type
expect(screen.queryByPlaceholderText('Lufthansa')).not.toBeInTheDocument();
// Train fields should appear
expect(screen.getByText(/Train No\./i)).toBeInTheDocument();
expect(screen.getByDisplayValue('Via Roma 1')).toBeInTheDocument();
});
// ── Validation ──────────────────────────────────────────────────────────────
@@ -231,18 +219,18 @@ describe('ReservationModal', () => {
// ── Submit flow ─────────────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-016: submitting valid flight calls onSave with correct shape', async () => {
it('FE-PLANNER-RESMODAL-016: submitting valid restaurant booking calls onSave with correct shape', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Air France 777');
await userEvent.click(screen.getByRole('button', { name: /Restaurant/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Le Jules Verne');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Air France 777', type: 'flight' })
expect.objectContaining({ title: 'Le Jules Verne', type: 'restaurant' })
);
});
@@ -438,17 +426,17 @@ describe('ReservationModal', () => {
);
});
it('FE-PLANNER-RESMODAL-031: train type — saving calls onSave with train type', async () => {
it('FE-PLANNER-RESMODAL-031: event type — saving calls onSave with event type', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /Train/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Eurostar Paris');
await userEvent.click(screen.getByRole('button', { name: /Event/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Louvre Museum');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Eurostar Paris', type: 'train' })
expect.objectContaining({ title: 'Louvre Museum', type: 'event' })
);
});
@@ -472,7 +460,7 @@ describe('ReservationModal', () => {
it('FE-PLANNER-RESMODAL-036: file upload to existing reservation calls onFileUpload', async () => {
const onFileUpload = vi.fn().mockResolvedValue(undefined);
const res = buildReservation({ id: 10, title: 'My Trip', type: 'flight' });
const res = buildReservation({ id: 10, title: 'My Trip', type: 'other' });
render(
<ReservationModal
{...defaultProps}
@@ -574,30 +562,18 @@ describe('ReservationModal', () => {
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-042: flight type metadata saved with airline and airports', async () => {
it('FE-PLANNER-RESMODAL-042: hotel type metadata saved with check-in time', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'AF 447');
await userEvent.type(screen.getByPlaceholderText('Lufthansa'), 'Air France');
await userEvent.type(screen.getByPlaceholderText('LH 123'), 'AF 447');
await userEvent.type(screen.getByPlaceholderText('FRA'), 'CDG');
await userEvent.type(screen.getByPlaceholderText('NRT'), 'JFK');
await userEvent.click(screen.getByRole('button', { name: /Accommodation/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Grand Hotel');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
type: 'flight',
metadata: expect.objectContaining({
airline: 'Air France',
flight_number: 'AF 447',
departure_airport: 'CDG',
arrival_airport: 'JFK',
}),
})
expect.objectContaining({ title: 'Grand Hotel', type: 'hotel' })
);
});
@@ -637,22 +613,21 @@ describe('ReservationModal', () => {
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-045: car type shows date/time section', async () => {
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
render(<ReservationModal {...defaultProps} />);
await userEvent.click(screen.getByRole('button', { name: /Rental Car/i }));
// Car type still shows date fields (not hotel which hides them)
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i }));
await waitFor(() => {
expect(screen.getAllByTestId('date-picker').length).toBeGreaterThan(0);
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThan(0);
});
});
it('FE-PLANNER-RESMODAL-046: cruise type renders and saves correctly', async () => {
it('FE-PLANNER-RESMODAL-046: other type renders and saves correctly', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /Cruise/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Caribbean Cruise');
await userEvent.click(screen.getByRole('button', { name: /^Other$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Misc item');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'cruise' })));
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' })));
});
it('FE-PLANNER-RESMODAL-047: clicking budget category select changes the value', async () => {
@@ -733,23 +708,17 @@ describe('ReservationModal', () => {
});
});
it('FE-PLANNER-RESMODAL-035: flight with train number metadata saved correctly', async () => {
it('FE-PLANNER-RESMODAL-035: hotel type saves correctly', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /Train/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE 792');
await userEvent.type(screen.getByPlaceholderText(/ICE 123/i), 'ICE 792');
await userEvent.type(screen.getByPlaceholderText(/^12$/i), '5');
await userEvent.type(screen.getByPlaceholderText(/42A/i), '14B');
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Test');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
type: 'train',
metadata: expect.objectContaining({ train_number: 'ICE 792', platform: '5', seat: '14B' }),
})
expect.objectContaining({ type: 'hotel' })
);
});
});
@@ -5,7 +5,7 @@ import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
import { Hotel, Utensils, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
@@ -14,12 +14,8 @@ import { openFile } from '../../utils/fileDownload'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils },
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket },
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users },
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
@@ -33,7 +29,6 @@ function buildAssignmentOptions(days, assignments, t, locale) {
const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number })
const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : ''
const groupLabel = `${dayLabel}${dateStr}`
// Group header (non-selectable)
options.push({ value: `_header_${day.id}`, label: groupLabel, disabled: true, isHeader: true })
for (let i = 0; i < da.length; i++) {
const place = da[i].place
@@ -64,9 +59,10 @@ interface ReservationModalProps {
onFileUpload?: (fd: FormData) => Promise<void>
onFileDelete: (fileId: number) => Promise<void>
accommodations?: Accommodation[]
defaultAssignmentId?: number | null
}
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [] }: ReservationModalProps) {
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null }: ReservationModalProps) {
const { id: tripId } = useParams<{ id: string }>()
const loadFiles = useTripStore(s => s.loadFiles)
const toast = useToast()
@@ -84,20 +80,16 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const [form, setForm] = useState({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: '', accommodation_id: '',
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
price: '', budget_category: '',
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
meta_departure_timezone: '', meta_arrival_timezone: '',
meta_train_number: '', meta_platform: '', meta_seat: '',
meta_check_in_time: '', meta_check_out_time: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
})
const [isSaving, setIsSaving] = useState(false)
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState([])
const [showFilePicker, setShowFilePicker] = useState(false)
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
const [unlinkedFileIds, setUnlinkedFileIds] = useState<number[]>([])
const assignmentOptions = useMemo(
() => buildAssignmentOptions(days, assignments, t, locale),
@@ -107,7 +99,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
useEffect(() => {
if (reservation) {
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
// Parse end_date from reservation_end_time if it's a full ISO datetime
const rawEnd = reservation.reservation_end_time || ''
let endDate = ''
let endTime = rawEnd
@@ -130,16 +121,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
notes: reservation.notes || '',
assignment_id: reservation.assignment_id || '',
accommodation_id: reservation.accommodation_id || '',
meta_airline: meta.airline || '',
meta_flight_number: meta.flight_number || '',
meta_departure_airport: meta.departure_airport || '',
meta_arrival_airport: meta.arrival_airport || '',
meta_departure_timezone: meta.departure_timezone || '',
meta_arrival_timezone: meta.arrival_timezone || '',
meta_train_number: meta.train_number || '',
meta_platform: meta.platform || '',
meta_seat: meta.seat || '',
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 || '' })(),
@@ -151,41 +134,22 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
setForm({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: '', accommodation_id: '',
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
price: '', budget_category: '',
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
meta_departure_timezone: '', meta_arrival_timezone: '',
meta_train_number: '', meta_platform: '', meta_seat: '',
meta_check_in_time: '', meta_check_out_time: '',
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
})
setPendingFiles([])
}
}, [reservation, isOpen, selectedDayId])
}, [reservation, isOpen, selectedDayId, defaultAssignmentId])
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
// Validate that end datetime is after start datetime
const isEndBeforeStart = (() => {
if (!form.end_date || !form.reservation_time) return false
const startDate = form.reservation_time.split('T')[0]
const startTime = form.reservation_time.split('T')[1] || '00:00'
const endTime = form.reservation_end_time || '00:00'
// For flights, compare in UTC using timezone offsets
if (form.type === 'flight') {
const parseOffset = (tz: string): number | null => {
if (!tz) return null
const m = tz.trim().match(/^(?:UTC|GMT)?\s*([+-])(\d{1,2})(?::(\d{2}))?$/i)
if (!m) return null
const sign = m[1] === '+' ? 1 : -1
return sign * (parseInt(m[2]) * 60 + parseInt(m[3] || '0'))
}
const depOffset = parseOffset(form.meta_departure_timezone)
const arrOffset = parseOffset(form.meta_arrival_timezone)
if (depOffset === null || arrOffset === null) return false
const depMinutes = new Date(`${startDate}T${startTime}`).getTime() - depOffset * 60000
const arrMinutes = new Date(`${form.end_date}T${endTime}`).getTime() - arrOffset * 60000
return arrMinutes <= depMinutes
}
const startFull = `${startDate}T${startTime}`
const endFull = `${form.end_date}T${endTime}`
return endFull <= startFull
@@ -198,22 +162,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
setIsSaving(true)
try {
const metadata: Record<string, string> = {}
if (form.type === 'flight') {
if (form.meta_airline) metadata.airline = form.meta_airline
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
if (form.meta_departure_airport) metadata.departure_airport = form.meta_departure_airport
if (form.meta_arrival_airport) metadata.arrival_airport = form.meta_arrival_airport
if (form.meta_departure_timezone) metadata.departure_timezone = form.meta_departure_timezone
if (form.meta_arrival_timezone) metadata.arrival_timezone = form.meta_arrival_timezone
} else if (form.type === 'hotel') {
if (form.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
if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat
}
// Combine end_date + end_time into reservation_end_time
let combinedEndTime = form.reservation_end_time
if (form.end_date) {
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
@@ -222,29 +175,31 @@ 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 saveData: Record<string, any> = {
title: form.title, type: form.type, status: form.status,
reservation_time: form.type === 'hotel' ? null : form.reservation_time,
reservation_end_time: form.type === 'hotel' ? null : combinedEndTime,
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null),
reservation_end_time: form.type === 'hotel' ? null : (combinedEndTime || null),
location: form.location, confirmation_number: form.confirmation_number,
notes: form.notes,
assignment_id: form.assignment_id || null,
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
metadata: Object.keys(metadata).length > 0 ? metadata : null,
endpoints: [],
needs_review: false,
}
// Auto-create/update budget entry if price is set, or signal removal if cleared
if (isBudgetEnabled) {
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
// If hotel with place + days, pass hotel data for auto-creation or update
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
saveData.create_accommodation = {
place_id: form.hotel_place_id,
start_day_id: form.hotel_start_day,
end_day_id: form.hotel_end_day,
check_in: form.meta_check_in_time || null,
check_in_end: form.meta_check_in_end_time || null,
check_out: form.meta_check_out_time || null,
confirmation: form.confirmation_number || null,
}
@@ -335,7 +290,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
{/* Assignment Picker (hidden for hotels) */}
{form.type !== 'hotel' && assignmentOptions.length > 0 && (
<div>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>
<Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
@@ -362,126 +317,88 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
size="sm"
/>
</div>
</div>
</div>
)}
{/* Start Date/Time + End Date/Time + Status (hidden for hotels) */}
{form.type !== 'hotel' && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}</label>
<CustomDatePicker
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
onChange={d => {
const [, t] = (form.reservation_time || '').split('T')
set('reservation_time', d ? (t ? `${d}T${t}` : d) : '')
}}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.departureTime') : form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}</label>
<CustomTimePicker
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
onChange={t => {
const [d] = (form.reservation_time || '').split('T')
const selectedDay = days.find(dy => dy.id === selectedDayId)
const date = d || selectedDay?.date || new Date().toISOString().split('T')[0]
set('reservation_time', t ? `${date}T${t}` : date)
}}
/>
</div>
{form.type === 'flight' && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<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} />
<label style={labelStyle}>{t('reservations.date')}</label>
<CustomDatePicker
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
onChange={d => {
const [, tm] = (form.reservation_time || '').split('T')
set('reservation_time', d ? (tm ? `${d}T${tm}` : d) : '')
}}
/>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}</label>
<CustomDatePicker
value={form.end_date}
onChange={d => set('end_date', d || '')}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}</label>
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
</div>
{form.type === 'flight' && (
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.meta.arrivalTimezone')}</label>
<input type="text" value={form.meta_arrival_timezone} onChange={e => set('meta_arrival_timezone', e.target.value)}
placeholder="e.g. JST, UTC+9" style={inputStyle} />
<label style={labelStyle}>{t('reservations.startTime')}</label>
<CustomTimePicker
value={(() => { const [, tm] = (form.reservation_time || '').split('T'); return tm || '' })()}
onChange={tm => {
const [d] = (form.reservation_time || '').split('T')
const selectedDay = days.find(dy => dy.id === selectedDayId)
const date = d || selectedDay?.date || new Date().toISOString().split('T')[0]
set('reservation_time', tm ? `${date}T${tm}` : date)
}}
/>
</div>
)}
</div>
{isEndBeforeStart && (
<div style={{ fontSize: 11, color: '#ef4444', marginTop: -6 }}>{t('reservations.validation.endBeforeStart')}</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
</div>
</div>
</>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.endDate')}</label>
<CustomDatePicker
value={form.end_date}
onChange={d => set('end_date', d || '')}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.endTime')}</label>
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
</div>
</div>
{isEndBeforeStart && (
<div style={{ fontSize: 11, color: '#ef4444', marginTop: -6 }}>{t('reservations.validation.endBeforeStart')}</div>
)}
</>
)}
{/* Location + Booking Code */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Location */}
{form.type !== 'hotel' && (
<div>
<label style={labelStyle}>{t('reservations.locationAddress')}</label>
<input type="text" value={form.location} onChange={e => set('location', e.target.value)}
placeholder={t('reservations.locationPlaceholder')} style={inputStyle} />
</div>
)}
{/* Booking Code + Status */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.confirmationCode')}</label>
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
</div>
</div>
{/* Type-specific fields */}
{form.type === 'flight' && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.airline') || 'Airline'}</label>
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
placeholder="Lufthansa" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.flightNumber') || 'Flight No.'}</label>
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
placeholder="LH 123" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.from') || 'From'}</label>
<input type="text" value={form.meta_departure_airport} onChange={e => set('meta_departure_airport', e.target.value)}
placeholder="FRA" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.to') || 'To'}</label>
<input type="text" value={form.meta_arrival_airport} onChange={e => set('meta_arrival_airport', e.target.value)}
placeholder="NRT" style={inputStyle} />
</div>
</div>
)}
{/* Hotel fields */}
{form.type === 'hotel' && (
<>
{/* Hotel place + day range */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.hotelPlace')}</label>
@@ -525,52 +442,23 @@ 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-1 sm:grid-cols-3 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.checkIn')}</label>
<CustomTimePicker value={form.meta_check_in_time} onChange={v => set('meta_check_in_time', v)} />
</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)} />
</div>
<div>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
</div>
</div>
</>
)}
{form.type === 'train' && (
<div className="grid grid-cols-3 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.trainNumber') || 'Train No.'}</label>
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
placeholder="ICE 123" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.platform') || 'Platform'}</label>
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
placeholder="12" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.seat') || 'Seat'}</label>
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
placeholder="42A" style={inputStyle} />
</div>
</div>
)}
{/* Notes */}
<div>
<label style={labelStyle}>{t('reservations.notes')}</label>
@@ -589,12 +477,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
<button type="button" onClick={async () => {
// Always unlink, never delete the file
// Clear primary reservation_id if it points to this reservation
if (f.reservation_id === reservation?.id) {
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
}
// Remove from file_links if linked there
try {
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
@@ -627,7 +512,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<Paperclip size={11} />
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
</button>}
{/* Link existing file picker */}
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
<div style={{ position: 'relative' }}>
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
@@ -671,7 +555,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div>
</div>
{/* Price + Budget Category — only shown when budget addon is enabled */}
{/* Price + Budget Category */}
{isBudgetEnabled && (
<>
<div style={{ display: 'flex', gap: 8 }}>
@@ -679,7 +563,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<label style={labelStyle}>{t('reservations.price')}</label>
<input type="text" inputMode="decimal" value={form.price}
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } set('price', t) }}
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
placeholder="0.00"
style={inputStyle} />
</div>
@@ -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) ──────────────────────────────────────────────────
@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react'
import { useState, useMemo, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
@@ -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
@@ -59,9 +69,10 @@ interface ReservationCardProps {
onNavigateToFiles: () => void
assignmentLookup: Record<number, AssignmentLookupEntry>
canEdit: boolean
days?: Day[]
}
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit }: ReservationCardProps) {
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit, days = [] }: ReservationCardProps) {
const { toggleReservationStatus } = useTripStore()
const toast = useToast()
const { t, locale } = useTranslation()
@@ -84,184 +95,269 @@ 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
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
const startDay = r.day_id ? days.find(d => d.id === r.day_id) : undefined
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id) : undefined
const dayLabel = (day: typeof startDay): string => {
if (!day) return ''
const base = day.title || t('dayplan.dayN', { n: day.day_number })
if (day.date) {
const d = new Date(day.date + 'T00:00:00Z')
const dateStr = d.toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
return `${base} · ${dateStr}`
}
return base
}
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 }}>
{/* Day label for transport reservations linked to a day */}
{isTransportType && startDay && (
<div>
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
{dayLabel(startDay)}{endDay && endDay.id !== startDay.id ? ` ${dayLabel(endDay)}` : ''}
</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>
{/* 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>
)}
{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>
{/* 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>
)}
{(() => {
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,
@@ -311,25 +407,40 @@ interface SectionProps {
children: React.ReactNode
defaultOpen?: boolean
accent: 'green' | string
storageKey?: string
}
function Section({ title, count, children, defaultOpen = true, accent }: SectionProps) {
const [open, setOpen] = useState(defaultOpen)
function Section({ title, count, children, defaultOpen = true, accent, storageKey }: SectionProps) {
const [open, setOpen] = useState(() => {
if (!storageKey || typeof window === 'undefined') return defaultOpen
const stored = window.localStorage.getItem(storageKey)
if (stored === null) return defaultOpen
return stored === '1'
})
useEffect(() => {
if (!storageKey || typeof window === 'undefined') return
window.localStorage.setItem(storageKey, open ? '1' : '0')
}, [open, storageKey])
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>
)
}
@@ -344,64 +455,163 @@ interface ReservationsPanelProps {
onEdit: (reservation: Reservation) => void
onDelete: (id: number) => void
onNavigateToFiles: () => void
titleKey?: string
addManualKey?: string
}
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
const { t, locale } = useTranslation()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('reservation_edit', trip)
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
const 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(titleKey)}
</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(addManualKey)}</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>
<Section title={t('reservations.pending')} count={allPending.length} accent="gray" storageKey={`trek:bookings-pending-open:${tripId}`}>
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} days={days} />)}
</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>
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green" storageKey={`trek:bookings-confirmed-open:${tripId}`}>
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} days={days} />)}
</Section>
)}
</>
@@ -0,0 +1,422 @@
import { useState, useEffect } from 'react'
import { Plane, Train, Car, Ship } from 'lucide-react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker'
import AirportSelect, { type Airport } from './AirportSelect'
import LocationSelect, { type LocationPoint } from './LocationSelect'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { formatDate } from '../../utils/formatters'
import type { Day, Reservation, ReservationEndpoint } from '../../types'
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const
type TransportType = typeof TRANSPORT_TYPES[number]
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 },
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
]
const defaultForm = {
title: '',
type: 'flight' as TransportType,
status: 'pending' as 'pending' | 'confirmed',
start_day_id: '' as string | number,
end_day_id: '' as string | number,
departure_time: '',
arrival_time: '',
confirmation_number: '',
notes: '',
meta_airline: '',
meta_flight_number: '',
meta_train_number: '',
meta_platform: '',
meta_seat: '',
}
interface TransportModalProps {
isOpen: boolean
onClose: () => void
onSave: (data: Record<string, any>) => Promise<void>
reservation: Reservation | null
days: Day[]
selectedDayId: number | null
}
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
const { t, locale } = useTranslation()
const toast = useToast()
const [form, setForm] = useState({ ...defaultForm })
const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({})
const [toPick, setToPick] = useState<EndpointPick>({})
useEffect(() => {
if (!isOpen) return
if (reservation) {
const meta = typeof reservation.metadata === 'string'
? JSON.parse(reservation.metadata || '{}')
: (reservation.metadata || {})
const eps = reservation.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
const type = (TRANSPORT_TYPES as readonly string[]).includes(reservation.type)
? reservation.type as TransportType
: 'flight'
setForm({
title: reservation.title || '',
type,
status: reservation.status || 'pending',
start_day_id: reservation.day_id ?? '',
end_day_id: reservation.end_day_id ?? '',
departure_time: reservation.reservation_time?.split('T')[1]?.slice(0, 5) ?? '',
arrival_time: reservation.reservation_end_time?.split('T')[1]?.slice(0, 5) ?? '',
confirmation_number: reservation.confirmation_number || '',
notes: reservation.notes || '',
meta_airline: meta.airline || '',
meta_flight_number: meta.flight_number || '',
meta_train_number: meta.train_number || '',
meta_platform: meta.platform || '',
meta_seat: meta.seat || '',
})
if (type === 'flight') {
setFromPick({ airport: airportFromEndpoint(from) || undefined })
setToPick({ airport: airportFromEndpoint(to) || undefined })
} else {
setFromPick({ location: locationFromEndpoint(from) || undefined })
setToPick({ location: locationFromEndpoint(to) || undefined })
}
} else {
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '' })
setFromPick({})
setToPick({})
}
}, [isOpen, reservation, selectedDayId])
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.title.trim()) return
setIsSaving(true)
try {
const startDay = days.find(d => d.id === Number(form.start_day_id))
const endDay = days.find(d => d.id === Number(form.end_day_id))
const buildTime = (day: Day | undefined, time: string): string | null => {
if (!time) return null
return day?.date ? `${day.date}T${time}` : `T${time}`
}
const metadata: Record<string, string> = {}
if (form.type === 'flight') {
if (form.meta_airline) metadata.airline = form.meta_airline
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
if (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 === 'train') {
if (form.meta_train_number) metadata.train_number = form.meta_train_number
if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat
}
const startDate = startDay?.date ?? null
const endDate = (endDay ?? startDay)?.date ?? null
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
if (form.type === 'flight') {
if (fromPick.airport) endpoints.push(endpointFromAirport(fromPick.airport, 'from', 0, startDate, form.departure_time || null))
if (toPick.airport) endpoints.push(endpointFromAirport(toPick.airport, 'to', 1, endDate, form.arrival_time || null))
} else {
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, form.arrival_time || null))
}
const payload = {
title: form.title,
type: form.type,
status: form.status,
day_id: form.start_day_id ? Number(form.start_day_id) : null,
end_day_id: form.end_day_id ? Number(form.end_day_id) : null,
reservation_time: buildTime(startDay, form.departure_time),
reservation_end_time: buildTime(endDay ?? startDay, form.arrival_time),
location: null,
confirmation_number: form.confirmation_number || null,
notes: form.notes || null,
metadata: Object.keys(metadata).length > 0 ? metadata : null,
endpoints,
needs_review: false,
}
await onSave(payload)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
} finally {
setIsSaving(false)
}
}
const inputStyle = {
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
outline: 'none', boxSizing: 'border-box' as const, color: 'var(--text-primary)', background: 'var(--bg-input)',
}
const labelStyle = {
display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)',
marginBottom: 5, textTransform: 'uppercase' as const, letterSpacing: '0.03em',
}
const dayOptions = [
{ value: '', label: '—' },
...days.map(d => ({
value: d.id,
label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale) ?? ''}` : ''}`,
})),
]
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
size="2xl"
>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Type selector */}
<div>
<label style={labelStyle}>{t('reservations.bookingType')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => (
<button key={value} type="button" onClick={() => set('type', value)} style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '5px 10px', borderRadius: 99, border: '1px solid',
fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
background: form.type === value ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: form.type === value ? 'var(--text-primary)' : 'var(--border-primary)',
color: form.type === value ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
<Icon size={11} /> {t(labelKey)}
</button>
))}
</div>
</div>
{/* Title */}
<div>
<label style={labelStyle}>{t('reservations.titleLabel')} *</label>
<input type="text" value={form.title} onChange={e => set('title', e.target.value)} required
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
</div>
{/* From / To endpoints */}
<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>
{/* Departure row */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>
{form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}
</label>
<CustomSelect
value={form.start_day_id}
onChange={value => set('start_day_id', value)}
placeholder={t('dayplan.dayN', { n: '?' })}
options={dayOptions}
size="sm"
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>
{form.type === 'flight' ? t('reservations.departureTime') : form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}
</label>
<CustomTimePicker value={form.departure_time} onChange={v => set('departure_time', v)} />
</div>
{form.type === 'flight' && fromPick.airport && (
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.meta.departureTimezone')}</label>
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
{fromPick.airport.tz}
</div>
</div>
)}
</div>
{/* Arrival row */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>
{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}
</label>
<CustomSelect
value={form.end_day_id}
onChange={value => set('end_day_id', value)}
placeholder={t('dayplan.dayN', { n: '?' })}
options={dayOptions}
size="sm"
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>
{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}
</label>
<CustomTimePicker value={form.arrival_time} onChange={v => set('arrival_time', v)} />
</div>
{form.type === 'flight' && toPick.airport && (
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.meta.arrivalTimezone')}</label>
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
{toPick.airport.tz}
</div>
</div>
)}
</div>
{/* Flight-specific fields */}
{form.type === 'flight' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.airline')}</label>
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
placeholder="Lufthansa" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.flightNumber')}</label>
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
placeholder="LH 123" style={inputStyle} />
</div>
</div>
)}
{/* Train-specific fields */}
{form.type === 'train' && (
<div className="grid grid-cols-3 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.trainNumber')}</label>
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
placeholder="ICE 123" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.platform')}</label>
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
placeholder="12" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.seat')}</label>
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
placeholder="42A" style={inputStyle} />
</div>
</div>
)}
{/* Booking Code + Status */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.confirmationCode')}</label>
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
</div>
</div>
{/* Notes */}
<div>
<label style={labelStyle}>{t('reservations.notes')}</label>
<textarea value={form.notes} onChange={e => set('notes', e.target.value)} rows={2}
placeholder={t('reservations.notesPlaceholder')}
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div>
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
</form>
</Modal>
)
}
+231 -8
View File
@@ -1,5 +1,5 @@
import React from 'react'
import { Info, Coffee, Heart, ExternalLink, Bug, Lightbulb, BookOpen } from 'lucide-react'
import { Info, Coffee, Heart, ExternalLink, Bug, Lightbulb, BookOpen, Tent, Compass, Plane, Crown, Infinity as InfinityIcon } from 'lucide-react'
import { useTranslation } from '../../i18n'
import Section from './Section'
@@ -7,8 +7,229 @@ interface Props {
appVersion: string
}
type SupporterTierId = 'no_return_ticket' | 'lost_luggage_vip' | 'business_class_dreamer' | 'budget_traveller' | 'hostel_bunkmate'
interface SupporterTier {
id: SupporterTierId
labelKey: string
price: string
gradient: string
glow: string
icon: typeof Tent
}
const SUPPORTER_TIERS: SupporterTier[] = [
{ id: 'no_return_ticket', labelKey: 'settings.about.supporter.tier.noReturnTicket', price: '∞', gradient: 'linear-gradient(135deg, #fbbf24, #ec4899 55%, #6366f1)', glow: 'rgba(236,72,153,0.45)', icon: InfinityIcon },
{ id: 'lost_luggage_vip', labelKey: 'settings.about.supporter.tier.lostLuggageVip', price: '$30', gradient: 'linear-gradient(135deg, #a855f7, #ec4899)', glow: 'rgba(168,85,247,0.35)', icon: Crown },
{ id: 'business_class_dreamer', labelKey: 'settings.about.supporter.tier.businessClassDreamer', price: '$15', gradient: 'linear-gradient(135deg, #6366f1, #0ea5e9)', glow: 'rgba(99,102,241,0.35)', icon: Plane },
{ id: 'budget_traveller', labelKey: 'settings.about.supporter.tier.budgetTraveller', price: '$10', gradient: 'linear-gradient(135deg, #14b8a6, #06b6d4)', glow: 'rgba(20,184,166,0.3)', icon: Compass },
{ id: 'hostel_bunkmate', labelKey: 'settings.about.supporter.tier.hostelBunkmate', price: '$5', gradient: 'linear-gradient(135deg, #64748b, #94a3b8)', glow: 'rgba(100,116,139,0.25)', icon: Tent },
]
interface Supporter {
username: string
tier: SupporterTierId
since: string
link?: string
}
const SUPPORTERS: Supporter[] = [
{ username: 'Someone', tier: 'hostel_bunkmate', since: '2026-04' },
]
function SupporterSection({ t, locale }: { t: (key: string, vars?: Record<string, string | number>) => string; locale: string }) {
if (SUPPORTERS.length === 0) return null
const formatSince = (yearMonth: string): string => {
const [y, m] = yearMonth.split('-').map(Number)
if (!y || !m) return yearMonth
try {
return new Date(y, m - 1, 1).toLocaleDateString(locale, { year: 'numeric', month: 'long' })
} catch { return yearMonth }
}
return (
<div className="supporter-section">
<style>{`
.supporter-section { margin-top: 20px; }
.supporter-card {
position: relative;
border-radius: 20px;
padding: 22px 22px 18px;
background: linear-gradient(180deg, rgba(99,102,241,0.06) 0%, rgba(236,72,153,0.04) 100%);
border: 1px solid rgba(99,102,241,0.18);
overflow: hidden;
}
.supporter-glow {
position: absolute; inset: -60px; z-index: 0; pointer-events: none;
background: radial-gradient(500px 240px at 15% -10%, rgba(99,102,241,0.18), transparent 60%), radial-gradient(400px 200px at 90% 110%, rgba(236,72,153,0.12), transparent 60%);
animation: supporterGlow 6s ease-in-out infinite;
}
.supporter-header {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin-bottom: 6px;
}
.supporter-badge {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px;
background: linear-gradient(90deg, #6366f1, #ec4899, #fbbf24);
background-size: 200% 100%;
animation: supporterShimmer 4s ease-in-out infinite;
color: #fff; font-weight: 700; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase;
box-shadow: 0 4px 16px rgba(236,72,153,0.25);
white-space: nowrap;
}
.supporter-title {
margin: 0; font-size: 16px; font-weight: 700;
color: var(--text-primary); letter-spacing: -0.01em;
}
.supporter-subtitle {
position: relative; z-index: 1;
margin: 0 0 16px; font-size: 12.5px;
color: var(--text-secondary); line-height: 1.55;
}
.supporter-tiers {
position: relative; z-index: 1;
display: flex; flex-direction: column; gap: 10px;
}
.supporter-tier {
display: flex; align-items: flex-start; gap: 12px;
padding: 10px 12px; border-radius: 14px;
background: var(--bg-card);
border: 1px solid var(--border-primary);
}
.supporter-tier-icon {
width: 38px; height: 38px; border-radius: 11px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
color: #fff;
}
.supporter-tier-body { flex: 1; min-width: 0; }
.supporter-tier-head {
display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
}
.supporter-tier-label {
font-size: 13.5px; font-weight: 700; color: var(--text-primary);
}
.supporter-tier-price {
font-size: 11px; font-weight: 600; color: var(--text-faint);
padding: 1px 7px; border-radius: 6px; background: var(--bg-tertiary);
}
.supporter-tier-chips {
display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px;
}
.supporter-tier-empty {
font-size: 11.5px; font-style: italic; color: var(--text-faint);
}
.supporter-chip {
display: inline-flex; align-items: center; gap: 7px;
padding: 4px 10px; border-radius: 999px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
text-decoration: none;
transition: border-color 0.15s, box-shadow 0.15s;
max-width: 100%;
}
.supporter-chip-name {
font-size: 12px; font-weight: 600; color: var(--text-primary);
white-space: nowrap;
}
.supporter-chip-since {
font-size: 10.5px; font-weight: 500; color: var(--text-faint);
white-space: nowrap;
}
.supporter-chip-since-short { display: none; }
@keyframes supporterShimmer {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
@keyframes supporterGlow {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.75; }
}
@media (max-width: 640px) {
.supporter-card { border-radius: 16px; padding: 16px 14px 14px; }
.supporter-glow { inset: -40px; }
.supporter-header { gap: 8px; }
.supporter-badge { font-size: 10px; padding: 3px 9px; letter-spacing: 0.03em; }
.supporter-title { font-size: 15px; flex-basis: 100%; }
.supporter-subtitle { font-size: 12px; margin-bottom: 14px; }
.supporter-tier { padding: 10px; gap: 10px; border-radius: 12px; }
.supporter-tier-icon { width: 34px; height: 34px; border-radius: 10px; }
.supporter-tier-label { font-size: 13px; }
.supporter-tier-chips { gap: 5px; margin-top: 7px; }
.supporter-chip { padding: 3px 9px; }
.supporter-chip-since { font-size: 10px; }
.supporter-chip-since-full { display: none; }
.supporter-chip-since-short { display: inline; }
}
`}</style>
<div className="supporter-card">
<div className="supporter-glow" />
<div className="supporter-header">
<span className="supporter-badge">{t('settings.about.supporters.badge')}</span>
<h3 className="supporter-title">{t('settings.about.supporters.title')}</h3>
</div>
<p className="supporter-subtitle">{t('settings.about.supporters.subtitle')}</p>
<div className="supporter-tiers">
{SUPPORTER_TIERS.map(tier => {
const members = SUPPORTERS.filter(s => s.tier === tier.id)
const empty = members.length === 0
const TierIcon = tier.icon
return (
<div key={tier.id} className="supporter-tier" style={{ opacity: empty ? 0.55 : 1 }}>
<div className="supporter-tier-icon" style={{ background: tier.gradient, boxShadow: `0 6px 18px ${tier.glow}` }}>
<TierIcon size={18} strokeWidth={2.2} />
</div>
<div className="supporter-tier-body">
<div className="supporter-tier-head">
<span className="supporter-tier-label">{t(tier.labelKey)}</span>
<span className="supporter-tier-price">{tier.price}</span>
</div>
<div className="supporter-tier-chips">
{empty && (
<span className="supporter-tier-empty">
{t('settings.about.supporters.tierEmpty')}
</span>
)}
{members.map(m => {
const chipContent = (
<>
<span className="supporter-chip-name">{m.username}</span>
<span className="supporter-chip-since supporter-chip-since-full">
· {t('settings.about.supporters.since', { date: formatSince(m.since) })}
</span>
<span className="supporter-chip-since supporter-chip-since-short">
· {formatSince(m.since)}
</span>
</>
)
return m.link ? (
<a key={m.username} href={m.link} target="_blank" rel="noopener noreferrer" className="supporter-chip"
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.boxShadow = `0 2px 8px ${tier.glow}` }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
{chipContent}
</a>
) : (
<div key={m.username} className="supporter-chip">{chipContent}</div>
)
})}
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
export default function AboutTab({ appVersion }: Props): React.ReactElement {
const { t } = useTranslation()
const { t, locale } = useTranslation()
return (
<Section title={t('settings.about')} icon={Info}>
@@ -33,7 +254,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
href="https://ko-fi.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -51,7 +272,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
href="https://buymeacoffee.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -69,7 +290,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
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"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -90,7 +311,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -108,7 +329,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -126,7 +347,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
href="https://github.com/mauriceboe/TREK/wiki"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
@@ -141,6 +362,8 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
</div>
<SupporterSection t={t} locale={locale} />
</Section>
)
}
@@ -42,7 +42,7 @@ describe('DisplaySettingsTab', () => {
it('FE-COMP-DISPLAY-005: shows Auto mode button', () => {
render(<DisplaySettingsTab />);
expect(screen.getByText('Auto')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Auto/i })).toBeInTheDocument();
});
it('FE-COMP-DISPLAY-006: shows Language section', () => {
@@ -95,16 +95,16 @@ describe('DisplaySettingsTab', () => {
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'light' }), updateSetting });
render(<DisplaySettingsTab />);
await user.click(screen.getByText('Auto'));
await user.click(screen.getByRole('button', { name: /Auto/i }));
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'auto');
});
it('FE-COMP-DISPLAY-014: active color mode button has border with var(--text-primary)', () => {
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }) });
render(<DisplaySettingsTab />);
const darkBtn = screen.getByText('Dark').closest('button')!;
const lightBtn = screen.getByText('Light').closest('button')!;
const autoBtn = screen.getByText('Auto').closest('button')!;
const darkBtn = screen.getByRole('button', { name: /^Dark$/i });
const lightBtn = screen.getByRole('button', { name: /^Light$/i });
const autoBtn = screen.getByRole('button', { name: /Auto/i });
expect(darkBtn.style.border).toContain('var(--text-primary)');
expect(lightBtn.style.border).toContain('var(--border-primary)');
expect(autoBtn.style.border).toContain('var(--border-primary)');
@@ -122,8 +122,11 @@ describe('DisplaySettingsTab', () => {
it('FE-COMP-DISPLAY-016: active language button is visually highlighted', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) });
render(<DisplaySettingsTab />);
const englishBtn = screen.getByText('English').closest('button')!;
expect(englishBtn.style.border).toContain('var(--text-primary)');
// Multiple elements contain "English" (desktop grid button + mobile dropdown trigger).
// The desktop grid button is the one with the active border style.
const englishMatches = screen.getAllByText('English').map(el => el.closest('button')!).filter(Boolean);
const activeBtn = englishMatches.find(btn => (btn.style.border || '').includes('var(--text-primary)'));
expect(activeBtn).toBeDefined();
});
it('FE-COMP-DISPLAY-017: shows Temperature section label', () => {
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'
import { Palette, Sun, Moon, Monitor } from 'lucide-react'
import React, { useState, useEffect, useRef } from 'react'
import { Palette, Sun, Moon, Monitor, ChevronDown, Check } from 'lucide-react'
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
@@ -10,6 +10,17 @@ export default function DisplaySettingsTab(): React.ReactElement {
const { t } = useTranslation()
const toast = useToast()
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
const [langOpen, setLangOpen] = useState(false)
const langDropdownRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!langOpen) return
const handler = (e: MouseEvent) => {
if (langDropdownRef.current && !langDropdownRef.current.contains(e.target as Node)) setLangOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [langOpen])
useEffect(() => {
setTempUnit(settings.temperature_unit || 'celsius')
@@ -46,8 +57,13 @@ export default function DisplaySettingsTab(): React.ReactElement {
transition: 'all 0.15s',
}}
>
<opt.icon size={16} />
{opt.label}
<span className="hidden sm:inline-flex"><opt.icon size={16} /></span>
{opt.value === 'auto' ? (
<>
<span className="hidden sm:inline">{opt.label}</span>
<span className="sm:hidden">Auto</span>
</>
) : opt.label}
</button>
)
})}
@@ -57,7 +73,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
{/* Language */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
<div className="flex flex-wrap gap-3">
{/* Desktop: Button grid */}
<div className="hidden sm:flex flex-wrap gap-3">
{SUPPORTED_LANGUAGES.map(opt => (
<button
key={opt.value}
@@ -79,6 +96,60 @@ export default function DisplaySettingsTab(): React.ReactElement {
</button>
))}
</div>
{/* Mobile: Custom dropdown */}
<div ref={langDropdownRef} className="sm:hidden" style={{ position: 'relative' }}>
{(() => {
const current = SUPPORTED_LANGUAGES.find(o => o.value === settings.language) || SUPPORTED_LANGUAGES[0]
return (
<button
type="button"
onClick={() => setLangOpen(v => !v)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 14px', borderRadius: 10,
border: '2px solid var(--border-primary)',
background: 'var(--bg-card)', color: 'var(--text-primary)',
fontSize: 14, fontWeight: 500, fontFamily: 'inherit', cursor: 'pointer',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{current?.label}</span>
<ChevronDown size={14} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: langOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
</button>
)
})()}
{langOpen && (
<div style={{
position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, zIndex: 50,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 8px 24px rgba(0,0,0,0.15)', padding: 4, maxHeight: 280, overflowY: 'auto',
}}>
{SUPPORTED_LANGUAGES.map(opt => {
const active = settings.language === opt.value
return (
<button
key={opt.value}
type="button"
onClick={async () => {
setLangOpen(false)
try { await updateSetting('language', opt.value) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '9px 12px', borderRadius: 6, border: 'none', cursor: 'pointer',
background: active ? 'var(--bg-hover)' : 'transparent',
fontFamily: 'inherit', fontSize: 14, color: 'var(--text-primary)',
textAlign: 'left', fontWeight: active ? 600 : 500,
}}
>
<span style={{ flex: 1 }}>{opt.label}</span>
{active && <Check size={14} strokeWidth={2.5} color="var(--accent)" />}
</button>
)
})}
</div>
)}
</div>
</div>
{/* Temperature */}
@@ -172,6 +243,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>
@@ -11,6 +11,7 @@ interface PreferencesMatrix {
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> = {
@@ -233,7 +234,7 @@ export default function NotificationsTab(): React.ReactElement {
type="text"
value={ntfyServer}
onChange={e => setNtfyServer(e.target.value)}
placeholder={t('settings.ntfyUrl.serverPlaceholder')}
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 }}>
@@ -253,7 +254,7 @@ export default function NotificationsTab(): React.ReactElement {
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('settings.ntfyUrl.clearToken')}
{t('common.clear')}
</button>
)}
<button
@@ -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,830 @@
import React, { useState, useEffect, useRef } from 'react';
import { flushSync } from 'react-dom';
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" style={{ flex: '1 1 0', minHeight: '100%' }}>
{/* Dismiss X button — only on last page so users read all notices */}
{notice.dismissible && isLastPage && (
<button
onClick={onDismissAll}
className="absolute top-4 right-4 z-10 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>
)}
{/* Scrollable content — vertically centered when shorter than available space */}
<div className="flex flex-col justify-center" style={{ flex: '1 1 0' }}>
{/* 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'} flex flex-col`}>
{/* 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 */}
<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>
)}
</div>
</div>
{/* Sticky footer — pager + CTA, always anchored at the bottom of the slot */}
<div
className="sticky bottom-0 px-8 pt-4 flex flex-col gap-3 bg-white dark:bg-slate-900 border-t border-slate-100 dark:border-slate-800"
style={{ paddingBottom: 'calc(var(--bottom-nav-h) + 1rem)' }}
>
{/* Pager — dots, arrows, counter (only when multiple notices) */}
{total > 1 && (
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-2">
<button
onClick={onPrev}
disabled={!canPage || currentPage === 0}
aria-label={t('system_notice.pager.prev')}
className="px-2 py-1 rounded border border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 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="px-2 py-1 rounded border border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 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">
{ctaLabel && isLastPage ? (
<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>
) : (notice.dismissible || isLastPage) && (
<button
id={`notice-cta-${notice.id}`}
onClick={isLastPage ? onDismissAll : onNext}
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 touchStartX = useRef<number | null>(null);
const touchStartY = useRef<number | null>(null);
// 'h' once we classify the gesture as horizontal, 'v' for vertical, null = unclassified
const dragLockRef = useRef<'h' | 'v' | null>(null);
// Sheet scroll offset at the moment the touch began — used to suppress dismiss-drag
// when the user is scrolled into content and pans down to scroll back up.
const scrollTopAtTouchStart = useRef(0);
// 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');
// Mobile drag strip — wraps all 3 slots and is translated to reveal prev/current/next
const stripRef = useRef<HTMLDivElement>(null);
// The sheet element itself — animated on vertical drag-to-dismiss
const sheetRef = useRef<HTMLDivElement>(null);
const clipRef = useRef<HTMLDivElement>(null);
// Individual slot scroll containers (prev / center / next)
const prevSlotRef = useRef<HTMLDivElement>(null);
const contentWrapperRef = useRef<HTMLDivElement>(null); // center slot
const nextSlotRef = 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]);
// Reset center slot scroll to top on navigation (keyboard / pager buttons).
useEffect(() => {
if (!isMobile) return;
contentWrapperRef.current?.scrollTo({ top: 0 });
}, [idx, isMobile]);
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();
}
}
function animatedDismissAll() {
const sheet = sheetRef.current;
if (!sheet || prefersReducedMotion) { handleDismissAll(); return; }
sheet.style.transition = 'transform 300ms ease-out';
sheet.style.transform = 'translateY(110%)';
sheet.addEventListener('transitionend', function onDone() {
sheet.removeEventListener('transitionend', onDone);
handleDismissAll();
}, { once: true });
}
// 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');
// Build ContentProps for an adjacent slot so NoticeContent renders correctly
function buildSlotProps(n: SystemNoticeDTO, slotIdx: number): ContentProps {
const slotRawBody = t(n.bodyKey);
const slotBody = n.bodyParams
? Object.entries(n.bodyParams).reduce(
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v),
slotRawBody
)
: slotRawBody;
return {
notice: n,
title: t(n.titleKey),
body: slotBody,
ctaLabel: n.cta ? t(n.cta.labelKey) : null,
titleId: `notice-title-${n.id}`,
bodyId: `notice-body-${n.id}`,
isDark,
onDismiss: handleDismiss,
onDismissAll: handleDismissAll,
onCTA: handleCTA,
total: notices.length,
currentPage: slotIdx,
canPage,
onPrev: handlePrev,
onNext: handleNext,
onGoto: handleGoto,
};
}
const prevNotice = notices[idx - 1] ?? null;
const nextNotice = notices[idx + 1] ?? null;
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 ? animatedDismissAll : undefined}
/>
{/* Bottom sheet */}
<div
ref={sheetRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={bodyId}
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden h-[85dvh] flex flex-col bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-[opacity,transform] ${dur} ${ease} ${mobileMotion}`}
style={{ touchAction: 'pan-y' }}
onTouchStart={e => {
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
dragLockRef.current = null;
scrollTopAtTouchStart.current = contentWrapperRef.current?.scrollTop ?? 0;
}}
onTouchMove={e => {
if (prefersReducedMotion) return;
const startX = touchStartX.current;
const startY = touchStartY.current;
if (startX === null || startY === null) return;
const dx = e.touches[0].clientX - startX;
const dy = e.touches[0].clientY - startY;
// Classify gesture direction on first significant movement
if (!dragLockRef.current) {
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
dragLockRef.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v';
// Reset adjacent slots to top before they slide into view.
if (dragLockRef.current === 'h') {
prevSlotRef.current?.scrollTo({ top: 0 });
nextSlotRef.current?.scrollTo({ top: 0 });
}
}
return;
}
if (dragLockRef.current === 'h') {
const strip = stripRef.current;
if (!strip) return;
strip.style.transition = 'none';
// Strip base = -33.333% (center slot visible); dx offsets from there
strip.style.transform = `translateX(calc(-33.333% + ${dx}px))`;
} else if (dragLockRef.current === 'v' && notice.dismissible) {
// Only intercept downward drag for dismiss when the sheet is scrolled to the top.
// If scrolled into content, let native pan-y scroll it back up.
if (scrollTopAtTouchStart.current > 0) return;
const sheet = sheetRef.current;
if (!sheet || dy <= 0) return;
sheet.style.transition = 'none';
sheet.style.transform = `translateY(${dy}px)`;
}
}}
onTouchEnd={e => {
const startX = touchStartX.current;
const startY = touchStartY.current;
touchStartX.current = null;
touchStartY.current = null;
const lock = dragLockRef.current;
dragLockRef.current = null;
if (lock === 'h') {
if (startX === null) return;
const deltaX = e.changedTouches[0].clientX - startX;
const strip = stripRef.current;
if (!strip) return;
const goNext = isRtlLanguage(language) ? deltaX > 50 : deltaX < -50;
const goPrev = isRtlLanguage(language) ? deltaX < -50 : deltaX > 50;
const canGoNext = canPage && idx < notices.length - 1;
const canGoPrev = canPage && idx > 0;
if ((goNext && canGoNext) || (goPrev && canGoPrev)) {
// Animate strip to the adjacent slot (-66.666% = next, 0% = prev)
strip.style.transition = 'transform 200ms ease-out';
strip.style.transform = goNext ? 'translateX(-66.666%)' : 'translateX(0%)';
strip.addEventListener('transitionend', function onDone() {
strip.removeEventListener('transitionend', onDone);
strip.style.transition = 'none';
// Render new content into the center slot BEFORE moving the strip,
// so the browser never paints old content at the center position.
const newIdx = goNext ? idx + 1 : idx - 1;
flushSync(() => {
isPageNavRef.current = true;
setIdx(newIdx);
announceIndex(newIdx, notices.length);
});
// Reset all slot scrolls so the new center starts at top.
prevSlotRef.current?.scrollTo({ top: 0 });
contentWrapperRef.current?.scrollTo({ top: 0 });
nextSlotRef.current?.scrollTo({ top: 0 });
strip.style.transform = 'translateX(-33.333%)';
}, { once: true });
} else {
// Spring back to center
strip.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
strip.style.transform = 'translateX(-33.333%)';
strip.addEventListener('transitionend', function onSnap() {
strip.removeEventListener('transitionend', onSnap);
strip.style.transition = '';
strip.style.transform = 'translateX(-33.333%)';
}, { once: true });
}
return;
}
// Vertical drag — animated dismiss or spring back (only when at scroll top)
if (lock === 'v' && startY !== null && scrollTopAtTouchStart.current === 0) {
const deltaY = e.changedTouches[0].clientY - startY;
const sheet = sheetRef.current;
if (deltaY > 80 && notice.dismissible) {
animatedDismissAll();
} else if (sheet && deltaY > 0) {
sheet.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
sheet.style.transform = 'translateY(0)';
sheet.addEventListener('transitionend', function onSnap() {
sheet.removeEventListener('transitionend', onSnap);
sheet.style.transition = '';
sheet.style.transform = '';
}, { once: true });
}
}
}}
>
{/* Drag handle — fixed, does not scroll */}
<div className="pt-3 pb-1 flex justify-center shrink-0">
<div className="w-9 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
</div>
{/* Clip container — fills remaining sheet height, hides adjacent slots */}
<div style={{ flex: '1 1 0', minHeight: 0, overflow: 'hidden', width: '100%' }}>
{/* 3-slot strip: [prev][current][next] — starts at -33.333% to show current */}
<div
ref={stripRef}
style={{ display: 'flex', width: '300%', height: '100%', alignItems: 'stretch', transform: 'translateX(-33.333%)' }}
>
<div ref={prevSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{prevNotice && <NoticeContent {...buildSlotProps(prevNotice, idx - 1)} />}
</div>
<div ref={contentWrapperRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<NoticeContent {...contentProps} />
</div>
<div ref={nextSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{nextNotice && <NoticeContent {...buildSlotProps(nextNotice, idx + 1)} />}
</div>
</div>
</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);
}
@@ -37,9 +37,10 @@ describe('TodoListPanel', () => {
expect(screen.getByText('Buy tickets')).toBeInTheDocument();
});
it('FE-COMP-TODO-002: shows Add new task button', () => {
render(<TodoListPanel tripId={1} items={[]} />);
expect(screen.getByText('Add new task...')).toBeInTheDocument();
it('FE-COMP-TODO-002: raising addItemSignal opens the new task form', async () => {
const { rerender } = render(<TodoListPanel tripId={1} items={[]} addItemSignal={0} />);
rerender(<TodoListPanel tripId={1} items={[]} addItemSignal={1} />);
await screen.findByText('Create task');
});
it('FE-COMP-TODO-003: sidebar filter buttons are rendered', () => {
@@ -119,11 +120,9 @@ describe('TodoListPanel', () => {
expect(screen.getByText(/1 \/ 2 completed/i)).toBeInTheDocument();
});
it('FE-COMP-TODO-011: clicking Add new task opens detail form', async () => {
const user = userEvent.setup();
render(<TodoListPanel tripId={1} items={[]} />);
await user.click(screen.getByText('Add new task...'));
// The detail pane shows "Create task" button
it('FE-COMP-TODO-011: raising addItemSignal opens detail form with Create task button', async () => {
const { rerender } = render(<TodoListPanel tripId={1} items={[]} addItemSignal={0} />);
rerender(<TodoListPanel tripId={1} items={[]} addItemSignal={1} />);
await screen.findByText('Create task');
});
@@ -398,15 +397,12 @@ describe('TodoListPanel', () => {
return HttpResponse.json({ item: buildTodoItem({ id: 99, name: 'Brand New Task' }) });
}),
);
render(<TodoListPanel tripId={1} items={[]} />);
// Open the new task pane
await user.click(screen.getByText('Add new task...'));
// Wait for "Create task" button to appear
const { rerender } = render(<TodoListPanel tripId={1} items={[]} addItemSignal={0} />);
// Raising the signal opens the new task pane (simulates the toolbar button click)
rerender(<TodoListPanel tripId={1} items={[]} addItemSignal={1} />);
await screen.findByText('Create task');
// Type a task name in the autoFocus input (Task name placeholder)
const nameInput = screen.getByPlaceholderText('Task name');
await user.type(nameInput, 'Brand New Task');
// Click the Create task button
await user.click(screen.getByText('Create task'));
await waitFor(() => expect(postCalled).toBe(true));
});
+90 -56
View File
@@ -1,4 +1,5 @@
import { useState, useMemo, useEffect } from 'react'
import { useState, useMemo, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
@@ -37,7 +38,7 @@ type FilterType = 'all' | 'my' | 'overdue' | 'done' | string
interface Member { id: number; username: string; avatar: string | null }
export default function TodoListPanel({ tripId, items }: { tripId: number; items: TodoItem[] }) {
export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tripId: number; items: TodoItem[]; addItemSignal?: number }) {
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore()
const canEdit = useCanDo('packing_edit')
const toast = useToast()
@@ -55,6 +56,15 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
const [filter, setFilter] = useState<FilterType>('all')
const [selectedId, setSelectedId] = useState<number | null>(null)
const [isAddingNew, setIsAddingNew] = useState(false)
const lastHandledAddSignal = useRef(addItemSignal)
useEffect(() => {
if (addItemSignal !== lastHandledAddSignal.current && addItemSignal > 0) {
setSelectedId(null)
setIsAddingNew(true)
}
lastHandledAddSignal.current = addItemSignal
}, [addItemSignal])
const [sortByPrio, setSortByPrio] = useState(false)
const [addingCategory, setAddingCategory] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
@@ -160,12 +170,12 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
{/* ── Left Sidebar ── */}
<div style={{
width: isMobile ? 52 : 220, flexShrink: 0, borderRight: '1px solid var(--border-faint)',
padding: isMobile ? '12px 6px' : '16px 10px', display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto',
padding: isMobile ? '12px 6px' : '16px 12px 16px 0', display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto',
transition: 'width 0.2s',
}}>
{/* Progress Card */}
{!isMobile && <div style={{
margin: '0 6px 12px', padding: '14px 14px 12px', borderRadius: 14,
margin: '0 0 12px', padding: '14px 14px 12px', borderRadius: 14,
background: 'var(--bg-hover)',
border: '1px solid var(--border-primary)',
boxShadow: '0 1px 2px rgba(0,0,0,0.02)',
@@ -192,9 +202,12 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
<SidebarItem id="overdue" icon={AlertCircle} label={t('todo.filter.overdue')} count={overdueCount} />
<SidebarItem id="done" icon={CheckCheck} label={t('todo.filter.done')} count={doneCount} />
{/* Sort by priority */}
{/* Sort by */}
{!isMobile && <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', padding: '16px 12px 4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t('todo.sidebar.sortBy')}
</div>}
<button onClick={() => setSortByPrio(v => !v)}
title={isMobile ? t('todo.sortByPrio') : undefined}
title={isMobile ? t('todo.priority') : undefined}
style={{
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
gap: isMobile ? 0 : 8, width: '100%', padding: isMobile ? '8px 0' : '7px 12px',
@@ -206,7 +219,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
onMouseEnter={e => { if (!sortByPrio) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!sortByPrio) e.currentTarget.style.background = 'transparent' }}>
<Flag size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} />
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.sortByPrio')}</span>}
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.priority')}</span>}
</button>
{/* Categories */}
@@ -251,27 +264,6 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
</div>
</div>
{/* Add task */}
{canEdit && (
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border-faint)' }}>
<button
onClick={() => { setSelectedId(null); setIsAddingNew(true) }}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
width: '100%', padding: '9px 16px', borderRadius: 8,
background: isAddingNew ? 'var(--text-primary)' : 'var(--bg-hover)',
color: isAddingNew ? 'var(--bg-primary)' : 'var(--text-primary)',
border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 13, fontWeight: 600, transition: 'all 0.15s',
}}
onMouseEnter={e => { if (!isAddingNew) { e.currentTarget.style.background = 'var(--text-primary)'; e.currentTarget.style.color = 'var(--bg-primary)'; e.currentTarget.style.borderColor = 'var(--text-primary)' } }}
onMouseLeave={e => { if (!isAddingNew) { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-primary)'; e.currentTarget.style.borderColor = 'var(--border-primary)' } }}>
<Plus size={14} />
{t('todo.addItem')}
</button>
</div>
)}
{/* Task list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
{filtered.length === 0 ? null : (
@@ -394,7 +386,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
)}
{selectedItem && !isAddingNew && isMobile && (
<div onClick={e => { if (e.target === e.currentTarget) setSelectedId(null) }}
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
<DetailPane
@@ -407,19 +399,28 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
</div>
</div>
)}
{isAddingNew && !selectedItem && !isMobile && (
<NewTaskPane
tripId={tripId}
categories={categories}
members={members}
defaultCategory={typeof filter === 'string' && categories.includes(filter) ? filter : null}
onCreated={(id) => { setIsAddingNew(false); setSelectedId(id) }}
onClose={() => setIsAddingNew(false)}
/>
)}
{isAddingNew && !selectedItem && isMobile && (
{isAddingNew && !selectedItem && !isMobile && ReactDOM.createPortal(
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
className="modal-backdrop"
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(15,23,42,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'flex-start', paddingTop: 'calc(var(--nav-h) + 60px)', paddingBottom: 40 }}>
<div style={{ width: 'min(520px, 92vw)', maxHeight: 'calc(100vh - var(--nav-h) - 120px)', overflow: 'auto', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.25)' }}
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px' } } }}>
<NewTaskPane
tripId={tripId}
categories={categories}
members={members}
defaultCategory={typeof filter === 'string' && categories.includes(filter) ? filter : null}
onCreated={(id) => { setIsAddingNew(false); setSelectedId(id) }}
onClose={() => setIsAddingNew(false)}
/>
</div>
</div>,
document.body
)}
{isAddingNew && !selectedItem && isMobile && ReactDOM.createPortal(
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
className="modal-backdrop"
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
<NewTaskPane
@@ -431,7 +432,8 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
onClose={() => setIsAddingNew(false)}
/>
</div>
</div>
</div>,
document.body
)}
</div>
)
@@ -647,6 +649,7 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
const [desc, setDesc] = useState('')
const [dueDate, setDueDate] = useState('')
const [category, setCategory] = useState(defaultCategory || '')
const [addingCategory, setAddingCategoryInline] = useState(false)
const [assignedUserId, setAssignedUserId] = useState<number | null>(null)
const [priority, setPriority] = useState(0)
const [saving, setSaving] = useState(false)
@@ -657,9 +660,10 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
if (!name.trim()) return
setSaving(true)
try {
const trimmedCategory = category.trim()
const item = await addTodoItem(tripId, {
name: name.trim(), description: desc || null, priority,
due_date: dueDate || null, category: category || null,
due_date: dueDate || null, category: trimmedCategory || null,
assigned_user_id: assignedUserId,
} as any)
if (item?.id) onCreated(item.id)
@@ -696,19 +700,49 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
<div>
<label style={labelStyle}>{t('todo.detail.category')}</label>
<CustomSelect
value={category}
onChange={v => setCategory(v)}
options={[
{ value: '', label: t('todo.noCategory') },
...categories.map(c => ({
value: c, label: c,
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
})),
]}
placeholder={t('todo.noCategory')}
size="sm"
/>
{addingCategory ? (
<div style={{ display: 'flex', gap: 4 }}>
<input
autoFocus
value={category}
onChange={e => setCategory(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') setAddingCategoryInline(false); if (e.key === 'Escape') { setCategory(''); setAddingCategoryInline(false) } }}
placeholder={t('todo.newCategory')}
style={{ flex: 1, fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
/>
<button type="button" onClick={() => setAddingCategoryInline(false)}
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-primary)' }}>
<Check size={14} />
</button>
</div>
) : (
<div style={{ display: 'flex', gap: 4 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<CustomSelect
value={category}
onChange={v => setCategory(v)}
options={[
{ value: '', label: t('todo.noCategory') },
...categories.map(c => ({
value: c, label: c,
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
})),
...(category && !categories.includes(category) ? [{
value: category, label: `${category} (${t('todo.newCategoryLabel') || 'new'})`,
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#9ca3af', display: 'inline-block' }} />,
}] : []),
]}
placeholder={t('todo.noCategory')}
size="sm"
/>
</div>
<button type="button" onClick={() => { setCategory(''); setAddingCategoryInline(true) }}
title={t('todo.newCategory')}
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>
<Plus size={14} />
</button>
</div>
)}
</div>
<div>
@@ -94,7 +94,7 @@ export default function VacayCalendar() {
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
<button
onClick={() => setCompanyMode(false)}
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-all"
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-[background-color,color,border-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{
background: !companyMode ? 'var(--text-primary)' : 'transparent',
color: !companyMode ? 'var(--bg-card)' : 'var(--text-muted)',
@@ -107,7 +107,7 @@ export default function VacayCalendar() {
{companyHolidaysEnabled && (
<button
onClick={() => setCompanyMode(true)}
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-all"
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-[background-color,color,border-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{
background: companyMode ? '#d97706' : 'transparent',
color: companyMode ? '#fff' : 'var(--text-muted)',
+5 -5
View File
@@ -121,9 +121,9 @@ export default function VacayPersons() {
{/* Invite Modal — Portal to body to avoid z-index issues */}
{showInvite && ReactDOM.createPortal(
<div className="fixed inset-0 flex items-center justify-center px-4" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
<div className="fixed inset-0 flex items-center justify-center px-4 trek-backdrop-enter" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
onClick={() => setShowInvite(false)}>
<div className="rounded-2xl shadow-2xl w-full max-w-sm" style={{ background: 'var(--bg-card)', animation: 'modalIn 0.2s ease-out' }}
<div className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm" style={{ background: 'var(--bg-card)' }}
onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.inviteUser')}</h2>
@@ -164,9 +164,9 @@ export default function VacayPersons() {
{/* Color Picker Modal — Portal to body */}
{showColorPicker && ReactDOM.createPortal(
<div className="fixed inset-0 flex items-center justify-center px-4" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
<div className="fixed inset-0 flex items-center justify-center px-4 trek-backdrop-enter" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
onClick={() => { setShowColorPicker(false); setColorEditUserId(null) }}>
<div className="rounded-2xl shadow-2xl w-full max-w-xs" style={{ background: 'var(--bg-card)', animation: 'modalIn 0.2s ease-out' }}
<div className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-xs" style={{ background: 'var(--bg-card)' }}
onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.changeColor')}</h2>
@@ -178,7 +178,7 @@ export default function VacayPersons() {
<div className="flex flex-wrap gap-2 justify-center">
{PRESET_COLORS.map(c => (
<button key={c} onClick={() => handleColorChange(c)}
className={`w-8 h-8 rounded-full transition-all ${editingUserColor === c ? 'ring-2 ring-offset-2 scale-110' : 'hover:scale-110'}`}
className={`w-8 h-8 rounded-full transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${editingUserColor === c ? 'ring-2 ring-offset-2 scale-110' : 'hover:scale-110'}`}
style={{ backgroundColor: c }} />
))}
</div>
+4 -1
View File
@@ -87,7 +87,10 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP
<span className="text-[10px] tabular-nums" style={{ color: 'var(--text-faint)' }}>{s.used}/{s.total_available}</span>
</div>
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--bg-secondary)' }}>
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${pct}%`, backgroundColor: s.person_color }} />
<div
className="trek-bar-fill h-full rounded-full transition-[width] duration-500 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ width: `${pct}%`, backgroundColor: s.person_color }}
/>
</div>
<div className="grid grid-cols-3 gap-1.5">
{/* Days — editable */}
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind } from 'lucide-react'
import { weatherApi } from '../../api/client'
import { fetchWeather } from '../../services/weatherQueue'
import { useSettingsStore } from '../../store/settingsStore'
const WEATHER_ICON_MAP = {
@@ -61,7 +61,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false }: Weath
// Climate data: use from cache but re-fetch in background to upgrade to forecast
else if (cached.type === 'climate') {
setWeather(cached)
weatherApi.get(lat, lng, date)
fetchWeather(lat, lng, date)
.then(data => {
if (!data.error && data.temp !== undefined && data.type === 'forecast') {
setWeatherCache(cacheKey, data)
@@ -77,7 +77,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false }: Weath
return
}
setLoading(true)
weatherApi.get(lat, lng, date)
fetchWeather(lat, lng, date)
.then(data => {
if (data.error || data.temp === undefined) {
setFailed(true)
+3 -12
View File
@@ -40,16 +40,13 @@ export default function ConfirmDialog({
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center px-4"
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
onClick={onClose}
>
<div
className="rounded-2xl shadow-2xl w-full max-w-sm p-6"
style={{
animation: 'modalIn 0.2s ease-out forwards',
background: 'var(--bg-card)',
}}
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm p-6"
style={{ background: 'var(--bg-card)' }}
onClick={e => e.stopPropagation()}
>
<div className="flex items-start gap-4">
@@ -90,12 +87,6 @@ export default function ConfirmDialog({
</div>
</div>
<style>{`
@keyframes modalIn {
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
`}</style>
</div>
)
}
+2 -3
View File
@@ -65,7 +65,7 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
if (!menu) return null
return ReactDOM.createPortal(
<div ref={ref} style={{
<div ref={ref} className="trek-popover-enter" style={{
position: 'fixed', left: menu.x, top: menu.y, zIndex: 999999,
background: 'var(--bg-card)', borderRadius: 10, padding: '4px',
border: '1px solid var(--border-primary)',
@@ -73,7 +73,7 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
minWidth: 160,
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
animation: 'ctxIn 0.1s ease-out',
transformOrigin: 'top left',
}}>
{menu.items.filter(Boolean).map((item, i) => {
if (item.divider) return <div key={i} style={{ height: 1, background: 'var(--border-faint)', margin: '3px 6px' }} />
@@ -95,7 +95,6 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
</button>
)
})}
<style>{`@keyframes ctxIn { from { opacity: 0; transform: scale(0.95) } to { opacity: 1; transform: scale(1) } }`}</style>
</div>,
document.body
)
@@ -0,0 +1,65 @@
import React, { useCallback, useState } from 'react'
import { Copy, Check } from 'lucide-react'
interface CopyButtonProps {
value: string
size?: number
title?: string
className?: string
onCopy?: () => void
}
// Button that morphs between copy icon and check icon for 1.5s after click.
export function CopyButton({ value, size = 14, title, className, onCopy }: CopyButtonProps): React.ReactElement {
const [copied, setCopied] = useState(false)
const handleClick = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation()
try {
await navigator.clipboard.writeText(value)
setCopied(true)
onCopy?.()
window.setTimeout(() => setCopied(false), 1500)
} catch {
// noop
}
}, [value, onCopy])
return (
<button
type="button"
onClick={handleClick}
title={title}
className={className}
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: size + 12,
height: size + 12,
border: 'none',
background: 'transparent',
color: copied ? '#22c55e' : 'var(--text-muted)',
cursor: 'pointer',
borderRadius: 6,
}}
>
<Copy size={size} style={{
position: 'absolute',
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 200ms cubic-bezier(0.23,1,0.32,1)',
opacity: copied ? 0 : 1,
transform: copied ? 'scale(0.6) rotate(-45deg)' : 'scale(1) rotate(0)',
}} />
<Check size={size} style={{
position: 'absolute',
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 200ms cubic-bezier(0.23,1,0.32,1)',
opacity: copied ? 1 : 0,
transform: copied ? 'scale(1) rotate(0)' : 'scale(0.6) rotate(45deg)',
strokeWidth: 2.5,
}} />
</button>
)
}
export default CopyButton
@@ -104,7 +104,7 @@ export default function CustomSelect({
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: selected ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{selected ? selected.label : placeholder}
</span>
<ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'none' }} />
<ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 200ms cubic-bezier(0.23,1,0.32,1)', transform: open ? 'rotate(180deg)' : 'none' }} />
</button>
{/* Dropdown */}
@@ -128,7 +128,9 @@ export default function CustomSelect({
borderRadius: 10,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
overflow: 'hidden',
animation: 'selectIn 0.15s ease-out',
animation: 'trek-menu-enter 200ms cubic-bezier(0.23, 1, 0.32, 1)',
transformOrigin: 'top center',
willChange: 'transform, opacity',
}}>
{/* Search */}
{searchable && (
@@ -194,12 +196,6 @@ export default function CustomSelect({
document.body
)}
<style>{`
@keyframes selectIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
</div>
)
}
@@ -0,0 +1,36 @@
import React, { useState, type ImgHTMLAttributes } from 'react'
interface LoadingImageProps extends ImgHTMLAttributes<HTMLImageElement> {
containerClassName?: string
containerStyle?: React.CSSProperties
}
// Image with shimmer-placeholder until loaded. Drops the shimmer once native load fires.
export function LoadingImage({
containerClassName, containerStyle, className, style, onLoad, ...imgProps
}: LoadingImageProps): React.ReactElement {
const [loaded, setLoaded] = useState(false)
return (
<div className={containerClassName} style={{ position: 'relative', overflow: 'hidden', ...containerStyle }}>
{!loaded && (
<div
className="trek-skeleton"
style={{ position: 'absolute', inset: 0, borderRadius: 0 }}
aria-hidden
/>
)}
<img
{...imgProps}
className={className}
style={{
...style,
opacity: loaded ? 1 : 0,
transition: 'opacity 300ms cubic-bezier(0.23, 1, 0.32, 1)',
}}
onLoad={e => { setLoaded(true); onLoad?.(e) }}
/>
</div>
)
}
export default LoadingImage
+4 -13
View File
@@ -50,8 +50,8 @@ export default function Modal({
return (
<div
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20, overflow: 'hidden' }}
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop trek-backdrop-enter"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
@@ -60,14 +60,11 @@ export default function Modal({
>
<div
className={`
trek-modal-enter
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
animate-in fade-in zoom-in-95 duration-200
`}
style={{
animation: 'modalIn 0.2s ease-out forwards',
background: 'var(--bg-card)',
}}
style={{ background: 'var(--bg-card)' }}
onClick={e => e.stopPropagation()}
>
{/* Header */}
@@ -96,12 +93,6 @@ export default function Modal({
)}
</div>
<style>{`
@keyframes modalIn {
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
`}</style>
</div>
)
}
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react'
import { getCategoryIcon } from './categoryIcons'
import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService'
import { useAuthStore } from '../../store/authStore'
import type { Place } from '../../types'
interface Category {
@@ -18,10 +19,12 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
const [visible, setVisible] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
// Observe visibility — fetch photo only when avatar enters viewport
useEffect(() => {
if (place.image_url) { setVisible(true); return }
if (!placesPhotosEnabled) return
const el = ref.current
if (!el) return
// Check if already cached — show immediately without waiting for intersection
@@ -37,6 +40,7 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
useEffect(() => {
if (!visible) return
if (place.image_url) { setPhotoSrc(place.image_url); return }
if (!placesPhotosEnabled) return
const photoId = place.google_place_id || place.osm_id
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
+70
View File
@@ -0,0 +1,70 @@
import React from 'react'
// Simple skeleton placeholder with shimmer. Size via className or props.
export function Skeleton({
width, height, radius, className, style,
}: {
width?: number | string
height?: number | string
radius?: number | string
className?: string
style?: React.CSSProperties
}): React.ReactElement {
return (
<div
className={`trek-skeleton ${className ?? ''}`.trim()}
style={{
width,
height: height ?? 14,
borderRadius: radius,
...style,
}}
aria-hidden
/>
)
}
// Trip card skeleton matching SpotlightCard layout
export function SpotlightSkeleton(): React.ReactElement {
return (
<div
className="relative rounded-3xl overflow-hidden mb-8"
style={{ minHeight: 340, background: 'var(--bg-tertiary)' }}
>
<div className="trek-skeleton absolute inset-0" style={{ borderRadius: 24 }} />
<div className="relative p-6 flex flex-col justify-end" style={{ minHeight: 340 }}>
<Skeleton width={160} height={40} radius={8} style={{ marginBottom: 8 }} />
<Skeleton width={220} height={16} radius={4} />
</div>
</div>
)
}
// Trip list item skeleton
export function TripCardSkeleton(): React.ReactElement {
return (
<div
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden"
style={{ background: 'var(--bg-card)' }}
>
<Skeleton height={140} radius={0} />
<div className="p-4 flex flex-col gap-2">
<Skeleton width="60%" height={18} />
<Skeleton width="40%" height={12} />
</div>
</div>
)
}
// Day sidebar skeleton row
export function DaySkeleton(): React.ReactElement {
return (
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<Skeleton width={120} height={16} />
<Skeleton width="80%" height={12} />
<Skeleton width="60%" height={12} />
</div>
)
}
export default Skeleton
@@ -0,0 +1,126 @@
import React, { useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
export interface SlidingTab<T extends string> {
id: T
label: React.ReactNode
title?: string
icon?: React.ComponentType<{ size?: number; className?: string }>
count?: number
}
interface SlidingTabsProps<T extends string> {
tabs: readonly SlidingTab<T>[]
activeTab: T
onChange: (id: T) => void
size?: 'sm' | 'md'
fullWidth?: boolean
className?: string
indicatorColor?: string
indicatorTextColor?: string
}
// Stripe-style sliding indicator — der aktive Pill gleitet zwischen Tabs.
// Nutzt gemessene Offsets der Buttons + CSS transform.
export function SlidingTabs<T extends string>({
tabs, activeTab, onChange, size = 'md', fullWidth, className,
indicatorColor = 'var(--accent)', indicatorTextColor = 'var(--accent-text)',
}: SlidingTabsProps<T>): React.ReactElement {
const containerRef = useRef<HTMLDivElement>(null)
const tabRefs = useRef<Map<T, HTMLButtonElement | null>>(new Map())
const [indicator, setIndicator] = useState<{ left: number; width: number; ready: boolean }>({ left: 0, width: 0, ready: false })
useLayoutEffect(() => {
const active = tabRefs.current.get(activeTab)
const container = containerRef.current
if (!active || !container) return
const containerRect = container.getBoundingClientRect()
const activeRect = active.getBoundingClientRect()
setIndicator({
left: activeRect.left - containerRect.left + container.scrollLeft,
width: activeRect.width,
ready: true,
})
}, [activeTab, tabs.length])
const padding = size === 'sm' ? '5px 12px' : '6px 14px'
const fontSize = size === 'sm' ? 12 : 13
const borderRadius = size === 'sm' ? 18 : 20
return (
<div
ref={containerRef}
className={className}
style={{
position: 'relative', display: 'flex', alignItems: 'center',
gap: 2, overflowX: 'auto', scrollbarWidth: 'none', msOverflowStyle: 'none',
width: fullWidth ? '100%' : undefined,
}}
>
{/* Sliding indicator */}
{indicator.ready && (
<div
aria-hidden
style={{
position: 'absolute',
top: '50%',
left: indicator.left,
width: indicator.width,
height: size === 'sm' ? 26 : 30,
background: indicatorColor,
borderRadius,
transform: 'translateY(-50%)',
transition: 'left 320ms cubic-bezier(0.77, 0, 0.175, 1), width 320ms cubic-bezier(0.77, 0, 0.175, 1)',
pointerEvents: 'none',
zIndex: 0,
willChange: 'left, width',
}}
/>
)}
{tabs.map(tab => {
const isActive = tab.id === activeTab
const Icon = tab.icon
const btnStyle: CSSProperties = {
position: 'relative', zIndex: 1,
flexShrink: 0,
padding,
borderRadius,
border: 'none',
cursor: 'pointer',
fontSize,
fontWeight: isActive ? 600 : 500,
background: 'transparent',
color: isActive ? indicatorTextColor : 'var(--text-muted)',
fontFamily: 'inherit',
transition: 'color 220ms cubic-bezier(0.23, 1, 0.32, 1)',
display: 'flex', alignItems: 'center', gap: 6,
flex: fullWidth ? 1 : undefined,
justifyContent: 'center',
whiteSpace: 'nowrap',
}
return (
<button
key={tab.id}
ref={el => { tabRefs.current.set(tab.id, el) }}
onClick={() => onChange(tab.id)}
style={btnStyle}
title={tab.title ?? (typeof tab.label === 'string' ? tab.label : undefined)}
>
{Icon && <Icon size={size === 'sm' ? 13 : 15} />}
{tab.label}
{tab.count != null && (
<span style={{
fontSize: 10, fontWeight: 600,
padding: '1px 6px', borderRadius: 99, minWidth: 16,
background: isActive ? 'rgba(255,255,255,0.22)' : 'var(--bg-tertiary)',
color: isActive ? 'inherit' : 'var(--text-faint)',
textAlign: 'center',
}}>{tab.count}</span>
)}
</button>
)
})}
</div>
)
}
export default SlidingTabs
+100
View File
@@ -0,0 +1,100 @@
import React, { useState, useRef, useEffect } from 'react'
import ReactDOM from 'react-dom'
type Placement = 'top' | 'bottom' | 'left' | 'right'
interface TooltipProps {
label: string
placement?: Placement
delay?: number
disabled?: boolean
children: React.ReactElement
}
export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, children }: TooltipProps) {
const [open, setOpen] = useState(false)
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
const triggerRef = useRef<HTMLElement | null>(null)
const tooltipRef = useRef<HTMLDivElement | null>(null)
const timerRef = useRef<number | null>(null)
const show = () => {
if (disabled || !label) return
if (timerRef.current) window.clearTimeout(timerRef.current)
timerRef.current = window.setTimeout(() => setOpen(true), delay)
}
const hide = () => {
if (timerRef.current) window.clearTimeout(timerRef.current)
setOpen(false)
}
useEffect(() => () => { if (timerRef.current) window.clearTimeout(timerRef.current) }, [])
useEffect(() => {
if (!open || !triggerRef.current) return
const r = triggerRef.current.getBoundingClientRect()
const tipW = tooltipRef.current?.offsetWidth ?? 0
const tipH = tooltipRef.current?.offsetHeight ?? 0
const gap = 6
let top = 0, left = 0
if (placement === 'top') { top = r.top - tipH - gap; left = r.left + r.width / 2 - tipW / 2 }
else if (placement === 'bottom') { top = r.bottom + gap; left = r.left + r.width / 2 - tipW / 2 }
else if (placement === 'left') { top = r.top + r.height / 2 - tipH / 2; left = r.left - tipW - gap }
else { top = r.top + r.height / 2 - tipH / 2; left = r.right + gap }
const pad = 6
left = Math.max(pad, Math.min(left, window.innerWidth - tipW - pad))
top = Math.max(pad, Math.min(top, window.innerHeight - tipH - pad))
setCoords({ top, left })
}, [open, placement, label])
const child = React.Children.only(children)
const trigger = React.cloneElement(child, {
ref: (node: HTMLElement | null) => {
triggerRef.current = node
const r = (child as any).ref
if (typeof r === 'function') r(node)
else if (r && typeof r === 'object') r.current = node
},
onMouseEnter: (e: any) => { show(); child.props.onMouseEnter?.(e) },
onMouseLeave: (e: any) => { hide(); child.props.onMouseLeave?.(e) },
onFocus: (e: any) => { show(); child.props.onFocus?.(e) },
onBlur: (e: any) => { hide(); child.props.onBlur?.(e) },
})
return (
<>
{trigger}
{open && ReactDOM.createPortal(
<div
ref={tooltipRef}
role="tooltip"
className="trek-popover-enter"
style={{
position: 'fixed',
top: coords?.top ?? -9999,
left: coords?.left ?? -9999,
visibility: coords ? 'visible' : 'hidden',
pointerEvents: 'none',
zIndex: 100000,
background: 'var(--bg-card, #ffffff)',
color: 'var(--text-primary, #111827)',
fontSize: 11,
fontWeight: 500,
padding: '5px 10px',
borderRadius: 8,
whiteSpace: 'nowrap',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
border: '1px solid var(--border-faint, #e5e7eb)',
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
transformOrigin: placement === 'top' ? 'bottom center' : placement === 'bottom' ? 'top center' : placement === 'left' ? 'center right' : 'center left',
}}
>
{label}
</div>,
document.body,
)}
</>
)
}
export default Tooltip
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useRef, useState } from 'react'
// Zählt beim Mount von 0 auf target hoch. Feste Dauer mit ease-out-quint.
export function useCountUp(target: number, duration = 800): number {
const [value, setValue] = useState(0)
const startRef = useRef<number | null>(null)
const frameRef = useRef<number | null>(null)
useEffect(() => {
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false
const isJsdom = typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent ?? '')
if (reduced || isJsdom || target <= 0) { setValue(target); return }
startRef.current = null
const step = (now: number) => {
if (startRef.current == null) startRef.current = now
const elapsed = now - startRef.current
const t = Math.min(elapsed / duration, 1)
// ease-out-quint
const eased = 1 - Math.pow(1 - t, 5)
setValue(Math.round(target * eased))
if (t < 1) frameRef.current = requestAnimationFrame(step)
}
frameRef.current = requestAnimationFrame(step)
return () => { if (frameRef.current) cancelAnimationFrame(frameRef.current) }
}, [target, duration])
return value
}
+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
}
+86 -13
View File
@@ -1,50 +1,123 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useSettingsStore } from '../store/settingsStore'
import { useTripStore } from '../store/tripStore'
import { calculateSegments } from '../components/Map/RouteCalculator'
import type { TripStoreState } from '../store/tripStore'
import type { RouteSegment, RouteResult } from '../types'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'cruise']
/**
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
* day assignments, draws a straight-line route, and optionally fetches per-segment
* driving/walking durations via OSRM. Aborts in-flight requests when the day changes.
*/
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null) {
const [route, setRoute] = useState<[number, number][] | null>(null)
const [route, setRoute] = useState<[number, number][][] | null>(null)
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
const routeAbortRef = useRef<AbortController | null>(null)
// Keep a ref to the latest tripStore so updateRouteForDay never has a stale closure
const tripStoreRef = useRef(tripStore)
tripStoreRef.current = tripStore
const reservationsForSignature = useTripStore((s) => s.reservations)
const updateRouteForDay = useCallback(async (dayId: number | null) => {
if (routeAbortRef.current) routeAbortRef.current.abort()
if (!dayId) { setRoute(null); setRouteSegments([]); return }
const currentAssignments = tripStoreRef.current.assignments || {}
// Read directly from store (not a render-phase ref) so callers after optimistic
// updates or non-optimistic deletes always see the latest assignments.
const currentAssignments = useTripStore.getState().assignments || {}
const da = (currentAssignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const waypoints = da.map((a) => a.place).filter((p) => p?.lat && p?.lng)
if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
setRoute(waypoints.map((p) => [p.lat!, p.lng!]))
const allReservations = useTripStore.getState().reservations || []
const allDays = useTripStore.getState().days || []
const dayOrder = (id: number | null | undefined): number | null => {
if (id == null) return null
const d = allDays.find(x => x.id === id)
return d ? ((d as any).day_number ?? allDays.indexOf(d)) : null
}
const thisOrder = dayOrder(dayId)
// Transport reservations for this day with a known position — mirrors getTransportForDay semantics
const dayTransports = thisOrder == null ? [] : allReservations.filter(r => {
if (!TRANSPORT_TYPES.includes(r.type)) return false
const startId = r.day_id
if (startId == null) return false
const endId = r.end_day_id ?? startId
if (startId === endId) {
if (startId !== dayId) return false
} else {
const startOrder = dayOrder(startId)
const endOrder = dayOrder(endId)
if (startOrder == null || endOrder == null) return false
if (thisOrder < startOrder || thisOrder > endOrder) return false
}
const pos = r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position
return pos != null
})
// Build a unified list of places + transports sorted by effective position,
// then derive segments by resetting whenever a transport appears — mirrors getMergedItems order.
type Entry = { kind: 'place'; lat: number; lng: number } | { kind: 'transport' }
const entries: (Entry & { pos: number })[] = [
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
})),
...dayTransports.map(r => ({
kind: 'transport' as const,
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
})),
].sort((a, b) => a.pos - b.pos)
const segments: [number, number][][] = []
let currentSeg: [number, number][] = []
for (const entry of entries) {
if (entry.kind === 'place') {
currentSeg.push([entry.lat, entry.lng])
} else {
if (currentSeg.length >= 2) segments.push([...currentSeg])
currentSeg = []
}
}
if (currentSeg.length >= 2) segments.push(currentSeg)
const geocodedWaypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng) as { lat: number; lng: number }[]
if (segments.length === 0 && geocodedWaypoints.length < 2) {
setRoute(null); setRouteSegments([]); return
}
setRoute(segments.length > 0 ? segments : null)
if (!routeCalcEnabled) { setRouteSegments([]); return }
const controller = new AbortController()
routeAbortRef.current = controller
try {
const segments = await calculateSegments(waypoints as { lat: number; lng: number }[], { signal: controller.signal })
if (!controller.signal.aborted) setRouteSegments(segments)
const calcSegments = await calculateSegments(geocodedWaypoints, { signal: controller.signal })
if (!controller.signal.aborted) setRouteSegments(calcSegments)
} catch (err: unknown) {
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
else if (!(err instanceof Error)) setRouteSegments([])
}
}, [routeCalcEnabled])
// Only recalculate when assignments for the SELECTED day change
// Stable signature for transport reservations on the selected day changes when a transport
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
const transportSignature = useMemo(() => {
if (!selectedDayId) return ''
return reservationsForSignature
.filter(r => TRANSPORT_TYPES.includes(r.type))
.map(r => {
const pos = r.day_positions?.[selectedDayId] ?? r.day_positions?.[String(selectedDayId)] ?? r.day_plan_position
return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}`
})
.sort()
.join('|')
}, [reservationsForSignature, selectedDayId])
// Recalculate when assignments or transport positions for the SELECTED day change
const selectedDayAssignments = selectedDayId ? tripStore.assignments?.[String(selectedDayId)] : null
useEffect(() => {
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
updateRouteForDay(selectedDayId)
}, [selectedDayId, selectedDayAssignments])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDayId, selectedDayAssignments, transportSignature])
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
}
+134 -3
View File
@@ -8,11 +8,15 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'common.showMore': 'عرض المزيد',
'common.showLess': 'عرض أقل',
'common.cancel': 'إلغاء',
'common.clear': 'مسح',
'common.delete': 'حذف',
'common.edit': 'تعديل',
'common.add': 'إضافة',
'common.loading': 'جارٍ التحميل...',
'common.import': 'استيراد',
'common.select': 'تحديد',
'common.selectAll': 'تحديد الكل',
'common.deselectAll': 'إلغاء تحديد الكل',
'common.error': 'خطأ',
'common.unknownError': 'خطأ غير معروف',
'common.tooManyAttempts': 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
@@ -312,6 +316,16 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'اقتراح ميزة',
'settings.about.featureRequestHint': 'اقترح ميزة جديدة',
'settings.about.wikiHint': 'التوثيق والأدلة',
'settings.about.supporters.badge': 'الداعمون الشهريون',
'settings.about.supporters.title': 'رفاق رحلة TREK',
'settings.about.supporters.subtitle': 'بينما تخطّط لمسارك التالي، يساعد هؤلاء الأشخاص في التخطيط لمستقبل TREK. تذهب مساهمتهم الشهرية مباشرةً إلى التطوير والساعات الفعلية المبذولة — حتى يظلّ TREK مفتوح المصدر.',
'settings.about.supporters.since': 'داعم منذ {date}',
'settings.about.supporters.tierEmpty': 'كن الأول',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK هو مخطط سفر مستضاف ذاتيًا يساعدك على تنظيم رحلاتك من أول فكرة حتى آخر ذكرى. تخطيط يومي، ميزانية، قوائم تعبئة، صور والمزيد — كل شيء في مكان واحد، على خادمك الخاص.',
'settings.about.madeWith': 'صُنع بـ',
'settings.about.madeBy': 'بواسطة موريس ومجتمع مفتوح المصدر متنامٍ.',
@@ -463,6 +477,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',
@@ -581,8 +601,22 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'تم حفظ إعدادات أنواع الملفات',
// Packing Templates & Bag Tracking
'admin.placesPhotos.title': 'صور الأماكن',
'admin.placesPhotos.subtitle': 'جلب الصور من Google Places API. عطّلها للحفاظ على حصة API. صور Wikimedia غير متأثرة.',
'admin.placesAutocomplete.title': 'الإكمال التلقائي للأماكن',
'admin.placesAutocomplete.subtitle': 'استخدام Google Places API لاقتراحات البحث. عطّلها للحفاظ على حصة API.',
'admin.placesDetails.title': 'تفاصيل الأماكن',
'admin.placesDetails.subtitle': 'جلب معلومات تفصيلية عن الأماكن (الساعات، التقييم، الموقع) من Google Places API. عطّلها للحفاظ على حصة API.',
'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': 'قالب جديد',
@@ -836,6 +870,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// Trip Planner
'trip.tabs.plan': 'الخطة',
'trip.tabs.transports': 'المواصلات',
'trip.tabs.reservations': 'الحجوزات',
'trip.tabs.reservationsShort': 'حجز',
'trip.tabs.packing': 'قائمة التجهيز',
@@ -858,6 +893,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'trip.toast.reservationAdded': 'تمت إضافة الحجز',
'trip.toast.deleted': 'تم الحذف',
'trip.confirm.deletePlace': 'هل تريد حذف هذا المكان؟',
'trip.confirm.deletePlaces': 'حذف {count} أماكن؟',
'trip.toast.placesDeleted': 'تم حذف {count} أماكن',
// Day Plan Sidebar
'dayplan.emptyDay': 'لا توجد أماكن مخططة لهذا اليوم',
@@ -902,6 +939,17 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'places.importFileError': 'فشل الاستيراد',
'places.importAllSkipped': 'جميع الأماكن موجودة بالفعل في الرحلة.',
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
'places.gpxImportTypes': 'ما الذي تريد استيراده؟',
'places.gpxImportWaypoints': 'نقاط الطريق',
'places.gpxImportRoutes': 'المسارات',
'places.gpxImportTracks': 'المسارات (مع هندسة الطريق)',
'places.gpxImportNoneSelected': 'اختر نوعاً واحداً على الأقل للاستيراد.',
'places.kmlImportTypes': 'ما الذي تريد استيراده؟',
'places.kmlImportPoints': 'نقاط (Placemarks)',
'places.kmlImportPaths': 'مسارات (LineStrings)',
'places.kmlImportNoneSelected': 'اختر نوعًا واحدًا على الأقل.',
'places.selectionCount': '{count} محدد',
'places.deleteSelected': 'حذف المحدد',
'places.kmlKmzImported': 'تم استيراد {count} مكان من KMZ/KML',
'places.urlResolved': 'تم استيراد المكان من الرابط',
'places.importList': 'استيراد قائمة',
@@ -918,6 +966,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
'places.all': 'الكل',
'places.unplanned': 'غير مخطط',
'places.filterTracks': 'المسارات',
'places.search': 'ابحث عن أماكن...',
'places.allCategories': 'كل الفئات',
'places.categoriesSelected': 'فئات',
@@ -1002,10 +1051,20 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.flightNumber': 'رقم الرحلة',
'reservations.meta.from': 'من',
'reservations.meta.to': 'إلى',
'reservations.needsReview': 'مراجعة',
'reservations.needsReviewHint': 'تعذّر مطابقة المطار تلقائياً — يرجى تأكيد الموقع.',
'reservations.searchLocation': 'ابحث عن محطة، ميناء، عنوان...',
'airport.searchPlaceholder': 'رمز المطار أو المدينة (مثل FRA)',
'map.connections': 'الاتصالات',
'map.showConnections': 'عرض مسارات الحجوزات',
'map.hideConnections': 'إخفاء مسارات الحجوزات',
'settings.bookingLabels': 'تسميات مسارات الحجوزات',
'settings.bookingLabelsHint': 'عرض أسماء المحطات/المطارات على الخريطة. عند الإيقاف، يتم عرض الرمز فقط.',
'reservations.meta.trainNumber': 'رقم القطار',
'reservations.meta.platform': 'المنصة',
'reservations.meta.seat': 'المقعد',
'reservations.meta.checkIn': 'تسجيل الوصول',
'reservations.meta.checkInUntil': 'تسجيل الدخول حتى',
'reservations.meta.checkOut': 'تسجيل المغادرة',
'reservations.meta.linkAccommodation': 'الإقامة',
'reservations.meta.pickAccommodation': 'ربط بالإقامة',
@@ -1019,7 +1078,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.hotel': 'إقامة',
'reservations.type.restaurant': 'مطعم',
'reservations.type.train': 'قطار',
'reservations.type.car': 'سيارة مستأجرة',
'reservations.type.car': 'سيارة',
'reservations.type.cruise': 'رحلة بحرية',
'reservations.type.event': 'فعالية',
'reservations.type.tour': 'جولة',
@@ -1080,6 +1139,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'reservations.span.end': 'النهاية',
'reservations.span.ongoing': 'جارٍ',
'reservations.validation.endBeforeStart': 'يجب أن يكون تاريخ/وقت الانتهاء بعد تاريخ/وقت البدء',
'reservations.addBooking': 'إضافة حجز',
// Budget
'budget.title': 'الميزانية',
@@ -1490,6 +1550,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'day.noPlacesForHotel': 'أضف أماكن إلى رحلتك أولًا',
'day.allDays': 'الكل',
'day.checkIn': 'تسجيل الوصول',
'day.checkInUntil': 'حتى',
'day.checkOut': 'تسجيل المغادرة',
'day.confirmation': 'التأكيد',
'day.editAccommodation': 'تعديل الإقامة',
@@ -1516,6 +1577,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'memories.providerPassword': 'كلمة المرور',
'memories.providerOTP': 'رمز MFA (إذا كان مفعلاً)',
'memories.skipSSLVerification': 'تخطي التحقق من شهادة SSL',
'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع',
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
'memories.testConnection': 'اختبار الاتصال',
'memories.testFirst': 'اختبر الاتصال أولاً',
@@ -1553,6 +1615,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'memories.confirmShareTitle': 'مشاركة مع أعضاء الرحلة؟',
'memories.confirmShareHint': '{count} صور ستكون مرئية لجميع أعضاء هذه الرحلة. يمكنك جعل الصور الفردية خاصة لاحقًا.',
'memories.confirmShareButton': 'مشاركة الصور',
'journey.search.placeholder': 'البحث في الرحلات…',
'journey.search.noResults': 'لا توجد رحلات تطابق "{query}"',
'journey.status.archived': 'مؤرشف',
'journey.settings.endJourney': 'أرشفة الرحلة',
'journey.settings.reopenJourney': 'استعادة الرحلة',
'journey.settings.archived': 'تم أرشفة الرحلة',
'journey.settings.reopened': 'تمت إعادة فتح الرحلة',
'journey.settings.endDescription': 'يخفي شارة البث المباشر. يمكنك إعادة الفتح في أي وقت.',
'journey.settings.failedToDelete': 'فشل في الحذف',
'journey.entries.deleteTitle': 'حذف الإدخال',
'journey.photosUploaded': 'تم رفع {count} صورة',
@@ -1586,6 +1656,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'journey.invite.inviting': 'جارٍ الدعوة...',
// Journey Entry Editor
'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟',
'journey.editor.uploadPhotos': 'رفع صور',
'journey.editor.uploading': '...جارٍ الرفع',
'journey.editor.fromGallery': 'من المعرض',
@@ -1723,6 +1794,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'undo.reorder': 'تمت إعادة ترتيب الأماكن',
'undo.optimize': 'تم تحسين المسار',
'undo.deletePlace': 'تم حذف المكان',
'undo.deletePlaces': 'تم حذف الأماكن',
'undo.moveDay': 'تم نقل المكان إلى يوم آخر',
'undo.lock': 'تم تبديل قفل المكان',
'undo.importGpx': 'استيراد GPX',
@@ -1782,7 +1854,11 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'غير مُسنَد',
'todo.noCategory': 'بدون فئة',
'todo.hasDescription': 'له وصف',
'todo.addItem': 'إضافة مهمة جديدة...',
'todo.addItem': 'إضافة مهمة جديدة',
'todo.sidebar.sortBy': 'ترتيب حسب',
'todo.priority': 'الأولوية',
'todo.newCategoryLabel': 'جديد',
'budget.categoriesLabel': 'فئات',
'todo.newCategory': 'اسم الفئة',
'todo.addCategory': 'إضافة فئة',
'todo.newItem': 'مهمة جديدة',
@@ -1824,7 +1900,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.ntfyUrl.test': 'اختبار',
'settings.ntfyUrl.testSuccess': 'تم إرسال إشعار Ntfy التجريبي بنجاح',
'settings.ntfyUrl.testFailed': 'فشل إشعار Ntfy التجريبي',
'settings.ntfyUrl.clearToken': 'مسح',
'settings.ntfyUrl.tokenCleared': 'تم مسح رمز الوصول',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1841,22 +1916,29 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'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.notifications.tripReminders.title': 'تذكيرات الرحلات',
'admin.notifications.tripReminders.hint': 'إرسال تذكير قبل بدء الرحلة (يتطلب تعيين أيام التذكير على الرحلة).',
'admin.notifications.tripReminders.enabled': 'تم تفعيل تذكيرات الرحلات',
'admin.notifications.tripReminders.disabled': 'تم تعطيل تذكيرات الرحلات',
'admin.tabs.notifications': 'الإشعارات',
'notifications.versionAvailable.title': 'تحديث متاح',
'notifications.versionAvailable.text': 'TREK {version} متاح الآن.',
@@ -1960,6 +2042,55 @@ 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) يبقي الأضواء مشتعلة.',
'transport.addTransport': 'Add transport',
'transport.modalTitle.create': 'Add transport',
'transport.modalTitle.edit': 'Edit transport',
'transport.title': 'المواصلات',
'transport.addManual': 'نقل يدوي',
}
export default ar
+134 -3
View File
@@ -4,11 +4,15 @@ 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',
'common.loading': 'Carregando...',
'common.import': 'Importar',
'common.select': 'Selecionar',
'common.selectAll': 'Selecionar tudo',
'common.deselectAll': 'Desmarcar tudo',
'common.error': 'Erro',
'common.unknownError': 'Erro desconhecido',
'common.tooManyAttempts': 'Muitas tentativas. Tente novamente mais tarde.',
@@ -239,6 +243,16 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'Solicitar recurso',
'settings.about.featureRequestHint': 'Sugira um novo recurso',
'settings.about.wikiHint': 'Documentação e guias',
'settings.about.supporters.badge': 'Apoiadores Mensais',
'settings.about.supporters.title': 'Companheiros de viagem do TREK',
'settings.about.supporters.subtitle': 'Enquanto você planeja sua próxima rota, essas pessoas planejam junto o futuro do TREK. A contribuição mensal delas vai direto para o desenvolvimento e horas reais investidas — para o TREK continuar Open Source.',
'settings.about.supporters.since': 'apoiador desde {date}',
'settings.about.supporters.tierEmpty': 'Seja o primeiro',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK é um planejador de viagens auto-hospedado que ajuda você a organizar suas viagens da primeira ideia à última lembrança. Planejamento diário, orçamento, listas de bagagem, fotos e muito mais — tudo em um só lugar, no seu próprio servidor.',
'settings.about.madeWith': 'Feito com',
'settings.about.madeBy': 'por Maurice e uma crescente comunidade open-source.',
@@ -545,9 +559,29 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'Configurações de tipos de arquivo salvas',
// Packing Templates & Bag Tracking
'admin.placesPhotos.title': 'Fotos de Locais',
'admin.placesPhotos.subtitle': 'Busca fotos da Google Places API. Desative para economizar cota da API. Fotos do Wikimedia não são afetadas.',
'admin.placesAutocomplete.title': 'Autocompletar de Locais',
'admin.placesAutocomplete.subtitle': 'Usa a Google Places API para sugestões de pesquisa. Desative para economizar cota da API.',
'admin.placesDetails.title': 'Detalhes do Local',
'admin.placesDetails.subtitle': 'Busca informações detalhadas do local (horários, avaliação, site) da Google Places API. Desative para economizar cota da API.',
'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',
@@ -806,6 +840,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
// Trip Planner
'trip.tabs.plan': 'Plano',
'trip.tabs.transports': 'Transportes',
'trip.tabs.reservations': 'Reservas',
'trip.tabs.reservationsShort': 'Reservas',
'trip.tabs.packing': 'Lista de mala',
@@ -827,6 +862,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'trip.toast.reservationAdded': 'Reserva adicionada',
'trip.toast.deleted': 'Excluído',
'trip.confirm.deletePlace': 'Tem certeza de que deseja excluir este lugar?',
'trip.confirm.deletePlaces': 'Excluir {count} lugares?',
'trip.toast.placesDeleted': '{count} lugares excluídos',
'trip.loadingPhotos': 'Carregando fotos dos lugares...',
// Day Plan Sidebar
@@ -872,6 +909,17 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'places.importFileError': 'Importação falhou',
'places.importAllSkipped': 'Todos os lugares já estavam na viagem.',
'places.gpxImported': '{count} lugares importados do GPX',
'places.gpxImportTypes': 'O que deseja importar?',
'places.gpxImportWaypoints': 'Pontos de caminho',
'places.gpxImportRoutes': 'Rotas',
'places.gpxImportTracks': 'Trilhas (com geometria de percurso)',
'places.gpxImportNoneSelected': 'Selecione pelo menos um tipo para importar.',
'places.kmlImportTypes': 'O que deseja importar?',
'places.kmlImportPoints': 'Pontos (Placemarks)',
'places.kmlImportPaths': 'Caminhos (LineStrings)',
'places.kmlImportNoneSelected': 'Selecione pelo menos um tipo.',
'places.selectionCount': '{count} selecionado(s)',
'places.deleteSelected': 'Excluir seleção',
'places.kmlKmzImported': '{count} lugares importados de KMZ/KML',
'places.urlResolved': 'Lugar importado da URL',
'places.importList': 'Importar lista',
@@ -888,6 +936,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'places.assignToDay': 'Adicionar a qual dia?',
'places.all': 'Todos',
'places.unplanned': 'Não planejados',
'places.filterTracks': 'Trilhas',
'places.search': 'Buscar lugares...',
'places.allCategories': 'Todas as categorias',
'places.categoriesSelected': 'categorias',
@@ -971,10 +1020,20 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.flightNumber': 'Nº do voo',
'reservations.meta.from': 'De',
'reservations.meta.to': 'Para',
'reservations.needsReview': 'Verificar',
'reservations.needsReviewHint': 'Aeroporto não pôde ser identificado automaticamente — confirme o local.',
'reservations.searchLocation': 'Buscar estação, porto, endereço...',
'airport.searchPlaceholder': 'Código ou cidade do aeroporto (ex. FRA)',
'map.connections': 'Conexões',
'map.showConnections': 'Mostrar rotas de reservas',
'map.hideConnections': 'Ocultar rotas de reservas',
'settings.bookingLabels': 'Rótulos das rotas de reservas',
'settings.bookingLabelsHint': 'Mostra nomes de estações / aeroportos no mapa. Desativado, apenas o ícone aparece.',
'reservations.meta.trainNumber': 'Nº do trem',
'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',
@@ -988,7 +1047,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.hotel': 'Hospedagem',
'reservations.type.restaurant': 'Restaurante',
'reservations.type.train': 'Trem',
'reservations.type.car': 'Carro alugado',
'reservations.type.car': 'Carro',
'reservations.type.cruise': 'Cruzeiro',
'reservations.type.event': 'Evento',
'reservations.type.tour': 'Passeio',
@@ -1049,6 +1108,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'reservations.span.end': 'Fim',
'reservations.span.ongoing': 'Em andamento',
'reservations.validation.endBeforeStart': 'A data/hora final deve ser posterior à data/hora inicial',
'reservations.addBooking': 'Adicionar reserva',
// Budget
'budget.title': 'Orçamento',
@@ -1459,6 +1519,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',
@@ -1555,6 +1616,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'memories.providerPassword': 'Senha',
'memories.providerOTP': 'Código MFA (se habilitado)',
'memories.skipSSLVerification': 'Pular verificação de certificado SSL',
'memories.immichAutoUpload': 'Espelhar fotos da jornada no Immich ao enviar',
'memories.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo',
'memories.testConnection': 'Testar conexão',
'memories.testFirst': 'Teste a conexão primeiro',
@@ -1672,6 +1734,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'undo.reorder': 'Locais reordenados',
'undo.optimize': 'Rota otimizada',
'undo.deletePlace': 'Local excluído',
'undo.deletePlaces': 'Lugares excluídos',
'undo.moveDay': 'Local movido para outro dia',
'undo.lock': 'Bloqueio do local alternado',
'undo.importGpx': 'Importação de GPX',
@@ -1731,7 +1794,11 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'Não atribuído',
'todo.noCategory': 'Sem categoria',
'todo.hasDescription': 'Com descrição',
'todo.addItem': 'Adicionar nova tarefa...',
'todo.addItem': 'Nova tarefa',
'todo.sidebar.sortBy': 'Ordenar por',
'todo.priority': 'Prioridade',
'todo.newCategoryLabel': 'nova',
'budget.categoriesLabel': 'categorias',
'todo.newCategory': 'Nome da categoria',
'todo.addCategory': 'Adicionar categoria',
'todo.newItem': 'Nova tarefa',
@@ -1773,7 +1840,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'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.clearToken': 'Limpar',
'settings.ntfyUrl.tokenCleared': 'Token de acesso removido',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1790,22 +1856,29 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'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.notifications.tripReminders.title': 'Lembretes de viagem',
'admin.notifications.tripReminders.hint': 'Envia uma notificação de lembrete antes do início de uma viagem (requer dias de lembrete definidos na viagem).',
'admin.notifications.tripReminders.enabled': 'Lembretes de viagem ativados',
'admin.notifications.tripReminders.disabled': 'Lembretes de viagem desativados',
'admin.tabs.notifications': 'Notificações',
'notifications.versionAvailable.title': 'Atualização disponível',
'notifications.versionAvailable.text': 'TREK {version} já está disponível.',
@@ -1853,6 +1926,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'memories.saveRouteNotConfigured': 'A rota de salvamento não está configurada para este provedor',
'memories.testRouteNotConfigured': 'A rota de teste não está configurada para este provedor',
'memories.fillRequiredFields': 'Por favor preencha todos os campos obrigatórios',
'journey.search.placeholder': 'Buscar jornadas…',
'journey.search.noResults': 'Nenhuma jornada corresponde a "{query}"',
'journey.title': 'Jornada',
'journey.subtitle': 'Registre suas viagens em tempo real',
'journey.new': 'Nova jornada',
@@ -1874,6 +1949,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Ativa',
'journey.status.completed': 'Concluída',
'journey.status.upcoming': 'Próxima',
'journey.status.archived': 'Arquivado',
'journey.checkin.add': 'Fazer check-in',
'journey.checkin.namePlaceholder': 'Nome do local',
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
@@ -1950,6 +2026,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.verdict.couldBeBetter': 'Poderia ser melhor',
'journey.synced.places': 'lugares',
'journey.synced.synced': 'sincronizado',
'journey.editor.discardChangesConfirm': 'Você tem alterações não salvas. Descartá-las?',
'journey.editor.uploadPhotos': 'Enviar fotos',
'journey.editor.uploading': 'Enviando...',
'journey.editor.fromGallery': 'Da galeria',
@@ -2027,6 +2104,11 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Nome',
'journey.settings.subtitle': 'Subtítulo',
'journey.settings.subtitlePlaceholder': 'ex. Tailândia, Vietnã e Camboja',
'journey.settings.endJourney': 'Arquivar Jornada',
'journey.settings.reopenJourney': 'Restaurar Jornada',
'journey.settings.archived': 'Jornada arquivada',
'journey.settings.reopened': 'Jornada reaberta',
'journey.settings.endDescription': 'Oculta o selo Ao Vivo. Você pode reabrir a qualquer momento.',
'journey.settings.delete': 'Excluir',
'journey.settings.deleteJourney': 'Excluir jornada',
'journey.settings.deleteMessage': 'Excluir "{title}"? Todas as entradas e fotos serão perdidas.',
@@ -2163,6 +2245,55 @@ 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.',
'transport.addTransport': 'Add transport',
'transport.modalTitle.create': 'Add transport',
'transport.modalTitle.edit': 'Edit transport',
'transport.title': 'Transportes',
'transport.addManual': 'Transporte Manual',
}
export default br
+134 -3
View File
@@ -4,11 +4,15 @@ 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',
'common.loading': 'Načítání...',
'common.import': 'Importovat',
'common.select': 'Vybrat',
'common.selectAll': 'Vybrat vše',
'common.deselectAll': 'Zrušit výběr všeho',
'common.error': 'Chyba',
'common.unknownError': 'Neznámá chyba',
'common.tooManyAttempts': 'Příliš mnoho pokusů. Zkuste to prosím znovu.',
@@ -263,6 +267,16 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'Navrhnout funkci',
'settings.about.featureRequestHint': 'Navrhněte novou funkci',
'settings.about.wikiHint': 'Dokumentace a návody',
'settings.about.supporters.badge': 'Měsíční podporovatelé',
'settings.about.supporters.title': 'Společníci na cestě s TREK',
'settings.about.supporters.subtitle': 'Zatímco plánuješ další trasu, tihle lidé plánují společně se mnou budoucnost TREK. Jejich měsíční příspěvek jde přímo na vývoj a reálně strávené hodiny — aby TREK zůstal Open Source.',
'settings.about.supporters.since': 'podporovatel od {date}',
'settings.about.supporters.tierEmpty': 'Buď první',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK je samohostovaný plánovač cest, který vám pomůže organizovat výlety od prvního nápadu po poslední vzpomínku. Denní plánování, rozpočet, balicí seznamy, fotky a mnoho dalšího — vše na jednom místě, na vašem vlastním serveru.',
'settings.about.madeWith': 'Vytvořeno s',
'settings.about.madeBy': 'Mauricem a rostoucí open-source komunitou.',
@@ -545,9 +559,29 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'Nastavení souborů uloženo',
// Šablony balení (Packing Templates)
'admin.placesPhotos.title': 'Fotografie míst',
'admin.placesPhotos.subtitle': 'Načítání fotografií z Google Places API. Zakázáním ušetříte kvótu API. Fotografie z Wikimedia nejsou ovlivněny.',
'admin.placesAutocomplete.title': 'Automatické doplňování míst',
'admin.placesAutocomplete.subtitle': 'Použití Google Places API pro návrhy vyhledávání. Zakázáním ušetříte kvótu API.',
'admin.placesDetails.title': 'Podrobnosti o místě',
'admin.placesDetails.subtitle': 'Načítání podrobných informací o místě (hodiny, hodnocení, web) z Google Places API. Zakázáním ušetříte kvótu API.',
'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',
@@ -834,6 +868,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
// Plánovač cesty (Trip Planner)
'trip.tabs.plan': 'Plán',
'trip.tabs.transports': 'Doprava',
'trip.tabs.reservations': 'Rezervace',
'trip.tabs.reservationsShort': 'Rez.',
'trip.tabs.packing': 'Seznam věcí',
@@ -856,6 +891,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'trip.toast.reservationAdded': 'Rezervace přidána',
'trip.toast.deleted': 'Smazáno',
'trip.confirm.deletePlace': 'Opravdu chcete toto místo smazat?',
'trip.confirm.deletePlaces': 'Smazat {count} míst?',
'trip.toast.placesDeleted': '{count} míst smazáno',
// Denní plán (Day Plan)
'dayplan.emptyDay': 'Na tento den nejsou naplánována žádná místa',
@@ -900,6 +937,17 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'places.importFileError': 'Import se nezdařil',
'places.importAllSkipped': 'Všechna místa již byla v cestě.',
'places.gpxImported': '{count} míst importováno z GPX',
'places.gpxImportTypes': 'Co chcete importovat?',
'places.gpxImportWaypoints': 'Trasové body',
'places.gpxImportRoutes': 'Trasy',
'places.gpxImportTracks': 'Trasy GPS (s geometrií)',
'places.gpxImportNoneSelected': 'Vyberte alespoň jeden typ k importu.',
'places.kmlImportTypes': 'Co chcete importovat?',
'places.kmlImportPoints': 'Body (Placemarks)',
'places.kmlImportPaths': 'Trasy (LineStrings)',
'places.kmlImportNoneSelected': 'Vyberte alespoň jeden typ.',
'places.selectionCount': '{count} vybráno',
'places.deleteSelected': 'Smazat vybrané',
'places.kmlKmzImported': 'Importováno {count} míst z KMZ/KML',
'places.urlResolved': 'Místo importováno z URL',
'places.importList': 'Import seznamu',
@@ -916,6 +964,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'places.assignToDay': 'Přidat do kterého dne?',
'places.all': 'Vše',
'places.unplanned': 'Nezařazené',
'places.filterTracks': 'Trasy',
'places.search': 'Hledat místa...',
'places.allCategories': 'Všechny kategorie',
'places.categoriesSelected': 'kategorií',
@@ -1000,10 +1049,20 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.flightNumber': 'Číslo letu',
'reservations.meta.from': 'Z',
'reservations.meta.to': 'Do',
'reservations.needsReview': 'Zkontrolovat',
'reservations.needsReviewHint': 'Letiště nebylo možné automaticky rozpoznat — potvrďte prosím místo.',
'reservations.searchLocation': 'Hledat stanici, přístav, adresu...',
'airport.searchPlaceholder': 'Kód letiště nebo město (např. FRA)',
'map.connections': 'Spojení',
'map.showConnections': 'Zobrazit trasy rezervací',
'map.hideConnections': 'Skrýt trasy rezervací',
'settings.bookingLabels': 'Popisky tras rezervací',
'settings.bookingLabelsHint': 'Zobrazuje názvy stanic / letišť na mapě. Pokud je vypnuto, zobrazí se pouze ikona.',
'reservations.meta.trainNumber': 'Číslo vlaku',
'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',
@@ -1017,7 +1076,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.hotel': 'Ubytování',
'reservations.type.restaurant': 'Restaurace',
'reservations.type.train': 'Vlak',
'reservations.type.car': 'Pronájem auta',
'reservations.type.car': 'Auto',
'reservations.type.cruise': 'Plavba',
'reservations.type.event': 'Událost',
'reservations.type.tour': 'Prohlídka',
@@ -1078,6 +1137,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'reservations.span.end': 'Konec',
'reservations.span.ongoing': 'Probíhá',
'reservations.validation.endBeforeStart': 'Datum/čas konce musí být po datu/čase začátku',
'reservations.addBooking': 'Přidat rezervaci',
// Rozpočet (Budget)
'budget.title': 'Rozpočet',
@@ -1488,6 +1548,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í',
@@ -1514,6 +1575,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'memories.providerPassword': 'Heslo',
'memories.providerOTP': 'MFA kód (pokud je povoleno)',
'memories.skipSSLVerification': 'Přeskočit ověření SSL certifikátu',
'memories.immichAutoUpload': 'Zrcadlit fotky journey při nahrávání také do Immich',
'memories.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo',
'memories.testConnection': 'Otestovat připojení',
'memories.testFirst': 'Nejprve otestujte připojení',
@@ -1675,6 +1737,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'undo.reorder': 'Místa přeseřazena',
'undo.optimize': 'Trasa optimalizována',
'undo.deletePlace': 'Místo smazáno',
'undo.deletePlaces': 'Místa smazána',
'undo.moveDay': 'Místo přesunuto na jiný den',
'undo.lock': 'Zámek místa přepnut',
'undo.importGpx': 'Import GPX',
@@ -1736,7 +1799,11 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'Nepřiřazeno',
'todo.noCategory': 'Bez kategorie',
'todo.hasDescription': 'Má popis',
'todo.addItem': 'Přidat nový úkol...',
'todo.addItem': 'Přidat nový úkol',
'todo.sidebar.sortBy': 'Řadit podle',
'todo.priority': 'Priorita',
'todo.newCategoryLabel': 'nová',
'budget.categoriesLabel': 'kategorie',
'todo.newCategory': 'Název kategorie',
'todo.addCategory': 'Přidat kategorii',
'todo.newItem': 'Nový úkol',
@@ -1778,7 +1845,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.ntfyUrl.test': 'Otestovat',
'settings.ntfyUrl.testSuccess': 'Testovací notifikace Ntfy byla úspěšně odeslána',
'settings.ntfyUrl.testFailed': 'Testovací notifikace Ntfy selhala',
'settings.ntfyUrl.clearToken': 'Vymazat',
'settings.ntfyUrl.tokenCleared': 'Přístupový token byl vymazán',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1795,22 +1861,29 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'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.notifications.tripReminders.title': 'Připomínky výletů',
'admin.notifications.tripReminders.hint': 'Odešle upozornění před začátkem výletu (vyžaduje nastavené dny připomínky na výletu).',
'admin.notifications.tripReminders.enabled': 'Připomínky výletů aktivovány',
'admin.notifications.tripReminders.disabled': 'Připomínky výletů deaktivovány',
'admin.tabs.notifications': 'Oznámení',
'notifications.versionAvailable.title': 'Dostupná aktualizace',
'notifications.versionAvailable.text': 'TREK {version} je nyní k dispozici.',
@@ -1858,6 +1931,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'memories.saveRouteNotConfigured': 'Trasa uložení není nakonfigurována pro tohoto poskytovatele',
'memories.testRouteNotConfigured': 'Testovací trasa není nakonfigurována pro tohoto poskytovatele',
'memories.fillRequiredFields': 'Prosím vyplňte všechna povinná pole',
'journey.search.placeholder': 'Hledat cesty…',
'journey.search.noResults': 'Žádné cesty neodpovídají „{query}"',
'journey.title': 'Cestovní deník',
'journey.subtitle': 'Zaznamenávejte své cesty průběžně',
'journey.new': 'Nový cestovní deník',
@@ -1879,6 +1954,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Aktivní',
'journey.status.completed': 'Dokončeno',
'journey.status.upcoming': 'Nadcházející',
'journey.status.archived': 'Archivováno',
'journey.checkin.add': 'Odbavit se',
'journey.checkin.namePlaceholder': 'Název místa',
'journey.checkin.notesPlaceholder': 'Poznámky (volitelné)',
@@ -1955,6 +2031,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.verdict.couldBeBetter': 'Mohlo by být lepší',
'journey.synced.places': 'místa',
'journey.synced.synced': 'synchronizováno',
'journey.editor.discardChangesConfirm': 'Máte neuložené změny. Zahodit?',
'journey.editor.uploadPhotos': 'Nahrát fotky',
'journey.editor.uploading': 'Nahrávání...',
'journey.editor.fromGallery': 'Z galerie',
@@ -2032,6 +2109,11 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Název',
'journey.settings.subtitle': 'Podtitul',
'journey.settings.subtitlePlaceholder': 'např. Thajsko, Vietnam a Kambodža',
'journey.settings.endJourney': 'Archivovat cestu',
'journey.settings.reopenJourney': 'Obnovit cestu',
'journey.settings.archived': 'Cesta archivována',
'journey.settings.reopened': 'Cesta znovu otevřena',
'journey.settings.endDescription': 'Skryje odznak Živě. Kdykoli jej lze znovu otevřít.',
'journey.settings.delete': 'Smazat',
'journey.settings.deleteJourney': 'Smazat cestovní deník',
'journey.settings.deleteMessage': 'Smazat „{title}"? Všechny záznamy a fotky budou ztraceny.',
@@ -2167,6 +2249,55 @@ 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á.',
'transport.addTransport': 'Add transport',
'transport.modalTitle.create': 'Add transport',
'transport.modalTitle.edit': 'Edit transport',
'transport.title': 'Doprava',
'transport.addManual': 'Ruční doprava',
}
export default cs
+143 -6
View File
@@ -4,11 +4,15 @@ 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',
'common.loading': 'Laden...',
'common.import': 'Importieren',
'common.select': 'Auswählen',
'common.selectAll': 'Alle auswählen',
'common.deselectAll': 'Alle abwählen',
'common.error': 'Fehler',
'common.unknownError': 'Unbekannter Fehler',
'common.tooManyAttempts': 'Zu viele Versuche. Bitte versuchen Sie es später erneut.',
@@ -144,7 +148,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.subtitle': 'Konfigurieren Sie Ihre persönlichen Einstellungen',
'settings.tabs.display': 'Anzeige',
'settings.tabs.map': 'Karte',
'settings.tabs.notifications': 'Benachrichtigungen',
'settings.tabs.notifications': 'Mitteilungen',
'settings.tabs.integrations': 'Integrationen',
'settings.tabs.account': 'Konto',
'settings.tabs.offline': 'Offline',
@@ -175,8 +179,10 @@ 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.notifications': 'Mitteilungen',
'settings.notifyTripInvite': 'Trip-Einladungen',
'settings.notifyBookingChange': 'Buchungsänderungen',
'settings.notifyTripReminder': 'Trip-Erinnerungen',
@@ -310,6 +316,16 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'Feature vorschlagen',
'settings.about.featureRequestHint': 'Schlage ein neues Feature vor',
'settings.about.wikiHint': 'Dokumentation & Anleitungen',
'settings.about.supporters.badge': 'Monatliche Unterstützer',
'settings.about.supporters.title': 'Reisebegleitung für TREK',
'settings.about.supporters.subtitle': 'Während du deine nächste Route planst, planen diese Leute mit, wie TREK weitergeht. Ihr monatlicher Beitrag fließt direkt in Entwicklung und echten Zeitaufwand — damit TREK Open Source bleibt.',
'settings.about.supporters.since': 'Unterstützer seit {date}',
'settings.about.supporters.tierEmpty': 'Sei die/der Erste',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK ist ein selbst gehosteter Reiseplaner, der dir hilft, deine Trips von der ersten Idee bis zur letzten Erinnerung zu organisieren. Tagesplanung, Budget, Packlisten, Fotos und vieles mehr — alles an einem Ort, auf deinem eigenen Server.',
'settings.about.madeWith': 'Entwickelt mit',
'settings.about.madeBy': 'von Maurice und einer wachsenden Open-Source-Community.',
@@ -548,10 +564,30 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesFormat': 'Kommagetrennte Endungen (z.B. jpg,png,pdf,doc). Verwende * um alle Typen zu erlauben.',
'admin.fileTypesSaved': 'Dateityp-Einstellungen gespeichert',
'admin.placesPhotos.title': 'Ortsfotos',
'admin.placesPhotos.subtitle': 'Fotos von der Google Places API laden. Deaktivieren, um API-Kontingent zu sparen. Wikimedia-Fotos sind davon nicht betroffen.',
'admin.placesAutocomplete.title': 'Orts-Autovervollständigung',
'admin.placesAutocomplete.subtitle': 'Google Places API für Suchvorschläge nutzen. Deaktivieren, um API-Kontingent zu sparen.',
'admin.placesDetails.title': 'Ortsdetails',
'admin.placesDetails.subtitle': 'Detaillierte Ortsinformationen (Öffnungszeiten, Bewertung, Website) von der Google Places API laden. Deaktivieren, um API-Kontingent zu sparen.',
// 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',
@@ -837,6 +873,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
// Trip Planner
'trip.tabs.plan': 'Karte',
'trip.tabs.transports': 'Transport',
'trip.tabs.reservations': 'Buchungen',
'trip.tabs.reservationsShort': 'Buchung',
'trip.tabs.packing': 'Liste',
@@ -859,6 +896,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'trip.toast.reservationAdded': 'Reservierung hinzugefügt',
'trip.toast.deleted': 'Gelöscht',
'trip.confirm.deletePlace': 'Möchtest du diesen Ort wirklich löschen?',
'trip.confirm.deletePlaces': '{count} Orte löschen?',
'trip.toast.placesDeleted': '{count} Orte gelöscht',
// Day Plan Sidebar
'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant',
@@ -869,6 +908,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'dayplan.cannotDropOnTimed': 'Orte können nicht zwischen zeitgebundene Einträge geschoben werden',
'dayplan.cannotBreakChronology': 'Die zeitliche Reihenfolge von Uhrzeiten und Buchungen darf nicht verletzt werden',
'dayplan.addNote': 'Notiz hinzufügen',
'dayplan.expandAll': 'Alle Tage ausklappen',
'dayplan.collapseAll': 'Alle Tage einklappen',
'dayplan.editNote': 'Notiz bearbeiten',
'dayplan.noteAdd': 'Notiz hinzufügen',
'dayplan.noteEdit': 'Notiz bearbeiten',
@@ -893,7 +934,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
// Places Sidebar
'places.addPlace': 'Ort/Aktivität hinzufügen',
'places.importFile': 'Datei importieren',
'places.importFile': 'Dateimport',
'places.sidebarDrop': 'Ablegen zum Importieren',
'places.importFileHint': '.gpx-, .kml- oder .kmz-Dateien aus Tools wie Google My Maps, Google Earth oder einem GPS-Tracker importieren.',
'places.importFileDropHere': 'Datei auswählen oder hierher ziehen und ablegen',
@@ -903,6 +944,17 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'places.importFileError': 'Import fehlgeschlagen',
'places.importAllSkipped': 'Alle Orte waren bereits in der Reise.',
'places.gpxImported': '{count} Orte aus GPX importiert',
'places.gpxImportTypes': 'Was soll importiert werden?',
'places.gpxImportWaypoints': 'Wegpunkte',
'places.gpxImportRoutes': 'Routen',
'places.gpxImportTracks': 'Tracks (mit Streckenverlauf)',
'places.gpxImportNoneSelected': 'Wähle mindestens einen Typ zum Importieren.',
'places.kmlImportTypes': 'Was möchtest du importieren?',
'places.kmlImportPoints': 'Punkte (Placemarks)',
'places.kmlImportPaths': 'Pfade (LineStrings)',
'places.kmlImportNoneSelected': 'Wähle mindestens einen Typ aus.',
'places.selectionCount': '{count} ausgewählt',
'places.deleteSelected': 'Auswahl löschen',
'places.kmlKmzImported': '{count} Orte aus KMZ/KML importiert',
'places.urlResolved': 'Ort aus URL importiert',
'places.importList': 'Listenimport',
@@ -919,6 +971,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
'places.all': 'Alle',
'places.unplanned': 'Ungeplant',
'places.filterTracks': 'Tracks',
'places.search': 'Orte suchen...',
'places.allCategories': 'Alle Kategorien',
'places.categoriesSelected': 'Kategorien',
@@ -1002,10 +1055,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',
@@ -1019,7 +1080,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.hotel': 'Unterkunft',
'reservations.type.restaurant': 'Restaurant',
'reservations.type.train': 'Zug',
'reservations.type.car': 'Mietwagen',
'reservations.type.car': 'Auto',
'reservations.type.cruise': 'Kreuzfahrt',
'reservations.type.event': 'Veranstaltung',
'reservations.type.tour': 'Tour',
@@ -1080,6 +1141,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'reservations.span.end': 'Ende',
'reservations.span.ongoing': 'Laufend',
'reservations.validation.endBeforeStart': 'Enddatum/-zeit muss nach dem Startdatum/-zeit liegen',
'reservations.addBooking': 'Buchung hinzufügen',
// Budget
'budget.title': 'Budget',
@@ -1490,6 +1552,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',
@@ -1516,6 +1579,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'memories.providerPassword': 'Passwort',
'memories.providerOTP': 'MFA-Code (falls aktiviert)',
'memories.skipSSLVerification': 'SSL-Zertifikatsprüfung überspringen',
'memories.immichAutoUpload': 'Journey-Fotos beim Upload auch zu Immich spiegeln',
'memories.providerUrlHintSynology': 'Füge den Fotos-App-Pfad in die URL ein, z.B. https://nas:5001/photo',
'memories.testConnection': 'Verbindung testen',
'memories.testFirst': 'Verbindung zuerst testen',
@@ -1680,6 +1744,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'undo.reorder': 'Orte neu sortiert',
'undo.optimize': 'Route optimiert',
'undo.deletePlace': 'Ort gelöscht',
'undo.deletePlaces': 'Orte gelöscht',
'undo.moveDay': 'Ort zu anderem Tag verschoben',
'undo.lock': 'Ortssperre umgeschaltet',
'undo.importGpx': 'GPX-Import',
@@ -1739,7 +1804,11 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'Nicht zugewiesen',
'todo.noCategory': 'Keine Kategorie',
'todo.hasDescription': 'Hat Beschreibung',
'todo.addItem': 'Neue Aufgabe hinzufügen...',
'todo.addItem': 'Neue Aufgabe hinzufügen',
'todo.sidebar.sortBy': 'Sortieren nach',
'todo.priority': 'Priorität',
'todo.newCategoryLabel': 'neu',
'budget.categoriesLabel': 'Kategorien',
'todo.newCategory': 'Kategoriename',
'todo.addCategory': 'Kategorie hinzufügen',
'todo.newItem': 'Neue Aufgabe',
@@ -1781,7 +1850,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.ntfyUrl.test': 'Testen',
'settings.ntfyUrl.testSuccess': 'Test-Ntfy-Benachrichtigung erfolgreich gesendet',
'settings.ntfyUrl.testFailed': 'Test-Ntfy-Benachrichtigung fehlgeschlagen',
'settings.ntfyUrl.clearToken': 'Löschen',
'settings.ntfyUrl.tokenCleared': 'Zugriffstoken gelöscht',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1798,22 +1866,29 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'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.notifications.tripReminders.title': 'Reiseerinnerungen',
'admin.notifications.tripReminders.hint': 'Sendet eine Erinnerungsbenachrichtigung vor Reisebeginn (erfordert gesetzte Erinnerungstage bei der Reise).',
'admin.notifications.tripReminders.enabled': 'Reiseerinnerungen aktiviert',
'admin.notifications.tripReminders.disabled': 'Reiseerinnerungen deaktiviert',
'admin.tabs.notifications': 'Benachrichtigungen',
'notifications.versionAvailable.title': 'Update verfügbar',
'notifications.versionAvailable.text': 'TREK {version} ist jetzt verfügbar.',
@@ -1855,6 +1930,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'notif.dev.unknown_event.text': 'Ereignistyp "{event}" ist nicht in EVENT_NOTIFICATION_CONFIG registriert',
// Journey Addon
'journey.search.placeholder': 'Reisen suchen…',
'journey.search.noResults': 'Keine Reisen passen zu „{query}"',
'journey.title': 'Journey',
'journey.subtitle': 'Dokumentiere deine Reisen unterwegs',
'journey.new': 'Neue Journey',
@@ -1876,6 +1953,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Aktiv',
'journey.status.completed': 'Abgeschlossen',
'journey.status.upcoming': 'Anstehend',
'journey.status.archived': 'Archiviert',
'journey.checkin.add': 'Einchecken',
'journey.checkin.namePlaceholder': 'Ortsname',
'journey.checkin.notesPlaceholder': 'Notizen (optional)',
@@ -1956,6 +2034,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.verdict.couldBeBetter': 'Verbesserungswürdig',
'journey.synced.places': 'Orte',
'journey.synced.synced': 'synchronisiert',
'journey.editor.discardChangesConfirm': 'Du hast ungespeicherte Änderungen. Verwerfen?',
'journey.editor.uploadPhotos': 'Fotos hochladen',
'journey.editor.uploading': 'Hochladen...',
'journey.editor.fromGallery': 'Aus Galerie',
@@ -2006,6 +2085,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.contributors.role': 'Rolle',
'journey.contributors.added': 'Mitwirkender hinzugefügt',
'journey.contributors.addFailed': 'Hinzufügen fehlgeschlagen',
'journey.contributors.remove': 'Mitwirkenden entfernen',
'journey.contributors.removeConfirm': '{username} aus dieser Journey entfernen?',
'journey.contributors.removed': 'Mitwirkender entfernt',
'journey.contributors.removeFailed': 'Entfernen fehlgeschlagen',
'journey.share.publicShare': 'Öffentlicher Link',
'journey.share.createLink': 'Link erstellen',
'journey.share.linkCreated': 'Link erstellt',
@@ -2033,6 +2116,11 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Name',
'journey.settings.subtitle': 'Untertitel',
'journey.settings.subtitlePlaceholder': 'z.B. Thailand, Vietnam & Kambodscha',
'journey.settings.endJourney': 'Reise archivieren',
'journey.settings.reopenJourney': 'Reise wiederherstellen',
'journey.settings.archived': 'Reise archiviert',
'journey.settings.reopened': 'Reise erneut geöffnet',
'journey.settings.endDescription': 'Blendet das Live-Abzeichen aus. Sie können jederzeit wieder öffnen.',
'journey.settings.delete': 'Löschen',
'journey.settings.deleteJourney': 'Journey löschen',
'journey.settings.deleteMessage': '"{title}" löschen? Alle Einträge und Fotos gehen verloren.',
@@ -2167,6 +2255,55 @@ 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.',
'transport.addTransport': 'Add transport',
'transport.modalTitle.create': 'Add transport',
'transport.modalTitle.edit': 'Edit transport',
'transport.title': 'Transporte',
'transport.addManual': 'Manuelles Transportmittel',
}
export default de
+142 -3
View File
@@ -4,11 +4,15 @@ 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',
'common.loading': 'Loading...',
'common.import': 'Import',
'common.select': 'Select',
'common.selectAll': 'Select all',
'common.deselectAll': 'Deselect all',
'common.error': 'Error',
'common.unknownError': 'Unknown error',
'common.tooManyAttempts': 'Too many attempts. Please try again later.',
@@ -175,6 +179,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',
@@ -209,7 +215,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.ntfyUrl.test': 'Test',
'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully',
'settings.ntfyUrl.testFailed': 'Test ntfy notification failed',
'settings.ntfyUrl.clearToken': 'Clear',
'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.',
@@ -217,6 +222,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -238,16 +244,22 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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.notifications.tripReminders.title': 'Trip Reminders',
'admin.notifications.tripReminders.hint': 'Send a reminder notification before a trip starts (requires reminder days to be set on the trip).',
'admin.notifications.tripReminders.enabled': 'Trip reminders enabled',
'admin.notifications.tripReminders.disabled': 'Trip reminders disabled',
'admin.smtp.title': 'Email & Notifications',
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
'admin.smtp.testButton': 'Send test email',
@@ -363,6 +375,16 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'Feature Request',
'settings.about.featureRequestHint': 'Suggest a new feature',
'settings.about.wikiHint': 'Documentation & guides',
'settings.about.supporters.badge': 'Monthly Supporters',
'settings.about.supporters.title': 'Travel companions for TREK',
'settings.about.supporters.subtitle': "While you're planning your next route, these folks are helping plan TREK's future. Their monthly contribution goes straight into development and real hours spent — so TREK stays Open Source.",
'settings.about.supporters.since': 'supporter since {date}',
'settings.about.supporters.tierEmpty': 'Be the first',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK is a self-hosted travel planner that helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place, on your own server.',
'settings.about.madeWith': 'Made with',
'settings.about.madeBy': 'by Maurice and a growing open-source community.',
@@ -602,10 +624,30 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesFormat': 'Comma-separated extensions (e.g. jpg,png,pdf,doc). Use * to allow all types.',
'admin.fileTypesSaved': 'File type settings saved',
'admin.placesPhotos.title': 'Place Photos',
'admin.placesPhotos.subtitle': 'Fetch photos from the Google Places API. Disable to save API quota. Wikimedia photos are unaffected.',
'admin.placesAutocomplete.title': 'Place Autocomplete',
'admin.placesAutocomplete.subtitle': 'Use the Google Places API for search suggestions. Disable to save API quota.',
'admin.placesDetails.title': 'Place Details',
'admin.placesDetails.subtitle': 'Fetch detailed place information (hours, rating, website) from the Google Places API. Disable to save API quota.',
// 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',
@@ -888,6 +930,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
// Trip Planner
'trip.tabs.plan': 'Plan',
'trip.tabs.transports': 'Transports',
'trip.tabs.reservations': 'Bookings',
'trip.tabs.reservationsShort': 'Book',
'trip.tabs.packing': 'Packing List',
@@ -910,6 +953,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'trip.toast.reservationAdded': 'Reservation added',
'trip.toast.deleted': 'Deleted',
'trip.confirm.deletePlace': 'Are you sure you want to delete this place?',
'trip.confirm.deletePlaces': 'Delete {count} places?',
'trip.toast.placesDeleted': '{count} places deleted',
// Day Plan Sidebar
'dayplan.emptyDay': 'No places planned for this day',
@@ -920,6 +965,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'dayplan.cannotDropOnTimed': 'Items cannot be placed between time-bound entries',
'dayplan.cannotBreakChronology': 'This would break the chronological order of timed items and bookings',
'dayplan.addNote': 'Add Note',
'dayplan.expandAll': 'Expand all days',
'dayplan.collapseAll': 'Collapse all days',
'dayplan.editNote': 'Edit Note',
'dayplan.noteAdd': 'Add Note',
'dayplan.noteEdit': 'Edit Note',
@@ -954,6 +1001,17 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'places.importFileError': 'Import failed',
'places.importAllSkipped': 'All places were already in the trip.',
'places.gpxImported': '{count} places imported from GPX',
'places.gpxImportTypes': 'What do you want to import?',
'places.gpxImportWaypoints': 'Waypoints',
'places.gpxImportRoutes': 'Routes',
'places.gpxImportTracks': 'Tracks (with path geometry)',
'places.gpxImportNoneSelected': 'Select at least one type to import.',
'places.kmlImportTypes': 'What do you want to import?',
'places.kmlImportPoints': 'Points (Placemarks)',
'places.kmlImportPaths': 'Paths (LineStrings)',
'places.kmlImportNoneSelected': 'Select at least one type to import.',
'places.selectionCount': '{count} selected',
'places.deleteSelected': 'Delete selected',
'places.kmlKmzImported': '{count} places imported from KMZ/KML',
'places.urlResolved': 'Place imported from URL',
'places.importList': 'List Import',
@@ -970,6 +1028,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'places.assignToDay': 'Add to which day?',
'places.all': 'All',
'places.unplanned': 'Unplanned',
'places.filterTracks': 'Tracks',
'places.search': 'Search places...',
'places.allCategories': 'All Categories',
'places.categoriesSelected': 'categories',
@@ -1053,10 +1112,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',
@@ -1070,7 +1137,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.hotel': 'Accommodation',
'reservations.type.restaurant': 'Restaurant',
'reservations.type.train': 'Train',
'reservations.type.car': 'Rental Car',
'reservations.type.car': 'Car',
'reservations.type.cruise': 'Cruise',
'reservations.type.event': 'Event',
'reservations.type.tour': 'Tour',
@@ -1131,6 +1198,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'reservations.span.end': 'End',
'reservations.span.ongoing': 'Ongoing',
'reservations.validation.endBeforeStart': 'End date/time must be after start date/time',
'reservations.addBooking': 'Add booking',
// Budget
'budget.title': 'Budget',
@@ -1541,6 +1609,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',
@@ -1569,6 +1638,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'memories.providerPassword': 'Password',
'memories.providerOTP': 'MFA code (if enabled)',
'memories.skipSSLVerification': 'Skip SSL certificate verification',
'memories.immichAutoUpload': 'Mirror journey photos to Immich on upload',
'memories.providerUrlHintSynology': 'Include the Photos app path in the URL, e.g. https://nas:5001/photo',
'memories.testConnection': 'Test connection',
'memories.testFirst': 'Test connection first',
@@ -1743,6 +1813,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'undo.reorder': 'Places reordered',
'undo.optimize': 'Route optimized',
'undo.deletePlace': 'Place deleted',
'undo.deletePlaces': 'Places deleted',
'undo.moveDay': 'Place moved to another day',
'undo.lock': 'Place lock toggled',
'undo.importGpx': 'GPX import',
@@ -1799,7 +1870,11 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'Unassigned',
'todo.noCategory': 'No category',
'todo.hasDescription': 'Has description',
'todo.addItem': 'Add new task...',
'todo.addItem': 'Add new task',
'todo.sidebar.sortBy': 'Sort by',
'todo.priority': 'Priority',
'todo.newCategoryLabel': 'new',
'budget.categoriesLabel': 'categories',
'todo.newCategory': 'Category name',
'todo.addCategory': 'Add category',
'todo.newItem': 'New task',
@@ -1858,6 +1933,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'notif.dev.unknown_event.text': 'Event type "{event}" is not registered in EVENT_NOTIFICATION_CONFIG',
// Journey addon
'journey.search.placeholder': 'Search journeys…',
'journey.search.noResults': 'No journeys match "{query}"',
'journey.title': 'Journey',
'journey.subtitle': 'Track your travels as they happen',
'journey.new': 'New Journey',
@@ -1879,6 +1956,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Active',
'journey.status.completed': 'Completed',
'journey.status.upcoming': 'Upcoming',
'journey.status.archived': 'Archived',
'journey.checkin.add': 'Check in',
'journey.checkin.namePlaceholder': 'Location name',
'journey.checkin.notesPlaceholder': 'Notes (optional)',
@@ -1939,6 +2017,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',
@@ -1967,6 +2046,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.synced.synced': 'synced',
// Journey Entry Editor
'journey.editor.discardChangesConfirm': 'You have unsaved changes. Discard them?',
'journey.editor.uploadPhotos': 'Upload photos',
'journey.editor.uploading': 'Uploading...',
'journey.editor.fromGallery': 'From Gallery',
@@ -2025,6 +2105,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.contributors.role': 'Role',
'journey.contributors.added': 'Contributor added',
'journey.contributors.addFailed': 'Failed to add contributor',
'journey.contributors.remove': 'Remove contributor',
'journey.contributors.removeConfirm': 'Remove {username} from this journey?',
'journey.contributors.removed': 'Contributor removed',
'journey.contributors.removeFailed': 'Failed to remove contributor',
// Journey — Share
'journey.share.publicShare': 'Public Share',
@@ -2056,6 +2140,11 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Name',
'journey.settings.subtitle': 'Subtitle',
'journey.settings.subtitlePlaceholder': 'e.g. Thailand, Vietnam & Cambodia',
'journey.settings.endJourney': 'Archive Journey',
'journey.settings.reopenJourney': 'Restore Journey',
'journey.settings.archived': 'Journey archived',
'journey.settings.reopened': 'Journey reopened',
'journey.settings.endDescription': 'Hides the Live badge. You can reopen anytime.',
'journey.settings.delete': 'Delete',
'journey.settings.deleteJourney': 'Delete Journey',
'journey.settings.deleteMessage': 'Delete "{title}"? All entries and photos will be lost.',
@@ -2203,6 +2292,56 @@ 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}',
'transport.addTransport': 'Add transport',
'transport.modalTitle.create': 'Add transport',
'transport.modalTitle.edit': 'Edit transport',
'transport.title': 'Transports',
'transport.addManual': 'Manual Transport',
}
export default en
+134 -3
View File
@@ -4,11 +4,15 @@ 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',
'common.loading': 'Cargando...',
'common.import': 'Importar',
'common.select': 'Seleccionar',
'common.selectAll': 'Seleccionar todo',
'common.deselectAll': 'Deseleccionar todo',
'common.error': 'Error',
'common.unknownError': 'Error desconocido',
'common.tooManyAttempts': 'Demasiados intentos. Inténtelo de nuevo más tarde.',
@@ -308,6 +312,16 @@ const es: Record<string, string> = {
'settings.about.featureRequest': 'Solicitar función',
'settings.about.featureRequestHint': 'Sugiere una nueva función',
'settings.about.wikiHint': 'Documentación y guías',
'settings.about.supporters.badge': 'Patrocinadores Mensuales',
'settings.about.supporters.title': 'Compañía de viaje para TREK',
'settings.about.supporters.subtitle': 'Mientras planeas tu próxima ruta, estas personas ayudan a planear el futuro de TREK. Su aporte mensual va directo al desarrollo y a las horas reales invertidas — para que TREK siga siendo Open Source.',
'settings.about.supporters.since': 'patrocinador desde {date}',
'settings.about.supporters.tierEmpty': 'Sé el primero',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK es un planificador de viajes autoalojado que te ayuda a organizar tus viajes desde la primera idea hasta el último recuerdo. Planificación diaria, presupuesto, listas de equipaje, fotos y mucho más — todo en un solo lugar, en tu propio servidor.',
'settings.about.madeWith': 'Hecho con',
'settings.about.madeBy': 'por Maurice y una creciente comunidad de código abierto.',
@@ -540,9 +554,29 @@ const es: Record<string, string> = {
'admin.fileTypesFormat': 'Extensiones separadas por comas (p. ej. jpg,png,pdf,doc). Usa * para permitir todos los tipos.',
'admin.fileTypesSaved': 'Ajustes de tipos de archivo guardados',
'admin.placesPhotos.title': 'Fotos de Lugares',
'admin.placesPhotos.subtitle': 'Obtiene fotos de la Google Places API. Desactiva para ahorrar cuota de API. Las fotos de Wikimedia no se ven afectadas.',
'admin.placesAutocomplete.title': 'Autocompletado de Lugares',
'admin.placesAutocomplete.subtitle': 'Usa la Google Places API para sugerencias de búsqueda. Desactiva para ahorrar cuota de API.',
'admin.placesDetails.title': 'Detalles del Lugar',
'admin.placesDetails.subtitle': 'Obtiene información detallada del lugar (horarios, valoración, web) de la Google Places API. Desactiva para ahorrar cuota de API.',
'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',
@@ -809,6 +843,7 @@ const es: Record<string, string> = {
// Trip Planner
'trip.tabs.plan': 'Plan',
'trip.tabs.transports': 'Transportes',
'trip.tabs.reservations': 'Reservas',
'trip.tabs.reservationsShort': 'Reservas',
'trip.tabs.packing': 'Lista de equipaje',
@@ -831,6 +866,8 @@ const es: Record<string, string> = {
'trip.toast.reservationAdded': 'Reserva añadida',
'trip.toast.deleted': 'Eliminado',
'trip.confirm.deletePlace': '¿Seguro que quieres eliminar este lugar?',
'trip.confirm.deletePlaces': '¿Eliminar {count} lugares?',
'trip.toast.placesDeleted': '{count} lugares eliminados',
// Day Plan Sidebar
'dayplan.emptyDay': 'No hay lugares planificados para este día',
@@ -875,6 +912,17 @@ const es: Record<string, string> = {
'places.importFileError': 'Importación fallida',
'places.importAllSkipped': 'Todos los lugares ya estaban en el viaje.',
'places.gpxImported': '{count} lugares importados desde GPX',
'places.gpxImportTypes': '¿Qué deseas importar?',
'places.gpxImportWaypoints': 'Puntos de ruta',
'places.gpxImportRoutes': 'Rutas',
'places.gpxImportTracks': 'Tracks (con geometría de ruta)',
'places.gpxImportNoneSelected': 'Selecciona al menos un tipo para importar.',
'places.kmlImportTypes': '¿Qué deseas importar?',
'places.kmlImportPoints': 'Puntos (Placemarks)',
'places.kmlImportPaths': 'Rutas (LineStrings)',
'places.kmlImportNoneSelected': 'Selecciona al menos un tipo.',
'places.selectionCount': '{count} seleccionado(s)',
'places.deleteSelected': 'Eliminar selección',
'places.kmlKmzImported': '{count} lugares importados desde KMZ/KML',
'places.urlResolved': 'Lugar importado desde URL',
'places.importList': 'Importar lista',
@@ -891,6 +939,7 @@ const es: Record<string, string> = {
'places.assignToDay': '¿A qué día añadirlo?',
'places.all': 'Todo',
'places.unplanned': 'Sin planificar',
'places.filterTracks': 'Rutas',
'places.search': 'Buscar lugares...',
'places.allCategories': 'Todas las categorías',
'places.categoriesSelected': 'categorías',
@@ -975,7 +1024,7 @@ const es: Record<string, string> = {
'reservations.type.hotel': 'Alojamiento',
'reservations.type.restaurant': 'Restaurante',
'reservations.type.train': 'Tren',
'reservations.type.car': 'Coche de alquiler',
'reservations.type.car': 'Coche',
'reservations.type.cruise': 'Crucero',
'reservations.type.event': 'Evento',
'reservations.type.tour': 'Excursión',
@@ -1036,6 +1085,7 @@ const es: Record<string, string> = {
'reservations.span.end': 'Fin',
'reservations.span.ongoing': 'En curso',
'reservations.validation.endBeforeStart': 'La fecha/hora de fin debe ser posterior a la de inicio',
'reservations.addBooking': 'Añadir reserva',
// Budget
'budget.title': 'Presupuesto',
@@ -1439,6 +1489,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',
@@ -1465,6 +1516,7 @@ const es: Record<string, string> = {
'memories.providerPassword': 'Contraseña',
'memories.providerOTP': 'Código MFA (si está habilitado)',
'memories.skipSSLVerification': 'Omitir verificación del certificado SSL',
'memories.immichAutoUpload': 'Duplicar las fotos del journey en Immich al subirlas',
'memories.providerUrlHintSynology': 'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo',
'memories.testConnection': 'Probar conexión',
'memories.testFirst': 'Probar conexión primero',
@@ -1602,10 +1654,20 @@ const es: Record<string, string> = {
'reservations.meta.flightNumber': 'N° de vuelo',
'reservations.meta.from': 'Desde',
'reservations.meta.to': 'Hasta',
'reservations.needsReview': 'Revisar',
'reservations.needsReviewHint': 'No se pudo identificar el aeropuerto automáticamente — por favor confirma la ubicación.',
'reservations.searchLocation': 'Buscar estación, puerto, dirección...',
'airport.searchPlaceholder': 'Código o ciudad del aeropuerto (ej. FRA)',
'map.connections': 'Conexiones',
'map.showConnections': 'Mostrar rutas de reservas',
'map.hideConnections': 'Ocultar rutas de reservas',
'settings.bookingLabels': 'Etiquetas de rutas de reservas',
'settings.bookingLabelsHint': 'Muestra nombres de estaciones / aeropuertos en el mapa. Desactivado, solo se muestra el icono.',
'reservations.meta.trainNumber': 'N° de tren',
'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',
@@ -1682,6 +1744,7 @@ const es: Record<string, string> = {
'undo.reorder': 'Lugares reordenados',
'undo.optimize': 'Ruta optimizada',
'undo.deletePlace': 'Lugar eliminado',
'undo.deletePlaces': 'Lugares eliminados',
'undo.moveDay': 'Lugar movido a otro día',
'undo.lock': 'Bloqueo de lugar activado/desactivado',
'undo.importGpx': 'Importación GPX',
@@ -1741,7 +1804,11 @@ const es: Record<string, string> = {
'todo.unassigned': 'Sin asignar',
'todo.noCategory': 'Sin categoría',
'todo.hasDescription': 'Con descripción',
'todo.addItem': 'Añadir nueva tarea...',
'todo.addItem': 'Nueva tarea',
'todo.sidebar.sortBy': 'Ordenar por',
'todo.priority': 'Prioridad',
'todo.newCategoryLabel': 'nueva',
'budget.categoriesLabel': 'categorías',
'todo.newCategory': 'Nombre de la categoría',
'todo.addCategory': 'Añadir categoría',
'todo.newItem': 'Nueva tarea',
@@ -1783,7 +1850,6 @@ const es: Record<string, string> = {
'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.clearToken': 'Borrar',
'settings.ntfyUrl.tokenCleared': 'Token de acceso eliminado',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1800,22 +1866,29 @@ const es: Record<string, string> = {
'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.notifications.tripReminders.title': 'Recordatorios de viaje',
'admin.notifications.tripReminders.hint': 'Envía una notificación de recordatorio antes de que comience un viaje (requiere días de recordatorio configurados en el viaje).',
'admin.notifications.tripReminders.enabled': 'Recordatorios de viaje activados',
'admin.notifications.tripReminders.disabled': 'Recordatorios de viaje desactivados',
'admin.tabs.notifications': 'Notificaciones',
'notifications.versionAvailable.title': 'Actualización disponible',
'notifications.versionAvailable.text': 'TREK {version} ya está disponible.',
@@ -1860,6 +1933,8 @@ const es: Record<string, string> = {
'common.justNow': 'justo ahora',
'common.hoursAgo': 'hace {count}h',
'common.daysAgo': 'hace {count}d',
'journey.search.placeholder': 'Buscar viajes…',
'journey.search.noResults': 'Ningún viaje coincide con "{query}"',
'journey.title': 'Travesía',
'journey.subtitle': 'Registra tus viajes en tiempo real',
'journey.new': 'Nueva travesía',
@@ -1881,6 +1956,7 @@ const es: Record<string, string> = {
'journey.status.active': 'Activa',
'journey.status.completed': 'Completada',
'journey.status.upcoming': 'Próxima',
'journey.status.archived': 'Archivado',
'journey.checkin.add': 'Registrar ubicación',
'journey.checkin.namePlaceholder': 'Nombre del lugar',
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
@@ -1957,6 +2033,7 @@ const es: Record<string, string> = {
'journey.verdict.couldBeBetter': 'Podría mejorar',
'journey.synced.places': 'lugares',
'journey.synced.synced': 'sincronizado',
'journey.editor.discardChangesConfirm': 'Tienes cambios sin guardar. ¿Descartarlos?',
'journey.editor.uploadPhotos': 'Subir fotos',
'journey.editor.uploading': 'Subiendo...',
'journey.editor.fromGallery': 'Desde galería',
@@ -2034,6 +2111,11 @@ const es: Record<string, string> = {
'journey.settings.name': 'Nombre',
'journey.settings.subtitle': 'Subtítulo',
'journey.settings.subtitlePlaceholder': 'p. ej. Tailandia, Vietnam y Camboya',
'journey.settings.endJourney': 'Archivar viaje',
'journey.settings.reopenJourney': 'Restaurar viaje',
'journey.settings.archived': 'Viaje archivado',
'journey.settings.reopened': 'Viaje reabierto',
'journey.settings.endDescription': 'Oculta la insignia En Vivo. Puedes reabrirlo en cualquier momento.',
'journey.settings.delete': 'Eliminar',
'journey.settings.deleteJourney': 'Eliminar travesía',
'journey.settings.deleteMessage': '¿Eliminar "{title}"? Todas las entradas y fotos se perderán.',
@@ -2169,6 +2251,55 @@ 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.',
'transport.addTransport': 'Add transport',
'transport.modalTitle.create': 'Add transport',
'transport.modalTitle.edit': 'Edit transport',
'transport.title': 'Transportes',
'transport.addManual': 'Transporte manual',
}
export default es
+134 -3
View File
@@ -4,11 +4,15 @@ 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',
'common.loading': 'Chargement…',
'common.import': 'Importer',
'common.select': 'Sélectionner',
'common.selectAll': 'Tout sélectionner',
'common.deselectAll': 'Tout désélectionner',
'common.error': 'Erreur',
'common.unknownError': 'Erreur inconnue',
'common.tooManyAttempts': 'Trop de tentatives. Veuillez réessayer plus tard.',
@@ -307,6 +311,16 @@ const fr: Record<string, string> = {
'settings.about.featureRequest': 'Proposer une fonctionnalité',
'settings.about.featureRequestHint': 'Suggérez une nouvelle fonctionnalité',
'settings.about.wikiHint': 'Documentation et guides',
'settings.about.supporters.badge': 'Soutiens Mensuels',
'settings.about.supporters.title': 'Compagnons de voyage pour TREK',
'settings.about.supporters.subtitle': 'Pendant que tu planifies ton prochain itinéraire, ces personnes aident à planifier l\'avenir de TREK. Leur contribution mensuelle va directement au développement et aux heures réellement passées — pour que TREK reste Open Source.',
'settings.about.supporters.since': 'soutien depuis {date}',
'settings.about.supporters.tierEmpty': 'Sois le premier',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK est un planificateur de voyage auto-hébergé qui vous aide à organiser vos voyages de la première idée au dernier souvenir. Planification journalière, budget, listes de bagages, photos et bien plus — le tout au même endroit, sur votre propre serveur.',
'settings.about.madeWith': 'Fait avec',
'settings.about.madeBy': 'par Maurice et une communauté open-source grandissante.',
@@ -544,9 +558,29 @@ const fr: Record<string, string> = {
'admin.fileTypesFormat': 'Extensions séparées par des virgules (ex. jpg,png,pdf,doc). Utilisez * pour autoriser tous les types.',
'admin.fileTypesSaved': 'Paramètres des types de fichiers enregistrés',
'admin.placesPhotos.title': 'Photos de lieux',
'admin.placesPhotos.subtitle': "Récupère les photos depuis l'API Google Places. Désactivez pour économiser le quota API. Les photos Wikimedia ne sont pas affectées.",
'admin.placesAutocomplete.title': 'Autocomplétion des lieux',
'admin.placesAutocomplete.subtitle': "Utilise l'API Google Places pour les suggestions de recherche. Désactivez pour économiser le quota API.",
'admin.placesDetails.title': 'Détails du lieu',
'admin.placesDetails.subtitle': "Récupère les informations détaillées du lieu (horaires, note, site web) depuis l'API Google Places. Désactivez pour économiser le quota API.",
'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',
@@ -833,6 +867,7 @@ const fr: Record<string, string> = {
// Trip Planner
'trip.tabs.plan': 'Plan',
'trip.tabs.transports': 'Transports',
'trip.tabs.reservations': 'Réservations',
'trip.tabs.reservationsShort': 'Résa',
'trip.tabs.packing': 'Liste de bagages',
@@ -855,6 +890,8 @@ const fr: Record<string, string> = {
'trip.toast.reservationAdded': 'Réservation ajoutée',
'trip.toast.deleted': 'Supprimé',
'trip.confirm.deletePlace': 'Voulez-vous vraiment supprimer ce lieu ?',
'trip.confirm.deletePlaces': 'Supprimer {count} lieux?',
'trip.toast.placesDeleted': '{count} lieux supprimés',
// Day Plan Sidebar
'dayplan.emptyDay': 'Aucun lieu prévu pour ce jour',
@@ -899,6 +936,17 @@ const fr: Record<string, string> = {
'places.importFileError': 'Importation échouée',
'places.importAllSkipped': 'Tous les lieux étaient déjà dans le voyage.',
'places.gpxImported': '{count} lieux importés depuis GPX',
'places.gpxImportTypes': 'Que voulez-vous importer?',
'places.gpxImportWaypoints': 'Points de passage',
'places.gpxImportRoutes': 'Itinéraires',
'places.gpxImportTracks': 'Traces (avec géométrie)',
'places.gpxImportNoneSelected': 'Sélectionnez au moins un type à importer.',
'places.kmlImportTypes': 'Que souhaitez-vous importer ?',
'places.kmlImportPoints': 'Points (Placemarks)',
'places.kmlImportPaths': 'Chemins (LineStrings)',
'places.kmlImportNoneSelected': 'Sélectionnez au moins un type.',
'places.selectionCount': '{count} sélectionné(s)',
'places.deleteSelected': 'Supprimer la sélection',
'places.kmlKmzImported': '{count} lieux importés depuis KMZ/KML',
'places.urlResolved': 'Lieu importé depuis l\'URL',
'places.importList': 'Import de liste',
@@ -915,6 +963,7 @@ const fr: Record<string, string> = {
'places.assignToDay': 'Ajouter à quel jour ?',
'places.all': 'Tous',
'places.unplanned': 'Non planifiés',
'places.filterTracks': 'Traces',
'places.search': 'Rechercher des lieux…',
'places.allCategories': 'Toutes les catégories',
'places.categoriesSelected': 'catégories',
@@ -998,10 +1047,20 @@ const fr: Record<string, string> = {
'reservations.meta.flightNumber': 'N° de vol',
'reservations.meta.from': 'De',
'reservations.meta.to': 'À',
'reservations.needsReview': 'Vérifier',
'reservations.needsReviewHint': 'L\'aéroport n\'a pas pu être identifié automatiquement — veuillez confirmer l\'emplacement.',
'reservations.searchLocation': 'Rechercher une gare, un port, une adresse…',
'airport.searchPlaceholder': 'Code ou ville de l\'aéroport (ex. FRA)',
'map.connections': 'Connexions',
'map.showConnections': 'Afficher les itinéraires',
'map.hideConnections': 'Masquer les itinéraires',
'settings.bookingLabels': 'Étiquettes des itinéraires',
'settings.bookingLabelsHint': 'Affiche les noms des gares / aéroports sur la carte. Si désactivé, seule l\'icône est affichée.',
'reservations.meta.trainNumber': 'N° de train',
'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',
@@ -1015,7 +1074,7 @@ const fr: Record<string, string> = {
'reservations.type.hotel': 'Hébergement',
'reservations.type.restaurant': 'Restaurant',
'reservations.type.train': 'Train',
'reservations.type.car': 'Voiture de location',
'reservations.type.car': 'Voiture',
'reservations.type.cruise': 'Croisière',
'reservations.type.event': 'Événement',
'reservations.type.tour': 'Visite',
@@ -1076,6 +1135,7 @@ const fr: Record<string, string> = {
'reservations.span.end': 'Fin',
'reservations.span.ongoing': 'En cours',
'reservations.validation.endBeforeStart': 'La date/heure de fin doit être postérieure à la date/heure de début',
'reservations.addBooking': 'Ajouter une réservation',
// Budget
'budget.title': 'Budget',
@@ -1486,6 +1546,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',
@@ -1512,6 +1573,7 @@ const fr: Record<string, string> = {
'memories.providerPassword': 'Mot de passe',
'memories.providerOTP': 'Code MFA (si activé)',
'memories.skipSSLVerification': 'Ignorer la vérification du certificat SSL',
'memories.immichAutoUpload': 'Répliquer les photos du journey vers Immich au téléversement',
'memories.providerUrlHintSynology': 'Incluez le chemin de l\'application Photos dans l\'URL, ex. https://nas:5001/photo',
'memories.testConnection': 'Tester la connexion',
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
@@ -1676,6 +1738,7 @@ const fr: Record<string, string> = {
'undo.reorder': 'Lieux réorganisés',
'undo.optimize': 'Itinéraire optimisé',
'undo.deletePlace': 'Lieu supprimé',
'undo.deletePlaces': 'Lieux supprimés',
'undo.moveDay': 'Lieu déplacé vers un autre jour',
'undo.lock': 'Verrouillage du lieu modifié',
'undo.importGpx': 'Import GPX',
@@ -1735,7 +1798,11 @@ const fr: Record<string, string> = {
'todo.unassigned': 'Non assigné',
'todo.noCategory': 'Aucune catégorie',
'todo.hasDescription': 'Avec description',
'todo.addItem': 'Ajouter une tâche...',
'todo.addItem': 'Nouvelle tâche',
'todo.sidebar.sortBy': 'Trier par',
'todo.priority': 'Priorité',
'todo.newCategoryLabel': 'nouvelle',
'budget.categoriesLabel': 'catégories',
'todo.newCategory': 'Nom de la catégorie',
'todo.addCategory': 'Ajouter une catégorie',
'todo.newItem': 'Nouvelle tâche',
@@ -1777,7 +1844,6 @@ const fr: Record<string, string> = {
'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.clearToken': 'Effacer',
'settings.ntfyUrl.tokenCleared': "Jeton d'accès effacé",
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1794,22 +1860,29 @@ const fr: Record<string, string> = {
'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.notifications.tripReminders.title': 'Rappels de voyage',
'admin.notifications.tripReminders.hint': 'Envoie une notification de rappel avant le début d\'un voyage (nécessite des jours de rappel définis sur le voyage).',
'admin.notifications.tripReminders.enabled': 'Rappels de voyage activés',
'admin.notifications.tripReminders.disabled': 'Rappels de voyage désactivés',
'admin.tabs.notifications': 'Notifications',
'notifications.versionAvailable.title': 'Mise à jour disponible',
'notifications.versionAvailable.text': 'TREK {version} est maintenant disponible.',
@@ -1854,6 +1927,8 @@ const fr: Record<string, string> = {
'common.justNow': 'à l\'instant',
'common.hoursAgo': 'il y a {count}h',
'common.daysAgo': 'il y a {count}j',
'journey.search.placeholder': 'Rechercher des journaux…',
'journey.search.noResults': 'Aucun journal ne correspond à « {query} »',
'journey.title': 'Journal de voyage',
'journey.subtitle': 'Suivez vos voyages en temps réel',
'journey.new': 'Nouveau journal',
@@ -1875,6 +1950,7 @@ const fr: Record<string, string> = {
'journey.status.active': 'Actif',
'journey.status.completed': 'Terminé',
'journey.status.upcoming': 'À venir',
'journey.status.archived': 'Archivé',
'journey.checkin.add': 'Check-in',
'journey.checkin.namePlaceholder': 'Nom du lieu',
'journey.checkin.notesPlaceholder': 'Notes (facultatif)',
@@ -1951,6 +2027,7 @@ const fr: Record<string, string> = {
'journey.verdict.couldBeBetter': 'Pourrait être mieux',
'journey.synced.places': 'lieux',
'journey.synced.synced': 'synchronisé',
'journey.editor.discardChangesConfirm': 'Vous avez des modifications non enregistrées. Les ignorer ?',
'journey.editor.uploadPhotos': 'Téléverser des photos',
'journey.editor.uploading': 'Envoi...',
'journey.editor.fromGallery': 'Depuis la galerie',
@@ -2028,6 +2105,11 @@ const fr: Record<string, string> = {
'journey.settings.name': 'Nom',
'journey.settings.subtitle': 'Sous-titre',
'journey.settings.subtitlePlaceholder': 'ex. Thaïlande, Vietnam et Cambodge',
'journey.settings.endJourney': 'Archiver le journal',
'journey.settings.reopenJourney': 'Restaurer le journal',
'journey.settings.archived': 'Journal archivé',
'journey.settings.reopened': 'Journal rouvert',
'journey.settings.endDescription': 'Masque l\'indicateur En direct. Vous pouvez rouvrir à tout moment.',
'journey.settings.delete': 'Supprimer',
'journey.settings.deleteJourney': 'Supprimer le journal',
'journey.settings.deleteMessage': 'Supprimer « {title} » ? Toutes les entrées et photos seront perdues.',
@@ -2163,6 +2245,55 @@ 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.',
'transport.addTransport': 'Add transport',
'transport.modalTitle.create': 'Add transport',
'transport.modalTitle.edit': 'Edit transport',
'transport.title': 'Transports',
'transport.addManual': 'Transport manuel',
}
export default fr
+134 -3
View File
@@ -4,11 +4,15 @@ 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',
'common.loading': 'Betöltés...',
'common.import': 'Importálás',
'common.select': 'Kiválaszt',
'common.selectAll': 'Mindet kiválaszt',
'common.deselectAll': 'Összes kijelölés megszüntetése',
'common.error': 'Hiba',
'common.unknownError': 'Ismeretlen hiba',
'common.tooManyAttempts': 'Túl sok próbálkozás. Kérjük, próbálja újra később.',
@@ -262,6 +266,16 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'Funkció javaslat',
'settings.about.featureRequestHint': 'Javasolj egy új funkciót',
'settings.about.wikiHint': 'Dokumentáció és útmutatók',
'settings.about.supporters.badge': 'Havi támogatók',
'settings.about.supporters.title': 'Útitársak a TREK mellett',
'settings.about.supporters.subtitle': 'Miközben te a következő útvonaladat tervezed, ők a TREK jövőjét tervezik velem együtt. Havi hozzájárulásuk közvetlenül fejlesztésre és valódi órákra fordítódik — hogy a TREK Open Source maradhasson.',
'settings.about.supporters.since': 'támogató {date} óta',
'settings.about.supporters.tierEmpty': 'Légy az első',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'A TREK egy saját szerveren üzemeltetett útitervező, amely segít az utazásaid megszervezésében az első ötlettől az utolsó emlékig. Napi tervezés, költségvetés, csomagolási listák, fotók és még sok más — minden egy helyen, a saját szervereden.',
'settings.about.madeWith': 'Készítve',
'settings.about.madeBy': 'Maurice és egy növekvő nyílt forráskódú közösség által.',
@@ -545,9 +559,29 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'Fájltípus-beállítások mentve',
// Csomagolási sablonok és poggyászkövetés
'admin.placesPhotos.title': 'Helyfotók',
'admin.placesPhotos.subtitle': 'Fotók lekérése a Google Places API-ból. Tiltsa le az API-kvóta megtakarításához. A Wikimedia-fotók nem érintettek.',
'admin.placesAutocomplete.title': 'Hely automatikus kiegészítése',
'admin.placesAutocomplete.subtitle': 'A Google Places API használata keresési javaslatokhoz. Tiltsa le az API-kvóta megtakarításához.',
'admin.placesDetails.title': 'Hely részletei',
'admin.placesDetails.subtitle': 'Részletes helyinformációk lekérése (nyitvatartás, értékelés, weboldal) a Google Places API-ból. Tiltsa le az API-kvóta megtakarításához.',
'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',
@@ -834,6 +868,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
// Utazástervező
'trip.tabs.plan': 'Terv',
'trip.tabs.transports': 'Közlekedés',
'trip.tabs.reservations': 'Foglalások',
'trip.tabs.reservationsShort': 'Foglalás',
'trip.tabs.packing': 'Csomagolási lista',
@@ -855,6 +890,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'trip.toast.reservationAdded': 'Foglalás hozzáadva',
'trip.toast.deleted': 'Törölve',
'trip.confirm.deletePlace': 'Biztosan törölni szeretnéd ezt a helyet?',
'trip.confirm.deletePlaces': '{count} helyet töröl?',
'trip.toast.placesDeleted': '{count} hely törölve',
'trip.loadingPhotos': 'Helyek fotóinak betöltése...',
// Napi terv oldalsáv
@@ -900,6 +937,17 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'places.importFileError': 'Importálás sikertelen',
'places.importAllSkipped': 'Minden hely már szerepel az utazásban.',
'places.gpxImported': '{count} hely importálva GPX-ből',
'places.gpxImportTypes': 'Mit szeretnél importálni?',
'places.gpxImportWaypoints': 'Útpontok',
'places.gpxImportRoutes': 'Útvonalak',
'places.gpxImportTracks': 'Nyomvonalak (útvonalgeometriával)',
'places.gpxImportNoneSelected': 'Válassz legalább egy típust az importáláshoz.',
'places.kmlImportTypes': 'Mit szeretnél importálni?',
'places.kmlImportPoints': 'Pontok (Placemarks)',
'places.kmlImportPaths': 'Útvonalak (LineStrings)',
'places.kmlImportNoneSelected': 'Válassz legalább egy típust.',
'places.selectionCount': '{count} kiválasztva',
'places.deleteSelected': 'Kijelöltek törlése',
'places.kmlKmzImported': '{count} hely importálva KMZ/KML-ből',
'places.urlResolved': 'Hely importálva URL-ből',
'places.importList': 'Lista importálás',
@@ -916,6 +964,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'places.assignToDay': 'Melyik naphoz adod?',
'places.all': 'Összes',
'places.unplanned': 'Nem tervezett',
'places.filterTracks': 'Nyomvonalak',
'places.search': 'Helyek keresése...',
'places.allCategories': 'Összes kategória',
'places.categoriesSelected': 'kategória',
@@ -1000,10 +1049,20 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.flightNumber': 'Járatszám',
'reservations.meta.from': 'Honnan',
'reservations.meta.to': 'Hová',
'reservations.needsReview': 'Ellenőrzés',
'reservations.needsReviewHint': 'A repülőteret nem sikerült automatikusan azonosítani — erősítsd meg a helyet.',
'reservations.searchLocation': 'Állomás, kikötő, cím keresése...',
'airport.searchPlaceholder': 'Repülőtér kódja vagy város (pl. FRA)',
'map.connections': 'Kapcsolatok',
'map.showConnections': 'Foglalási útvonalak megjelenítése',
'map.hideConnections': 'Foglalási útvonalak elrejtése',
'settings.bookingLabels': 'Útvonal-címkék a foglalásokhoz',
'settings.bookingLabelsHint': 'Állomás- / repülőtér-nevek megjelenítése a térképen. Ha ki van kapcsolva, csak az ikon látszik.',
'reservations.meta.trainNumber': 'Vonatszám',
'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',
@@ -1017,7 +1076,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.hotel': 'Szálloda',
'reservations.type.restaurant': 'Étterem',
'reservations.type.train': 'Vonat',
'reservations.type.car': 'Autóbérlés',
'reservations.type.car': 'Autó',
'reservations.type.cruise': 'Hajóút',
'reservations.type.event': 'Esemény',
'reservations.type.tour': 'Túra',
@@ -1077,6 +1136,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'reservations.span.end': 'Vége',
'reservations.span.ongoing': 'Folyamatban',
'reservations.validation.endBeforeStart': 'A befejezés dátuma/időpontja a kezdés utáni kell legyen',
'reservations.addBooking': 'Foglalás hozzáadása',
// Költségvetés
'budget.title': 'Költségvetés',
@@ -1487,6 +1547,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',
@@ -1583,6 +1644,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'memories.providerPassword': 'Jelszó',
'memories.providerOTP': 'MFA kód (ha engedélyezve van)',
'memories.skipSSLVerification': 'SSL tanúsítvány ellenőrzésének kihagyása',
'memories.immichAutoUpload': 'Journey-fotók feltöltésekor másolat Immich-be is',
'memories.providerUrlHintSynology': 'Adja meg a Photos alkalmazás elérési útját az URL-ben, pl. https://nas:5001/photo',
'memories.testConnection': 'Kapcsolat tesztelése',
'memories.testFirst': 'Először teszteld a kapcsolatot',
@@ -1674,6 +1736,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'undo.reorder': 'Helyek átrendezve',
'undo.optimize': 'Útvonal optimalizálva',
'undo.deletePlace': 'Hely törölve',
'undo.deletePlaces': 'Helyek törölve',
'undo.moveDay': 'Hely áthelyezve másik napra',
'undo.lock': 'Hely zárolása váltva',
'undo.importGpx': 'GPX importálás',
@@ -1733,7 +1796,11 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'Nem hozzárendelt',
'todo.noCategory': 'Nincs kategória',
'todo.hasDescription': 'Van leírás',
'todo.addItem': 'Új feladat hozzáadása...',
'todo.addItem': 'Új feladat',
'todo.sidebar.sortBy': 'Rendezés',
'todo.priority': 'Prioritás',
'todo.newCategoryLabel': 'új',
'budget.categoriesLabel': 'kategóriák',
'todo.newCategory': 'Kategória neve',
'todo.addCategory': 'Kategória hozzáadása',
'todo.newItem': 'Új feladat',
@@ -1775,7 +1842,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'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.clearToken': 'Törlés',
'settings.ntfyUrl.tokenCleared': 'Hozzáférési token törölve',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1792,22 +1858,29 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'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.notifications.tripReminders.title': 'Utazási emlékeztetők',
'admin.notifications.tripReminders.hint': 'Emlékeztető értesítést küld az utazás kezdete előtt (az utazásnál megadott emlékeztető napok szükségesek).',
'admin.notifications.tripReminders.enabled': 'Utazási emlékeztetők engedélyezve',
'admin.notifications.tripReminders.disabled': 'Utazási emlékeztetők letiltva',
'admin.tabs.notifications': 'Értesítések',
'notifications.versionAvailable.title': 'Elérhető frissítés',
'notifications.versionAvailable.text': 'A TREK {version} már elérhető.',
@@ -1855,6 +1928,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'memories.saveRouteNotConfigured': 'A mentési útvonal nincs konfigurálva ehhez a szolgáltatóhoz',
'memories.testRouteNotConfigured': 'A tesztútvonal nincs konfigurálva ehhez a szolgáltatóhoz',
'memories.fillRequiredFields': 'Kérjük töltse ki az összes kötelező mezőt',
'journey.search.placeholder': 'Utak keresése…',
'journey.search.noResults': 'Nincs „{query}" kifejezéssel egyező út',
'journey.title': 'Útinaplók',
'journey.subtitle': 'Kövesse nyomon utazásait valós időben',
'journey.new': 'Új útinapló',
@@ -1876,6 +1951,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Aktív',
'journey.status.completed': 'Befejezett',
'journey.status.upcoming': 'Közelgő',
'journey.status.archived': 'Archivált',
'journey.checkin.add': 'Bejelentkezés',
'journey.checkin.namePlaceholder': 'Helyszín neve',
'journey.checkin.notesPlaceholder': 'Jegyzetek (opcionális)',
@@ -1952,6 +2028,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.verdict.couldBeBetter': 'Lehetne jobb',
'journey.synced.places': 'helyszín',
'journey.synced.synced': 'szinkronizálva',
'journey.editor.discardChangesConfirm': 'Mentetlen módosításaid vannak. Elveted?',
'journey.editor.uploadPhotos': 'Fotók feltöltése',
'journey.editor.uploading': 'Feltöltés...',
'journey.editor.fromGallery': 'Galériából',
@@ -2029,6 +2106,11 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Név',
'journey.settings.subtitle': 'Alcím',
'journey.settings.subtitlePlaceholder': 'pl. Thaiföld, Vietnam és Kambodzsa',
'journey.settings.endJourney': 'Út archiválása',
'journey.settings.reopenJourney': 'Út visszaállítása',
'journey.settings.archived': 'Út archiválva',
'journey.settings.reopened': 'Út újranyitva',
'journey.settings.endDescription': 'Elrejti az Élő jelzést. Bármikor újranyitható.',
'journey.settings.delete': 'Törlés',
'journey.settings.deleteJourney': 'Útinapló törlése',
'journey.settings.deleteMessage': 'Törlöd a(z) „{title}" útinaplót? Minden bejegyzés és fotó elveszik.',
@@ -2164,6 +2246,55 @@ 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.',
'transport.addTransport': 'Add transport',
'transport.modalTitle.create': 'Add transport',
'transport.modalTitle.edit': 'Edit transport',
'transport.title': 'Közlekedés',
'transport.addManual': 'Kézi közlekedés',
}
export default hu
+134 -3
View File
@@ -4,11 +4,15 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'common.showMore': 'Tampilkan lebih banyak',
'common.showLess': 'Tampilkan lebih sedikit',
'common.cancel': 'Batal',
'common.clear': 'Hapus',
'common.delete': 'Hapus',
'common.edit': 'Sunting',
'common.add': 'Tambah',
'common.loading': 'Memuat...',
'common.import': 'Impor',
'common.select': 'Pilih',
'common.selectAll': 'Pilih semua',
'common.deselectAll': 'Batalkan semua pilihan',
'common.error': 'Kesalahan',
'common.unknownError': 'Kesalahan tidak diketahui',
'common.tooManyAttempts': 'Terlalu banyak percobaan. Coba lagi nanti.',
@@ -209,7 +213,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'settings.ntfyUrl.test': 'Uji',
'settings.ntfyUrl.testSuccess': 'Notifikasi uji Ntfy berhasil dikirim',
'settings.ntfyUrl.testFailed': 'Notifikasi uji Ntfy gagal',
'settings.ntfyUrl.clearToken': 'Hapus',
'settings.ntfyUrl.tokenCleared': 'Token akses dihapus',
'admin.notifications.title': 'Notifikasi',
'admin.notifications.hint': 'Pilih satu saluran notifikasi. Hanya satu yang bisa aktif sekaligus.',
@@ -232,22 +235,29 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminWebhookPanel.testFailed': 'Test webhook gagal',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook selalu berjalan jika URL dikonfigurasi',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Memungkinkan pengguna mengonfigurasi topik ntfy mereka sendiri untuk notifikasi push. Tetapkan server default di bawah untuk mengisi setelan pengguna secara otomatis.',
'admin.notifications.testNtfy': 'Kirim uji Ntfy',
'admin.notifications.testNtfySuccess': 'Uji Ntfy berhasil dikirim',
'admin.notifications.testNtfyFailed': 'Uji Ntfy gagal',
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
'admin.notifications.adminNtfyPanel.hint': 'Topik Ntfy ini digunakan khusus untuk notifikasi admin (mis. peringatan versi). Terpisah dari topik per pengguna dan selalu berjalan jika dikonfigurasi.',
'admin.notifications.adminNtfyPanel.serverLabel': 'URL Server Ntfy',
'admin.notifications.adminNtfyPanel.serverHint': 'Juga digunakan sebagai server default untuk notifikasi ntfy pengguna. Kosongkan untuk menggunakan ntfy.sh. Pengguna dapat menggantinya di pengaturan mereka sendiri.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Topik Admin',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Token Akses (opsional)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Token akses admin dihapus',
'admin.notifications.adminNtfyPanel.saved': 'Pengaturan Ntfy admin tersimpan',
'admin.notifications.adminNtfyPanel.test': 'Kirim uji Ntfy',
'admin.notifications.adminNtfyPanel.testSuccess': 'Uji Ntfy berhasil dikirim',
'admin.notifications.adminNtfyPanel.testFailed': 'Uji Ntfy gagal',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy selalu berjalan jika topik dikonfigurasi',
'admin.notifications.adminNotificationsHint': 'Atur saluran mana yang mengirimkan notifikasi khusus admin (mis. peringatan versi).',
'admin.notifications.tripReminders.title': 'Pengingat Perjalanan',
'admin.notifications.tripReminders.hint': 'Mengirim notifikasi pengingat sebelum perjalanan dimulai (memerlukan hari pengingat yang diatur pada perjalanan).',
'admin.notifications.tripReminders.enabled': 'Pengingat perjalanan diaktifkan',
'admin.notifications.tripReminders.disabled': 'Pengingat perjalanan dinonaktifkan',
'admin.smtp.title': 'Email & Notifikasi',
'admin.smtp.hint': 'Konfigurasi SMTP untuk pengiriman notifikasi email.',
'admin.smtp.testButton': 'Kirim email uji',
@@ -363,6 +373,16 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'Permintaan Fitur',
'settings.about.featureRequestHint': 'Sarankan fitur baru',
'settings.about.wikiHint': 'Dokumentasi & panduan',
'settings.about.supporters.badge': 'Pendukung Bulanan',
'settings.about.supporters.title': 'Rekan perjalanan untuk TREK',
'settings.about.supporters.subtitle': 'Saat kamu merencanakan rute berikutnya, orang-orang ini ikut merencanakan masa depan TREK. Kontribusi bulanan mereka langsung masuk ke pengembangan dan jam kerja nyata — supaya TREK tetap Open Source.',
'settings.about.supporters.since': 'pendukung sejak {date}',
'settings.about.supporters.tierEmpty': 'Jadilah yang pertama',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK adalah perencana perjalanan self-hosted yang membantu kamu mengatur perjalanan dari ide pertama hingga kenangan terakhir. Perencanaan harian, anggaran, daftar bawaan, foto dan masih banyak lagi — semua di satu tempat, di servermu sendiri.',
'settings.about.madeWith': 'Dibuat dengan',
'settings.about.madeBy': 'oleh Maurice dan komunitas open-source yang terus berkembang.',
@@ -603,9 +623,29 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'Pengaturan jenis file disimpan',
// Packing Templates & Bag Tracking
'admin.placesPhotos.title': 'Foto Tempat',
'admin.placesPhotos.subtitle': 'Mengambil foto dari Google Places API. Nonaktifkan untuk menghemat kuota API. Foto Wikimedia tidak terpengaruh.',
'admin.placesAutocomplete.title': 'Pelengkapan Otomatis Tempat',
'admin.placesAutocomplete.subtitle': 'Menggunakan Google Places API untuk saran pencarian. Nonaktifkan untuk menghemat kuota API.',
'admin.placesDetails.title': 'Detail Tempat',
'admin.placesDetails.subtitle': 'Mengambil informasi detail tempat (jam, penilaian, situs web) dari Google Places API. Nonaktifkan untuk menghemat kuota API.',
'admin.bagTracking.title': 'Pelacak Tas',
'admin.bagTracking.subtitle': 'Aktifkan berat dan penugasan tas untuk item packing',
'admin.collab.chat.title': 'Chat',
'admin.collab.chat.subtitle': 'Pesan real-time untuk kolaborasi',
'admin.collab.notes.title': 'Catatan',
'admin.collab.notes.subtitle': 'Catatan dan dokumen bersama',
'admin.collab.polls.title': 'Jajak Pendapat',
'admin.collab.polls.subtitle': 'Jajak pendapat dan voting grup',
'admin.collab.whatsnext.title': 'Selanjutnya',
'admin.collab.whatsnext.subtitle': 'Saran aktivitas dan langkah selanjutnya',
'admin.tabs.config': 'Personalisasi',
'admin.tabs.defaults': 'Pengaturan Default Pengguna',
'admin.defaultSettings.title': 'Pengaturan Default Pengguna',
'admin.defaultSettings.description': 'Tetapkan nilai default untuk seluruh instance. Pengguna yang belum mengubah pengaturan akan melihat nilai-nilai ini. Perubahan mereka sendiri selalu diprioritaskan.',
'admin.defaultSettings.saved': 'Default disimpan',
'admin.defaultSettings.reset': 'Atur ulang ke default bawaan',
'admin.defaultSettings.resetToBuiltIn': 'atur ulang',
'admin.tabs.templates': 'Template Packing',
'admin.packingTemplates.title': 'Template Packing',
'admin.packingTemplates.subtitle': 'Buat daftar packing yang bisa digunakan ulang untuk perjalananmu',
@@ -888,6 +928,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
// Trip Planner
'trip.tabs.plan': 'Rencana',
'trip.tabs.transports': 'Transportasi',
'trip.tabs.reservations': 'Pemesanan',
'trip.tabs.reservationsShort': 'Pesan',
'trip.tabs.packing': 'Daftar Perlengkapan',
@@ -910,6 +951,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'trip.toast.reservationAdded': 'Reservasi ditambahkan',
'trip.toast.deleted': 'Dihapus',
'trip.confirm.deletePlace': 'Apakah kamu yakin ingin menghapus tempat ini?',
'trip.confirm.deletePlaces': 'Hapus {count} tempat?',
'trip.toast.placesDeleted': '{count} tempat dihapus',
// Day Plan Sidebar
'dayplan.emptyDay': 'Belum ada tempat yang direncanakan untuk hari ini',
@@ -954,6 +997,17 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'places.importFileError': 'Impor gagal',
'places.importAllSkipped': 'Semua tempat sudah ada di perjalanan.',
'places.gpxImported': '{count} tempat diimpor dari GPX',
'places.gpxImportTypes': 'Apa yang ingin diimpor?',
'places.gpxImportWaypoints': 'Titik jalan',
'places.gpxImportRoutes': 'Rute',
'places.gpxImportTracks': 'Trek (dengan geometri jalur)',
'places.gpxImportNoneSelected': 'Pilih setidaknya satu jenis untuk diimpor.',
'places.kmlImportTypes': 'Apa yang ingin diimpor?',
'places.kmlImportPoints': 'Titik (Placemarks)',
'places.kmlImportPaths': 'Jalur (LineStrings)',
'places.kmlImportNoneSelected': 'Pilih setidaknya satu jenis.',
'places.selectionCount': '{count} dipilih',
'places.deleteSelected': 'Hapus yang dipilih',
'places.kmlKmzImported': '{count} tempat diimpor dari KMZ/KML',
'places.urlResolved': 'Tempat diimpor dari URL',
'places.importList': 'Impor Daftar',
@@ -970,6 +1024,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'places.assignToDay': 'Tambah ke hari mana?',
'places.all': 'Semua',
'places.unplanned': 'Belum direncanakan',
'places.filterTracks': 'Trek',
'places.search': 'Cari tempat...',
'places.allCategories': 'Semua Kategori',
'places.categoriesSelected': 'kategori',
@@ -1053,10 +1108,20 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.flightNumber': 'No. Penerbangan',
'reservations.meta.from': 'Dari',
'reservations.meta.to': 'Ke',
'reservations.needsReview': 'Tinjau',
'reservations.needsReviewHint': 'Bandara tidak dapat dicocokkan otomatis — konfirmasi lokasi.',
'reservations.searchLocation': 'Cari stasiun, pelabuhan, alamat...',
'airport.searchPlaceholder': 'Kode bandara atau kota (mis. FRA)',
'map.connections': 'Koneksi',
'map.showConnections': 'Tampilkan rute pemesanan',
'map.hideConnections': 'Sembunyikan rute pemesanan',
'settings.bookingLabels': 'Label rute pemesanan',
'settings.bookingLabelsHint': 'Menampilkan nama stasiun / bandara di peta. Jika mati, hanya ikon ditampilkan.',
'reservations.meta.trainNumber': 'No. Kereta',
'reservations.meta.platform': 'Peron',
'reservations.meta.seat': 'Kursi',
'reservations.meta.checkIn': 'Check-in',
'reservations.meta.checkInUntil': 'Check-in sampai',
'reservations.meta.checkOut': 'Check-out',
'reservations.meta.linkAccommodation': 'Akomodasi',
'reservations.meta.pickAccommodation': 'Hubungkan ke akomodasi',
@@ -1070,7 +1135,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.hotel': 'Akomodasi',
'reservations.type.restaurant': 'Restoran',
'reservations.type.train': 'Kereta',
'reservations.type.car': 'Mobil Sewa',
'reservations.type.car': 'Mobil',
'reservations.type.cruise': 'Kapal Pesiar',
'reservations.type.event': 'Acara',
'reservations.type.tour': 'Tur',
@@ -1131,6 +1196,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'reservations.span.end': 'Selesai',
'reservations.span.ongoing': 'Berlangsung',
'reservations.validation.endBeforeStart': 'Tanggal/waktu selesai harus setelah tanggal/waktu mulai',
'reservations.addBooking': 'Tambah pemesanan',
// Budget
'budget.title': 'Anggaran',
@@ -1541,6 +1607,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'day.noPlacesForHotel': 'Tambahkan tempat ke perjalananmu terlebih dahulu',
'day.allDays': 'Semua',
'day.checkIn': 'Check-in',
'day.checkInUntil': 'Sampai',
'day.checkOut': 'Check-out',
'day.confirmation': 'Konfirmasi',
'day.editAccommodation': 'Edit akomodasi',
@@ -1569,6 +1636,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'memories.providerPassword': 'Kata sandi',
'memories.providerOTP': 'Kode MFA (jika diaktifkan)',
'memories.skipSSLVerification': 'Lewati verifikasi sertifikat SSL',
'memories.immichAutoUpload': 'Salin foto journey ke Immich saat diunggah',
'memories.providerUrlHintSynology': 'Sertakan path aplikasi Photos di URL, mis. https://nas:5001/photo',
'memories.testConnection': 'Uji koneksi',
'memories.testFirst': 'Uji koneksi terlebih dahulu',
@@ -1743,6 +1811,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'undo.reorder': 'Tempat diurutkan ulang',
'undo.optimize': 'Rute dioptimalkan',
'undo.deletePlace': 'Tempat dihapus',
'undo.deletePlaces': 'Tempat dihapus',
'undo.moveDay': 'Tempat dipindah ke hari lain',
'undo.lock': 'Kunci tempat diubah',
'undo.importGpx': 'Impor GPX',
@@ -1799,7 +1868,11 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'Belum ditugaskan',
'todo.noCategory': 'Tanpa kategori',
'todo.hasDescription': 'Ada deskripsi',
'todo.addItem': 'Tambah tugas baru...',
'todo.addItem': 'Tugas baru',
'todo.sidebar.sortBy': 'Urutkan',
'todo.priority': 'Prioritas',
'todo.newCategoryLabel': 'baru',
'budget.categoriesLabel': 'kategori',
'todo.newCategory': 'Nama kategori',
'todo.addCategory': 'Tambah kategori',
'todo.newItem': 'Tugas baru',
@@ -1858,6 +1931,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'notif.dev.unknown_event.text': 'Tipe event "{event}" tidak terdaftar di EVENT_NOTIFICATION_CONFIG',
// Journey addon
'journey.search.placeholder': 'Cari perjalanan…',
'journey.search.noResults': 'Tidak ada perjalanan yang cocok dengan "{query}"',
'journey.title': 'Journey',
'journey.subtitle': 'Lacak perjalananmu saat terjadi',
'journey.new': 'Journey Baru',
@@ -1879,6 +1954,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Aktif',
'journey.status.completed': 'Selesai',
'journey.status.upcoming': 'Mendatang',
'journey.status.archived': 'Diarsipkan',
'journey.checkin.add': 'Check in',
'journey.checkin.namePlaceholder': 'Nama lokasi',
'journey.checkin.notesPlaceholder': 'Catatan (opsional)',
@@ -1967,6 +2043,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'journey.synced.synced': 'tersinkron',
// Journey Entry Editor
'journey.editor.discardChangesConfirm': 'Anda memiliki perubahan yang belum disimpan. Buang?',
'journey.editor.uploadPhotos': 'Unggah foto',
'journey.editor.uploading': 'Mengunggah...',
'journey.editor.fromGallery': 'Dari Galeri',
@@ -2056,6 +2133,11 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Nama',
'journey.settings.subtitle': 'Subjudul',
'journey.settings.subtitlePlaceholder': 'mis. Thailand, Vietnam & Kamboja',
'journey.settings.endJourney': 'Arsipkan Perjalanan',
'journey.settings.reopenJourney': 'Pulihkan Perjalanan',
'journey.settings.archived': 'Perjalanan diarsipkan',
'journey.settings.reopened': 'Perjalanan dibuka kembali',
'journey.settings.endDescription': 'Menyembunyikan lencana Langsung. Anda dapat membuka kembali kapan saja.',
'journey.settings.delete': 'Hapus',
'journey.settings.deleteJourney': 'Hapus Journey',
'journey.settings.deleteMessage': 'Hapus "{title}"? Semua entri dan foto akan hilang.',
@@ -2205,6 +2287,55 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'oauth.scope.weather:read.description': 'Ambil prakiraan cuaca untuk lokasi dan tanggal perjalanan',
// System notices
'system_notice.welcome_v1.title': 'Selamat datang di TREK',
'system_notice.welcome_v1.body': 'Perencana perjalanan lengkap Anda. Buat itinerari, bagikan perjalanan dengan teman, dan tetap terorganisir — online maupun offline.',
'system_notice.welcome_v1.cta_label': 'Rencanakan perjalanan',
'system_notice.welcome_v1.hero_alt': 'Destinasi wisata indah dengan antarmuka TREK',
'system_notice.welcome_v1.highlight_plan': 'Itinerari harian untuk setiap perjalanan',
'system_notice.welcome_v1.highlight_share': 'Berkolaborasi dengan teman perjalanan',
'system_notice.welcome_v1.highlight_offline': 'Bekerja offline di ponsel',
'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': 'Pemberitahuan sebelumnya',
'system_notice.pager.next': 'Pemberitahuan berikutnya',
'system_notice.pager.counter': '{current} / {total}',
'system_notice.pager.goto': 'Pergi ke pemberitahuan {n}',
'system_notice.pager.position': 'Pemberitahuan {current} dari {total}',
// System notices — 3.0.0 upgrade
'system_notice.v3_photos.title': 'Foto dipindahkan di 3.0',
'system_notice.v3_photos.body': '**Foto** di Perencana Perjalanan telah dihapus. Foto Anda aman — TREK tidak pernah mengubah perpustakaan Immich atau Synology Anda.\n\nFoto kini ada di addon **Journey**. Journey bersifat opsional — jika belum tersedia, minta admin untuk mengaktifkannya di Admin → Addon.',
'system_notice.v3_journey.title': 'Kenali Journey — jurnal perjalanan',
'system_notice.v3_journey.body': 'Dokumentasikan perjalanan Anda sebagai cerita hidup dengan linimasa, galeri foto, dan peta interaktif.',
'system_notice.v3_journey.cta_label': 'Buka Journey',
'system_notice.v3_journey.highlight_timeline': 'Linimasa & galeri',
'system_notice.v3_journey.highlight_photos': 'Impor dari Immich atau Synology',
'system_notice.v3_journey.highlight_share': 'Bagikan secara publik — tanpa login',
'system_notice.v3_journey.highlight_export': 'Ekspor sebagai buku foto PDF',
'system_notice.v3_features.title': 'Sorotan lain di 3.0',
'system_notice.v3_features.body': 'Beberapa pembaruan lain dalam rilis ini.',
'system_notice.v3_features.highlight_dashboard': 'Desain ulang dashboard mobile-first',
'system_notice.v3_features.highlight_offline': 'Mode offline penuh sebagai PWA',
'system_notice.v3_features.highlight_search': 'Pelengkapan otomatis tempat secara real-time',
'system_notice.v3_features.highlight_import': 'Impor tempat dari file KMZ/KML',
// System notices — MCP OAuth 2.1 upgrade
'system_notice.v3_mcp.title': 'MCP: pembaruan OAuth 2.1',
'system_notice.v3_mcp.body': 'Integrasi MCP telah sepenuhnya diperbarui. OAuth 2.1 kini menjadi metode autentikasi yang direkomendasikan. Token statis (trek_…) sudah usang dan akan dihapus pada versi mendatang.',
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 direkomendasikan (mcp-remote)',
'system_notice.v3_mcp.highlight_scopes': '24 cakupan izin yang terperinci',
'system_notice.v3_mcp.highlight_deprecated': 'Token statis trek_ sudah usang',
'system_notice.v3_mcp.highlight_tools': 'Perangkat dan prompt yang diperluas',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Catatan pribadi dari saya',
'system_notice.v3_thankyou.body': 'Sebelum kamu lanjut — saya ingin berhenti sejenak.\n\nTREK dimulai sebagai proyek sampingan yang saya buat untuk perjalanan saya sendiri. Saya tidak pernah membayangkan ia akan tumbuh menjadi sesuatu yang dipercaya oleh 4.000 dari kalian untuk merencanakan petualangan. Setiap bintang, setiap issue, setiap permintaan fitur — saya membaca semuanya, dan itulah yang membuat saya terus bertahan di malam-malam larut antara pekerjaan penuh waktu dan kuliah.\n\nSaya ingin kalian tahu: TREK akan selalu open source, selalu self-hosted, selalu milik kalian. Tanpa pelacakan, tanpa langganan, tanpa syarat tersembunyi. Hanya sebuah alat yang dibuat oleh seseorang yang mencintai traveling sama seperti kalian.\n\nTerima kasih khusus untuk [jubnl](https://github.com/jubnl) — kamu telah menjadi kolaborator yang luar biasa. Begitu banyak hal yang membuat versi 3.0 hebat memiliki jejakmu. Terima kasih telah percaya pada proyek ini ketika masih kasar.\n\nDan untuk setiap dari kalian yang melaporkan bug, menerjemahkan string, membagikan TREK kepada teman, atau sekadar menggunakannya untuk merencanakan perjalanan — **terima kasih**. Kalianlah alasan semua ini ada.\n\nUntuk lebih banyak petualangan bersama.\n\n— Maurice\n\n---\n\n[Bergabunglah dengan komunitas di Discord](https://discord.gg/7Q6M6jDwzf)\n\nJika TREK membuat perjalananmu lebih baik, [secangkir kopi kecil](https://ko-fi.com/mauriceboe) selalu membantu menjaga lampu tetap menyala.',
'transport.addTransport': 'Add transport',
'transport.modalTitle.create': 'Add transport',
'transport.modalTitle.edit': 'Edit transport',
'transport.title': 'Transportasi',
'transport.addManual': 'Transportasi Manual',
};
export default id;
+134 -3
View File
@@ -4,11 +4,15 @@ 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',
'common.loading': 'Caricamento...',
'common.import': 'Importa',
'common.select': 'Seleziona',
'common.selectAll': 'Seleziona tutto',
'common.deselectAll': 'Deseleziona tutto',
'common.error': 'Errore',
'common.unknownError': 'Errore sconosciuto',
'common.tooManyAttempts': 'Troppi tentativi. Riprova più tardi.',
@@ -262,6 +266,16 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'Richiedi funzionalità',
'settings.about.featureRequestHint': 'Suggerisci una nuova funzionalità',
'settings.about.wikiHint': 'Documentazione e guide',
'settings.about.supporters.badge': 'Sostenitori Mensili',
'settings.about.supporters.title': 'Compagni di viaggio per TREK',
'settings.about.supporters.subtitle': 'Mentre pianifichi il tuo prossimo itinerario, queste persone aiutano a pianificare il futuro di TREK. Il loro contributo mensile va direttamente allo sviluppo e alle ore realmente investite — per mantenere TREK Open Source.',
'settings.about.supporters.since': 'sostenitore da {date}',
'settings.about.supporters.tierEmpty': 'Sii il primo',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK è un pianificatore di viaggi self-hosted che ti aiuta a organizzare i tuoi viaggi dalla prima idea all\'ultimo ricordo. Pianificazione giornaliera, budget, liste bagagli, foto e molto altro — tutto in un unico posto, sul tuo server.',
'settings.about.madeWith': 'Fatto con',
'settings.about.madeBy': 'da Maurice e una crescente comunità open-source.',
@@ -544,9 +558,29 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesFormat': 'Estensioni separate da virgola (es. jpg,png,pdf,doc). Usa * per consentire tutti i tipi.',
'admin.fileTypesSaved': 'Impostazioni dei tipi di file salvate',
// Packing Templates & Bag Tracking
'admin.placesPhotos.title': 'Foto dei luoghi',
'admin.placesPhotos.subtitle': "Recupera le foto dall'API Google Places. Disabilita per risparmiare la quota API. Le foto di Wikimedia non sono interessate.",
'admin.placesAutocomplete.title': 'Completamento automatico dei luoghi',
'admin.placesAutocomplete.subtitle': "Utilizza l'API Google Places per i suggerimenti di ricerca. Disabilita per risparmiare la quota API.",
'admin.placesDetails.title': 'Dettagli del luogo',
'admin.placesDetails.subtitle': "Recupera informazioni dettagliate sul luogo (orari, valutazione, sito web) dall'API Google Places. Disabilita per risparmiare la quota API.",
'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',
@@ -834,6 +868,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
// Trip Planner
'trip.tabs.plan': 'Programma',
'trip.tabs.transports': 'Trasporti',
'trip.tabs.reservations': 'Prenotazioni',
'trip.tabs.reservationsShort': 'Pren.',
'trip.tabs.packing': 'Lista valigia',
@@ -855,6 +890,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'trip.toast.reservationAdded': 'Prenotazione aggiunta',
'trip.toast.deleted': 'Eliminato',
'trip.confirm.deletePlace': 'Sei sicuro di voler eliminare questo luogo?',
'trip.confirm.deletePlaces': 'Eliminare {count} luoghi?',
'trip.toast.placesDeleted': '{count} luoghi eliminati',
'trip.loadingPhotos': 'Caricamento foto dei luoghi...',
// Day Plan Sidebar
@@ -900,6 +937,17 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'places.importFileError': 'Importazione non riuscita',
'places.importAllSkipped': 'Tutti i luoghi erano già nel viaggio.',
'places.gpxImported': '{count} luoghi importati da GPX',
'places.gpxImportTypes': 'Cosa vuoi importare?',
'places.gpxImportWaypoints': 'Waypoint',
'places.gpxImportRoutes': 'Percorsi',
'places.gpxImportTracks': 'Tracce (con geometria percorso)',
'places.gpxImportNoneSelected': 'Seleziona almeno un tipo da importare.',
'places.kmlImportTypes': 'Cosa vuoi importare?',
'places.kmlImportPoints': 'Punti (Placemarks)',
'places.kmlImportPaths': 'Percorsi (LineStrings)',
'places.kmlImportNoneSelected': 'Seleziona almeno un tipo.',
'places.selectionCount': '{count} selezionato/i',
'places.deleteSelected': 'Elimina selezionati',
'places.kmlKmzImported': '{count} luoghi importati da KMZ/KML',
'places.urlResolved': 'Luogo importato dall\'URL',
'places.importList': 'Importa lista',
@@ -916,6 +964,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'places.assignToDay': 'A quale giorno aggiungere?',
'places.all': 'Tutti',
'places.unplanned': 'Non pianificati',
'places.filterTracks': 'Tracce',
'places.search': 'Cerca luoghi...',
'places.allCategories': 'Tutte le categorie',
'places.categoriesSelected': 'categorie',
@@ -999,10 +1048,20 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.flightNumber': 'N. volo',
'reservations.meta.from': 'Da',
'reservations.meta.to': 'A',
'reservations.needsReview': 'Verifica',
'reservations.needsReviewHint': 'L\'aeroporto non è stato riconosciuto automaticamente — conferma la posizione.',
'reservations.searchLocation': 'Cerca stazione, porto, indirizzo...',
'airport.searchPlaceholder': 'Codice o città dell\'aeroporto (es. FRA)',
'map.connections': 'Connessioni',
'map.showConnections': 'Mostra percorsi prenotati',
'map.hideConnections': 'Nascondi percorsi prenotati',
'settings.bookingLabels': 'Etichette percorsi prenotati',
'settings.bookingLabelsHint': 'Mostra i nomi di stazioni / aeroporti sulla mappa. Se disattivato, viene mostrata solo l\'icona.',
'reservations.meta.trainNumber': 'N. treno',
'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',
@@ -1016,7 +1075,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.hotel': 'Alloggio',
'reservations.type.restaurant': 'Ristorante',
'reservations.type.train': 'Treno',
'reservations.type.car': 'Auto a noleggio',
'reservations.type.car': 'Auto',
'reservations.type.cruise': 'Crociera',
'reservations.type.event': 'Evento',
'reservations.type.tour': 'Tour',
@@ -1077,6 +1136,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'reservations.span.end': 'Fine',
'reservations.span.ongoing': 'In corso',
'reservations.validation.endBeforeStart': 'La data/ora di fine deve essere successiva alla data/ora di inizio',
'reservations.addBooking': 'Aggiungi prenotazione',
// Budget
'budget.title': 'Budget',
@@ -1487,6 +1547,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',
@@ -1513,6 +1574,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'memories.providerPassword': 'Password',
'memories.providerOTP': 'Codice MFA (se abilitato)',
'memories.skipSSLVerification': 'Ignora la verifica del certificato SSL',
'memories.immichAutoUpload': 'Rispecchia le foto del journey su Immich al caricamento',
'memories.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo',
'memories.testConnection': 'Test connessione',
'memories.testFirst': 'Testa prima la connessione',
@@ -1678,6 +1740,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'undo.reorder': 'Luoghi riordinati',
'undo.optimize': 'Percorso ottimizzato',
'undo.deletePlace': 'Luogo eliminato',
'undo.deletePlaces': 'Luoghi eliminati',
'undo.moveDay': 'Luogo spostato in altro giorno',
'undo.lock': 'Blocco luogo modificato',
'undo.importGpx': 'Importazione GPX',
@@ -1736,7 +1799,11 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'Non assegnato',
'todo.noCategory': 'Nessuna categoria',
'todo.hasDescription': 'Ha descrizione',
'todo.addItem': 'Aggiungi nuova attività...',
'todo.addItem': 'Nuova attività',
'todo.sidebar.sortBy': 'Ordina per',
'todo.priority': 'Priorità',
'todo.newCategoryLabel': 'nuova',
'budget.categoriesLabel': 'categorie',
'todo.newCategory': 'Nome categoria',
'todo.addCategory': 'Aggiungi categoria',
'todo.newItem': 'Nuova attività',
@@ -1778,7 +1845,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.ntfyUrl.test': 'Testa',
'settings.ntfyUrl.testSuccess': 'Notifica di test Ntfy inviata con successo',
'settings.ntfyUrl.testFailed': 'Notifica di test Ntfy fallita',
'settings.ntfyUrl.clearToken': 'Cancella',
'settings.ntfyUrl.tokenCleared': 'Token di accesso rimosso',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1795,22 +1861,29 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'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.notifications.tripReminders.title': 'Promemoria viaggio',
'admin.notifications.tripReminders.hint': 'Invia una notifica promemoria prima dell\'inizio di un viaggio (richiede giorni di promemoria impostati sul viaggio).',
'admin.notifications.tripReminders.enabled': 'Promemoria viaggio attivati',
'admin.notifications.tripReminders.disabled': 'Promemoria viaggio disattivati',
'admin.tabs.notifications': 'Notifiche',
'notifications.versionAvailable.title': 'Aggiornamento disponibile',
'notifications.versionAvailable.text': 'TREK {version} è ora disponibile.',
@@ -1855,6 +1928,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'common.justNow': 'proprio ora',
'common.hoursAgo': '{count}h fa',
'common.daysAgo': '{count}g fa',
'journey.search.placeholder': 'Cerca viaggi…',
'journey.search.noResults': 'Nessun viaggio corrisponde a "{query}"',
'journey.title': 'Diario di viaggio',
'journey.subtitle': 'Segui i tuoi viaggi in tempo reale',
'journey.new': 'Nuovo diario',
@@ -1876,6 +1951,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Attivo',
'journey.status.completed': 'Completato',
'journey.status.upcoming': 'In arrivo',
'journey.status.archived': 'Archiviato',
'journey.checkin.add': 'Check-in',
'journey.checkin.namePlaceholder': 'Nome del luogo',
'journey.checkin.notesPlaceholder': 'Note (facoltativo)',
@@ -1952,6 +2028,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.verdict.couldBeBetter': 'Potrebbe essere meglio',
'journey.synced.places': 'luoghi',
'journey.synced.synced': 'sincronizzato',
'journey.editor.discardChangesConfirm': 'Hai modifiche non salvate. Vuoi scartarle?',
'journey.editor.uploadPhotos': 'Carica foto',
'journey.editor.uploading': 'Caricamento...',
'journey.editor.fromGallery': 'Dalla galleria',
@@ -2029,6 +2106,11 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Nome',
'journey.settings.subtitle': 'Sottotitolo',
'journey.settings.subtitlePlaceholder': 'es. Thailandia, Vietnam e Cambogia',
'journey.settings.endJourney': 'Archivia il viaggio',
'journey.settings.reopenJourney': 'Ripristina il viaggio',
'journey.settings.archived': 'Viaggio archiviato',
'journey.settings.reopened': 'Viaggio riaperto',
'journey.settings.endDescription': 'Nasconde il badge In diretta. Puoi riaprire in qualsiasi momento.',
'journey.settings.delete': 'Elimina',
'journey.settings.deleteJourney': 'Elimina diario',
'journey.settings.deleteMessage': 'Eliminare "{title}"? Tutte le voci e le foto andranno perse.',
@@ -2164,6 +2246,55 @@ 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.',
'transport.addTransport': 'Add transport',
'transport.modalTitle.create': 'Add transport',
'transport.modalTitle.edit': 'Edit transport',
'transport.title': 'Trasporti',
'transport.addManual': 'Trasporto manuale',
}
export default it
+134 -3
View File
@@ -4,11 +4,15 @@ 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',
'common.loading': 'Laden...',
'common.import': 'Importeren',
'common.select': 'Selecteren',
'common.selectAll': 'Alles selecteren',
'common.deselectAll': 'Alles deselecteren',
'common.error': 'Fout',
'common.unknownError': 'Onbekende fout',
'common.tooManyAttempts': 'Te veel pogingen. Probeer het later opnieuw.',
@@ -307,6 +311,16 @@ const nl: Record<string, string> = {
'settings.about.featureRequest': 'Feature aanvragen',
'settings.about.featureRequestHint': 'Stel een nieuwe functie voor',
'settings.about.wikiHint': 'Documentatie en handleidingen',
'settings.about.supporters.badge': 'Maandelijkse Steuners',
'settings.about.supporters.title': 'Reisgezelschap voor TREK',
'settings.about.supporters.subtitle': 'Terwijl jij je volgende route plant, plannen deze mensen mee aan de toekomst van TREK. Hun maandelijkse bijdrage gaat rechtstreeks naar ontwikkeling en echte uren — zodat TREK Open Source blijft.',
'settings.about.supporters.since': 'steuner sinds {date}',
'settings.about.supporters.tierEmpty': 'Wees de eerste',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK is een zelf-gehoste reisplanner die je helpt je reizen te organiseren van het eerste idee tot de laatste herinnering. Dagplanning, budget, paklijsten, foto\'s en nog veel meer — alles op één plek, op je eigen server.',
'settings.about.madeWith': 'Gemaakt met',
'settings.about.madeBy': 'door Maurice en een groeiende open-source community.',
@@ -545,9 +559,29 @@ const nl: Record<string, string> = {
'admin.fileTypesFormat': 'Kommagescheiden extensies (bijv. jpg,png,pdf,doc). Gebruik * om alle typen toe te staan.',
'admin.fileTypesSaved': 'Bestandstype-instellingen opgeslagen',
'admin.placesPhotos.title': "Plaatsfoto's",
'admin.placesPhotos.subtitle': "Haalt foto's op via de Google Places API. Schakel uit om API-quota te besparen. Wikimedia-foto's worden niet beïnvloed.",
'admin.placesAutocomplete.title': 'Plaatsautocomplete',
'admin.placesAutocomplete.subtitle': 'Gebruikt de Google Places API voor zoeksuggesties. Schakel uit om API-quota te besparen.',
'admin.placesDetails.title': 'Plaatsdetails',
'admin.placesDetails.subtitle': 'Haalt gedetailleerde plaatsinformatie (openingstijden, beoordeling, website) op via de Google Places API. Schakel uit om API-quota te besparen.',
'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',
@@ -833,6 +867,7 @@ const nl: Record<string, string> = {
// Trip Planner
'trip.tabs.plan': 'Plan',
'trip.tabs.transports': 'Transport',
'trip.tabs.reservations': 'Boekingen',
'trip.tabs.reservationsShort': 'Boek',
'trip.tabs.packing': 'Paklijst',
@@ -855,6 +890,8 @@ const nl: Record<string, string> = {
'trip.toast.reservationAdded': 'Reservering toegevoegd',
'trip.toast.deleted': 'Verwijderd',
'trip.confirm.deletePlace': 'Weet je zeker dat je deze plaats wilt verwijderen?',
'trip.confirm.deletePlaces': '{count} plaatsen verwijderen?',
'trip.toast.placesDeleted': '{count} plaatsen verwijderd',
// Day Plan Sidebar
'dayplan.emptyDay': 'Geen plaatsen gepland voor deze dag',
@@ -899,6 +936,17 @@ const nl: Record<string, string> = {
'places.importFileError': 'Importeren mislukt',
'places.importAllSkipped': 'Alle plaatsen waren al in de reis.',
'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX',
'places.gpxImportTypes': 'Wat wil je importeren?',
'places.gpxImportWaypoints': 'Waypoints',
'places.gpxImportRoutes': 'Routes',
'places.gpxImportTracks': 'Tracks (met routegeometrie)',
'places.gpxImportNoneSelected': 'Selecteer minstens één type om te importeren.',
'places.kmlImportTypes': 'Wat wil je importeren?',
'places.kmlImportPoints': 'Punten (Placemarks)',
'places.kmlImportPaths': 'Paden (LineStrings)',
'places.kmlImportNoneSelected': 'Selecteer minstens één type.',
'places.selectionCount': '{count} geselecteerd',
'places.deleteSelected': 'Selectie verwijderen',
'places.kmlKmzImported': '{count} plaatsen geïmporteerd uit KMZ/KML',
'places.urlResolved': 'Plaats geïmporteerd van URL',
'places.importList': 'Lijst importeren',
@@ -915,6 +963,7 @@ const nl: Record<string, string> = {
'places.assignToDay': 'Aan welke dag toevoegen?',
'places.all': 'Alle',
'places.unplanned': 'Ongepland',
'places.filterTracks': 'Tracks',
'places.search': 'Plaatsen zoeken...',
'places.allCategories': 'Alle categorieën',
'places.categoriesSelected': 'categorieën',
@@ -998,10 +1047,20 @@ const nl: Record<string, string> = {
'reservations.meta.flightNumber': 'Vluchtnr.',
'reservations.meta.from': 'Van',
'reservations.meta.to': 'Naar',
'reservations.needsReview': 'Controleren',
'reservations.needsReviewHint': 'Luchthaven kon niet automatisch worden herkend — bevestig de locatie.',
'reservations.searchLocation': 'Station, haven, adres zoeken...',
'airport.searchPlaceholder': 'Luchthavencode of stad (bijv. FRA)',
'map.connections': 'Verbindingen',
'map.showConnections': 'Boekingsroutes tonen',
'map.hideConnections': 'Boekingsroutes verbergen',
'settings.bookingLabels': 'Routelabels voor boekingen',
'settings.bookingLabelsHint': 'Toon station- / luchthavennamen op de kaart. Indien uit, alleen het icoon.',
'reservations.meta.trainNumber': 'Treinnr.',
'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',
@@ -1015,7 +1074,7 @@ const nl: Record<string, string> = {
'reservations.type.hotel': 'Accommodatie',
'reservations.type.restaurant': 'Restaurant',
'reservations.type.train': 'Trein',
'reservations.type.car': 'Huurauto',
'reservations.type.car': 'Auto',
'reservations.type.cruise': 'Cruise',
'reservations.type.event': 'Evenement',
'reservations.type.tour': 'Rondleiding',
@@ -1076,6 +1135,7 @@ const nl: Record<string, string> = {
'reservations.span.end': 'Einde',
'reservations.span.ongoing': 'Lopend',
'reservations.validation.endBeforeStart': 'Einddatum/-tijd moet na de startdatum/-tijd liggen',
'reservations.addBooking': 'Boeking toevoegen',
// Budget
'budget.title': 'Budget',
@@ -1486,6 +1546,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',
@@ -1512,6 +1573,7 @@ const nl: Record<string, string> = {
'memories.providerPassword': 'Wachtwoord',
'memories.providerOTP': 'MFA-code (indien ingeschakeld)',
'memories.skipSSLVerification': 'SSL-certificaatverificatie overslaan',
'memories.immichAutoUpload': 'Journey-foto\'s bij upload ook naar Immich spiegelen',
'memories.providerUrlHintSynology': 'Voeg het pad van de Photos-app toe aan de URL, bijv. https://nas:5001/photo',
'memories.testConnection': 'Verbinding testen',
'memories.testFirst': 'Test eerst de verbinding',
@@ -1676,6 +1738,7 @@ const nl: Record<string, string> = {
'undo.reorder': 'Locaties hergeordend',
'undo.optimize': 'Route geoptimaliseerd',
'undo.deletePlace': 'Locatie verwijderd',
'undo.deletePlaces': 'Plaatsen verwijderd',
'undo.moveDay': 'Locatie naar andere dag verplaatst',
'undo.lock': 'Vergrendeling locatie gewijzigd',
'undo.importGpx': 'GPX-import',
@@ -1735,7 +1798,11 @@ const nl: Record<string, string> = {
'todo.unassigned': 'Niet toegewezen',
'todo.noCategory': 'Geen categorie',
'todo.hasDescription': 'Heeft beschrijving',
'todo.addItem': 'Nieuwe taak toevoegen...',
'todo.addItem': 'Nieuwe taak',
'todo.sidebar.sortBy': 'Sorteren op',
'todo.priority': 'Prioriteit',
'todo.newCategoryLabel': 'nieuw',
'budget.categoriesLabel': 'categorieën',
'todo.newCategory': 'Categorienaam',
'todo.addCategory': 'Categorie toevoegen',
'todo.newItem': 'Nieuwe taak',
@@ -1777,7 +1844,6 @@ const nl: Record<string, string> = {
'settings.ntfyUrl.test': 'Testen',
'settings.ntfyUrl.testSuccess': 'Test-Ntfy-melding succesvol verzonden',
'settings.ntfyUrl.testFailed': 'Test-Ntfy-melding mislukt',
'settings.ntfyUrl.clearToken': 'Wissen',
'settings.ntfyUrl.tokenCleared': 'Toegangstoken gewist',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1794,22 +1860,29 @@ const nl: Record<string, string> = {
'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.notifications.tripReminders.title': 'Reisherinneringen',
'admin.notifications.tripReminders.hint': 'Stuurt een herinneringsmelding voor de start van een reis (vereist ingestelde herinneringsdagen bij de reis).',
'admin.notifications.tripReminders.enabled': 'Reisherinneringen ingeschakeld',
'admin.notifications.tripReminders.disabled': 'Reisherinneringen uitgeschakeld',
'admin.tabs.notifications': 'Meldingen',
'notifications.versionAvailable.title': 'Update beschikbaar',
'notifications.versionAvailable.text': 'TREK {version} is nu beschikbaar.',
@@ -1854,6 +1927,8 @@ const nl: Record<string, string> = {
'common.justNow': 'zojuist',
'common.hoursAgo': '{count}u geleden',
'common.daysAgo': '{count}d geleden',
'journey.search.placeholder': 'Reizen zoeken…',
'journey.search.noResults': 'Geen reizen komen overeen met "{query}"',
'journey.title': 'Reisverslag',
'journey.subtitle': 'Leg je reizen vast terwijl je onderweg bent',
'journey.new': 'Nieuw reisverslag',
@@ -1875,6 +1950,7 @@ const nl: Record<string, string> = {
'journey.status.active': 'Actief',
'journey.status.completed': 'Voltooid',
'journey.status.upcoming': 'Gepland',
'journey.status.archived': 'Gearchiveerd',
'journey.checkin.add': 'Inchecken',
'journey.checkin.namePlaceholder': 'Locatienaam',
'journey.checkin.notesPlaceholder': 'Notities (optioneel)',
@@ -1951,6 +2027,7 @@ const nl: Record<string, string> = {
'journey.verdict.couldBeBetter': 'Kan beter',
'journey.synced.places': 'plaatsen',
'journey.synced.synced': 'gesynchroniseerd',
'journey.editor.discardChangesConfirm': 'Je hebt niet-opgeslagen wijzigingen. Verwerpen?',
'journey.editor.uploadPhotos': 'Foto\'s uploaden',
'journey.editor.uploading': 'Uploaden...',
'journey.editor.fromGallery': 'Uit galerij',
@@ -2028,6 +2105,11 @@ const nl: Record<string, string> = {
'journey.settings.name': 'Naam',
'journey.settings.subtitle': 'Ondertitel',
'journey.settings.subtitlePlaceholder': 'bijv. Thailand, Vietnam & Cambodja',
'journey.settings.endJourney': 'Reis archiveren',
'journey.settings.reopenJourney': 'Reis herstellen',
'journey.settings.archived': 'Reis gearchiveerd',
'journey.settings.reopened': 'Reis heropend',
'journey.settings.endDescription': 'Verbergt het Live-badge. Je kunt het altijd heropenen.',
'journey.settings.delete': 'Verwijderen',
'journey.settings.deleteJourney': 'Reisverslag verwijderen',
'journey.settings.deleteMessage': '"{title}" verwijderen? Alle vermeldingen en foto\'s gaan verloren.',
@@ -2163,6 +2245,55 @@ 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.',
'transport.addTransport': 'Add transport',
'transport.modalTitle.create': 'Add transport',
'transport.modalTitle.edit': 'Edit transport',
'transport.title': 'Transport',
'transport.addManual': 'Handmatig transport',
}
export default nl
+133 -2
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',
@@ -280,6 +281,16 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'settings.about.featureRequest': 'Zaproponuj funkcję',
'settings.about.featureRequestHint': 'Zaproponuj nową funkcję',
'settings.about.wikiHint': 'Dokumentacja i poradniki',
'settings.about.supporters.badge': 'Miesięczni Patroni',
'settings.about.supporters.title': 'Towarzystwo podróży dla TREK',
'settings.about.supporters.subtitle': 'Gdy planujesz kolejną trasę, te osoby planują razem ze mną przyszłość TREK. Ich comiesięczny wkład idzie bezpośrednio na rozwój i realnie przepracowane godziny — aby TREK pozostał Open Source.',
'settings.about.supporters.since': 'patron od {date}',
'settings.about.supporters.tierEmpty': 'Bądź pierwszy',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK to samodzielnie hostowany planer podróży, który pomaga organizować wyprawy od pierwszego pomysłu po ostatnie wspomnienie. Planowanie dzienne, budżet, listy pakowania, zdjęcia i wiele więcej — wszystko w jednym miejscu, na własnym serwerze.',
'settings.about.madeWith': 'Stworzone z',
'settings.about.madeBy': 'przez Maurice\'a i rosnącą społeczność open-source.',
@@ -517,9 +528,29 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'admin.fileTypesSaved': 'Ustawienia typów plików zostały zapisane',
// Packing Templates & Bag Tracking
'admin.placesPhotos.title': 'Zdjęcia miejsc',
'admin.placesPhotos.subtitle': 'Pobiera zdjęcia z Google Places API. Wyłącz, aby zaoszczędzić limit API. Zdjęcia z Wikimedia nie są objęte.',
'admin.placesAutocomplete.title': 'Autouzupełnianie miejsc',
'admin.placesAutocomplete.subtitle': 'Używa Google Places API do sugestii wyszukiwania. Wyłącz, aby zaoszczędzić limit API.',
'admin.placesDetails.title': 'Szczegóły miejsca',
'admin.placesDetails.subtitle': 'Pobiera szczegółowe informacje o miejscu (godziny, ocena, strona) z Google Places API. Wyłącz, aby zaoszczędzić limit API.',
'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',
@@ -801,6 +832,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
// Trip Planner
'trip.tabs.plan': 'Plan',
'trip.tabs.transports': 'Transport',
'trip.tabs.reservations': 'Rezerwacje',
'trip.tabs.reservationsShort': 'Rezerwacje',
'trip.tabs.packing': 'Lista pakowania',
@@ -822,6 +854,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'trip.toast.reservationAdded': 'Rezerwacja została dodana',
'trip.toast.deleted': 'Usunięto',
'trip.confirm.deletePlace': 'Czy na pewno chcesz usunąć to miejsce?',
'trip.confirm.deletePlaces': 'Usunąć {count} miejsc?',
'trip.toast.placesDeleted': '{count} miejsc usunięto',
// Day Plan Sidebar
'dayplan.emptyDay': 'Brak miejsc zaplanowanych na ten dzień',
@@ -866,6 +900,17 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'places.importFileError': 'Import nie powiódł się',
'places.importAllSkipped': 'Wszystkie miejsca były już w podróży.',
'places.gpxImported': '{count} miejsc zaimportowanych z GPX',
'places.gpxImportTypes': 'Co chcesz zaimportować?',
'places.gpxImportWaypoints': 'Punkty trasy',
'places.gpxImportRoutes': 'Trasy',
'places.gpxImportTracks': 'Trasy GPS (ze śladem)',
'places.gpxImportNoneSelected': 'Wybierz co najmniej jeden typ do importu.',
'places.kmlImportTypes': 'Co chcesz zaimportować?',
'places.kmlImportPoints': 'Punkty (Placemarks)',
'places.kmlImportPaths': 'Ścieżki (LineStrings)',
'places.kmlImportNoneSelected': 'Wybierz co najmniej jeden typ.',
'places.selectionCount': '{count} zaznaczono',
'places.deleteSelected': 'Usuń wybrane',
'places.kmlKmzImported': 'Zaimportowano {count} miejsc z KMZ/KML',
'places.urlResolved': 'Miejsce zaimportowane z URL',
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Zaimportowano: {created} • Pominięto: {skipped}',
@@ -873,6 +918,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'places.assignToDay': 'Do którego dnia dodać?',
'places.all': 'Wszystkie',
'places.unplanned': 'Niezaplanowane',
'places.filterTracks': 'Trasy',
'places.search': 'Szukaj miejsc...',
'places.allCategories': 'Wszystkie kategorie',
'places.categoriesSelected': 'kategorii',
@@ -959,6 +1005,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',
@@ -973,6 +1020,15 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.restaurant': 'Restauracja',
'reservations.type.train': 'Pociąg',
'reservations.type.car': 'Samochód',
'reservations.needsReview': 'Sprawdź',
'reservations.needsReviewHint': 'Nie udało się automatycznie dopasować lotniska — potwierdź lokalizację.',
'reservations.searchLocation': 'Szukaj stacji, portu, adresu...',
'airport.searchPlaceholder': 'Kod lotniska lub miasto (np. FRA)',
'map.connections': 'Połączenia',
'map.showConnections': 'Pokaż trasy rezerwacji',
'map.hideConnections': 'Ukryj trasy rezerwacji',
'settings.bookingLabels': 'Etykiety tras rezerwacji',
'settings.bookingLabelsHint': 'Pokazuje nazwy stacji / lotnisk na mapie. Gdy wyłączone, wyświetlana jest tylko ikona.',
'reservations.type.cruise': 'Rejs',
'reservations.type.event': 'Wydarzenie',
'reservations.type.tour': 'Wycieczka',
@@ -1033,6 +1089,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'reservations.span.end': 'Koniec',
'reservations.span.ongoing': 'W trakcie',
'reservations.validation.endBeforeStart': 'Data/godzina zakończenia musi być późniejsza niż data/godzina rozpoczęcia',
'reservations.addBooking': 'Dodaj rezerwację',
// Budget
'budget.title': 'Budżet',
@@ -1441,6 +1498,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',
@@ -1467,6 +1525,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'memories.providerPassword': 'Hasło',
'memories.providerOTP': 'Kod MFA (jeśli włączony)',
'memories.skipSSLVerification': 'Pomiń weryfikację certyfikatu SSL',
'memories.immichAutoUpload': 'Przy przesyłaniu kopiuj zdjęcia journey także do Immich',
'memories.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo',
'memories.testConnection': 'Test',
'memories.connected': 'Połączono',
@@ -1563,6 +1622,9 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'collab.polls.delete': 'Usuń',
'collab.polls.closedSection': 'Zamknięte',
'common.import': 'Importuj',
'common.select': 'Wybierz',
'common.selectAll': 'Zaznacz wszystko',
'common.deselectAll': 'Odznacz wszystko',
'common.saved': 'Zapisano',
'trips.reminder': 'Przypomnienie',
'trips.reminderNone': 'Brak',
@@ -1597,22 +1659,29 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'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.notifications.tripReminders.title': 'Przypomnienia o podróżach',
'admin.notifications.tripReminders.hint': 'Wysyła powiadomienie z przypomnieniem przed rozpoczęciem podróży (wymaga ustawienia dni przypomnienia dla podróży).',
'admin.notifications.tripReminders.enabled': 'Przypomnienia o podróżach włączone',
'admin.notifications.tripReminders.disabled': 'Przypomnienia o podróżach wyłączone',
'admin.webhook.hint': 'Pozwól użytkownikom konfigurować własne adresy URL webhooka dla powiadomień (Discord, Slack itp.).',
'settings.notificationsDisabled': 'Powiadomienia nie są skonfigurowane.',
'settings.notificationPreferences.noChannels': 'Brak skonfigurowanych kanałów powiadomień. Poproś administratora o skonfigurowanie powiadomień e-mail lub webhook.',
@@ -1634,7 +1703,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'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.clearToken': 'Wyczyść',
'settings.ntfyUrl.tokenCleared': 'Token dostępu wyczyszczony',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1730,6 +1798,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'undo.reorder': 'Kolejność zmieniona',
'undo.optimize': 'Trasa zoptymalizowana',
'undo.deletePlace': 'Miejsce usunięte',
'undo.deletePlaces': 'Miejsca usunięte',
'undo.moveDay': 'Miejsce przeniesione',
'undo.lock': 'Blokada przełączona',
'undo.importGpx': 'Import GPX',
@@ -1782,7 +1851,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'Nieprzypisane',
'todo.noCategory': 'Brak kategorii',
'todo.hasDescription': 'Ma opis',
'todo.addItem': 'Dodaj nowe zadanie...',
'todo.addItem': 'Nowe zadanie',
'todo.sidebar.sortBy': 'Sortuj wg',
'todo.priority': 'Priorytet',
'todo.newCategoryLabel': 'nowa',
'budget.categoriesLabel': 'kategorie',
'todo.newCategory': 'Nazwa kategorii',
'todo.addCategory': 'Dodaj kategorię',
'todo.newItem': 'Nowe zadanie',
@@ -1847,6 +1920,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'memories.saveRouteNotConfigured': 'Trasa zapisu nie jest skonfigurowana dla tego dostawcy',
'memories.testRouteNotConfigured': 'Trasa testowa nie jest skonfigurowana dla tego dostawcy',
'memories.fillRequiredFields': 'Proszę wypełnić wszystkie wymagane pola',
'journey.search.placeholder': 'Szukaj podróży…',
'journey.search.noResults': 'Brak podróży pasujących do „{query}"',
'journey.title': 'Dziennik podróży',
'journey.subtitle': 'Dokumentuj swoje podróże na bieżąco',
'journey.new': 'Nowy dziennik podróży',
@@ -1868,6 +1943,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.status.active': 'Aktywny',
'journey.status.completed': 'Zakończony',
'journey.status.upcoming': 'Nadchodzący',
'journey.status.archived': 'Zarchiwizowano',
'journey.checkin.add': 'Zamelduj się',
'journey.checkin.namePlaceholder': 'Nazwa miejsca',
'journey.checkin.notesPlaceholder': 'Notatki (opcjonalnie)',
@@ -1944,6 +2020,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.verdict.couldBeBetter': 'Mogłoby być lepiej',
'journey.synced.places': 'miejsca',
'journey.synced.synced': 'zsynchronizowane',
'journey.editor.discardChangesConfirm': 'Masz niezapisane zmiany. Odrzucić?',
'journey.editor.uploadPhotos': 'Prześlij zdjęcia',
'journey.editor.uploading': 'Przesyłanie...',
'journey.editor.fromGallery': 'Z galerii',
@@ -2021,6 +2098,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.name': 'Nazwa',
'journey.settings.subtitle': 'Podtytuł',
'journey.settings.subtitlePlaceholder': 'np. Tajlandia, Wietnam i Kambodża',
'journey.settings.endJourney': 'Archiwizuj podróż',
'journey.settings.reopenJourney': 'Przywróć podróż',
'journey.settings.archived': 'Podróż zarchiwizowana',
'journey.settings.reopened': 'Podróż wznowiona',
'journey.settings.endDescription': 'Ukrywa odznakę Na żywo. Możesz wznowić w dowolnym momencie.',
'journey.settings.delete': 'Usuń',
'journey.settings.deleteJourney': 'Usuń dziennik podróży',
'journey.settings.deleteMessage': 'Usunąć „{title}"? Wszystkie wpisy i zdjęcia zostaną utracone.',
@@ -2156,6 +2238,55 @@ 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.',
'transport.addTransport': 'Add transport',
'transport.modalTitle.create': 'Add transport',
'transport.modalTitle.edit': 'Edit transport',
'transport.title': 'Transport',
'transport.addManual': 'Ręczny transport',
}
export default pl
+134 -3
View File
@@ -4,11 +4,15 @@ const ru: Record<string, string> = {
'common.showMore': 'Показать больше',
'common.showLess': 'Показать меньше',
'common.cancel': 'Отмена',
'common.clear': 'Очистить',
'common.delete': 'Удалить',
'common.edit': 'Редактировать',
'common.add': 'Добавить',
'common.loading': 'Загрузка...',
'common.import': 'Импорт',
'common.select': 'Выбрать',
'common.selectAll': 'Выбрать всё',
'common.deselectAll': 'Снять выделение со всех',
'common.error': 'Ошибка',
'common.unknownError': 'Неизвестная ошибка',
'common.tooManyAttempts': 'Слишком много попыток. Попробуйте позже.',
@@ -307,6 +311,16 @@ const ru: Record<string, string> = {
'settings.about.featureRequest': 'Предложить функцию',
'settings.about.featureRequestHint': 'Предложите новую функцию',
'settings.about.wikiHint': 'Документация и руководства',
'settings.about.supporters.badge': 'Ежемесячные спонсоры',
'settings.about.supporters.title': 'Спутники TREK',
'settings.about.supporters.subtitle': 'Пока ты планируешь следующий маршрут, эти люди планируют вместе со мной будущее TREK. Их ежемесячный взнос идёт напрямую в разработку и реально потраченные часы — чтобы TREK оставался Open Source.',
'settings.about.supporters.since': 'спонсор с {date}',
'settings.about.supporters.tierEmpty': 'Стань первым',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK — это самостоятельно размещаемый планировщик путешествий, который помогает организовать поездки от первой идеи до последнего воспоминания. Планирование по дням, бюджет, списки вещей, фото и многое другое — всё в одном месте, на вашем собственном сервере.',
'settings.about.madeWith': 'Сделано с',
'settings.about.madeBy': 'Морисом и растущим open-source сообществом.',
@@ -545,9 +559,29 @@ const ru: Record<string, string> = {
'admin.fileTypesFormat': 'Расширения через запятую (напр. jpg,png,pdf,doc). Используйте * для разрешения всех типов.',
'admin.fileTypesSaved': 'Настройки типов файлов сохранены',
'admin.placesPhotos.title': 'Фотографии мест',
'admin.placesPhotos.subtitle': 'Загрузка фотографий из Google Places API. Отключите для экономии квоты API. Фотографии Wikimedia не затронуты.',
'admin.placesAutocomplete.title': 'Автодополнение мест',
'admin.placesAutocomplete.subtitle': 'Использование Google Places API для поисковых подсказок. Отключите для экономии квоты API.',
'admin.placesDetails.title': 'Сведения о месте',
'admin.placesDetails.subtitle': 'Загрузка подробной информации о месте (часы работы, рейтинг, веб-сайт) из Google Places API. Отключите для экономии квоты API.',
'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': 'Создавайте многоразовые списки вещей для поездок',
@@ -833,6 +867,7 @@ const ru: Record<string, string> = {
// Trip Planner
'trip.tabs.plan': 'План',
'trip.tabs.transports': 'Транспорт',
'trip.tabs.reservations': 'Бронирования',
'trip.tabs.reservationsShort': 'Брони',
'trip.tabs.packing': 'Список вещей',
@@ -855,6 +890,8 @@ const ru: Record<string, string> = {
'trip.toast.reservationAdded': 'Бронирование добавлено',
'trip.toast.deleted': 'Удалено',
'trip.confirm.deletePlace': 'Вы уверены, что хотите удалить это место?',
'trip.confirm.deletePlaces': 'Удалить {count} мест?',
'trip.toast.placesDeleted': '{count} мест удалено',
// Day Plan Sidebar
'dayplan.emptyDay': 'На этот день мест не запланировано',
@@ -899,6 +936,17 @@ const ru: Record<string, string> = {
'places.importFileError': 'Ошибка импорта',
'places.importAllSkipped': 'Все места уже были в поездке.',
'places.gpxImported': '{count} мест импортировано из GPX',
'places.gpxImportTypes': 'Что импортировать?',
'places.gpxImportWaypoints': 'Путевые точки',
'places.gpxImportRoutes': 'Маршруты',
'places.gpxImportTracks': 'Треки (с геометрией пути)',
'places.gpxImportNoneSelected': 'Выберите хотя бы один тип для импорта.',
'places.kmlImportTypes': 'Что вы хотите импортировать?',
'places.kmlImportPoints': 'Точки (Placemarks)',
'places.kmlImportPaths': 'Маршруты (LineStrings)',
'places.kmlImportNoneSelected': 'Выберите хотя бы один тип.',
'places.selectionCount': '{count} выбрано',
'places.deleteSelected': 'Удалить выбранные',
'places.kmlKmzImported': '{count} мест импортировано из KMZ/KML',
'places.urlResolved': 'Место импортировано из URL',
'places.importList': 'Импорт списка',
@@ -915,6 +963,7 @@ const ru: Record<string, string> = {
'places.assignToDay': 'Добавить в какой день?',
'places.all': 'Все',
'places.unplanned': 'Незапланированные',
'places.filterTracks': 'Треки',
'places.search': 'Поиск мест...',
'places.allCategories': 'Все категории',
'places.categoriesSelected': 'категорий',
@@ -998,10 +1047,20 @@ const ru: Record<string, string> = {
'reservations.meta.flightNumber': 'Номер рейса',
'reservations.meta.from': 'Откуда',
'reservations.meta.to': 'Куда',
'reservations.needsReview': 'Проверить',
'reservations.needsReviewHint': 'Аэропорт не удалось определить автоматически — подтвердите местоположение.',
'reservations.searchLocation': 'Искать станцию, порт, адрес...',
'airport.searchPlaceholder': 'Код аэропорта или город (напр. FRA)',
'map.connections': 'Соединения',
'map.showConnections': 'Показать маршруты бронирований',
'map.hideConnections': 'Скрыть маршруты бронирований',
'settings.bookingLabels': 'Подписи маршрутов бронирований',
'settings.bookingLabelsHint': 'Отображает названия станций / аэропортов на карте. Если выключено, показывается только значок.',
'reservations.meta.trainNumber': 'Номер поезда',
'reservations.meta.platform': 'Платформа',
'reservations.meta.seat': 'Место',
'reservations.meta.checkIn': 'Заезд',
'reservations.meta.checkInUntil': 'Заселение до',
'reservations.meta.checkOut': 'Выезд',
'reservations.meta.linkAccommodation': 'Жильё',
'reservations.meta.pickAccommodation': 'Привязать к жилью',
@@ -1015,7 +1074,7 @@ const ru: Record<string, string> = {
'reservations.type.hotel': 'Жильё',
'reservations.type.restaurant': 'Ресторан',
'reservations.type.train': 'Поезд',
'reservations.type.car': 'Аренда авто',
'reservations.type.car': 'Автомобиль',
'reservations.type.cruise': 'Круиз',
'reservations.type.event': 'Мероприятие',
'reservations.type.tour': 'Экскурсия',
@@ -1076,6 +1135,7 @@ const ru: Record<string, string> = {
'reservations.span.end': 'Конец',
'reservations.span.ongoing': 'Продолжается',
'reservations.validation.endBeforeStart': 'Дата/время окончания должны быть позже даты/времени начала',
'reservations.addBooking': 'Добавить бронирование',
// Budget
'budget.title': 'Бюджет',
@@ -1486,6 +1546,7 @@ const ru: Record<string, string> = {
'day.noPlacesForHotel': 'Сначала добавьте места в поездку',
'day.allDays': 'Все',
'day.checkIn': 'Заезд',
'day.checkInUntil': 'До',
'day.checkOut': 'Выезд',
'day.confirmation': 'Подтверждение',
'day.editAccommodation': 'Редактировать жильё',
@@ -1512,6 +1573,7 @@ const ru: Record<string, string> = {
'memories.providerPassword': 'Пароль',
'memories.providerOTP': 'Код MFA (если включён)',
'memories.skipSSLVerification': 'Пропустить проверку SSL-сертификата',
'memories.immichAutoUpload': 'Дублировать фото journey в Immich при загрузке',
'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo',
'memories.testConnection': 'Проверить подключение',
'memories.testFirst': 'Сначала проверьте подключение',
@@ -1673,6 +1735,7 @@ const ru: Record<string, string> = {
'undo.reorder': 'Места переупорядочены',
'undo.optimize': 'Маршрут оптимизирован',
'undo.deletePlace': 'Место удалено',
'undo.deletePlaces': 'Места удалены',
'undo.moveDay': 'Место перемещено в другой день',
'undo.lock': 'Блокировка места изменена',
'undo.importGpx': 'Импорт GPX',
@@ -1732,7 +1795,11 @@ const ru: Record<string, string> = {
'todo.unassigned': 'Не назначено',
'todo.noCategory': 'Без категории',
'todo.hasDescription': 'Есть описание',
'todo.addItem': 'Добавить новую задачу...',
'todo.addItem': 'Новая задача',
'todo.sidebar.sortBy': 'Сортировать по',
'todo.priority': 'Приоритет',
'todo.newCategoryLabel': 'новая',
'budget.categoriesLabel': 'категорий',
'todo.newCategory': 'Название категории',
'todo.addCategory': 'Добавить категорию',
'todo.newItem': 'Новая задача',
@@ -1774,7 +1841,6 @@ const ru: Record<string, string> = {
'settings.ntfyUrl.test': 'Тест',
'settings.ntfyUrl.testSuccess': 'Тестовое уведомление Ntfy успешно отправлено',
'settings.ntfyUrl.testFailed': 'Ошибка отправки тестового уведомления Ntfy',
'settings.ntfyUrl.clearToken': 'Очистить',
'settings.ntfyUrl.tokenCleared': 'Токен доступа очищен',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1791,22 +1857,29 @@ const ru: Record<string, string> = {
'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.notifications.tripReminders.title': 'Напоминания о поездках',
'admin.notifications.tripReminders.hint': 'Отправляет напоминание перед началом поездки (необходимо указать дни напоминания в параметрах поездки).',
'admin.notifications.tripReminders.enabled': 'Напоминания о поездках включены',
'admin.notifications.tripReminders.disabled': 'Напоминания о поездках отключены',
'admin.tabs.notifications': 'Уведомления',
'notifications.versionAvailable.title': 'Доступно обновление',
'notifications.versionAvailable.text': 'TREK {version} теперь доступен.',
@@ -1854,6 +1927,8 @@ const ru: Record<string, string> = {
'memories.saveRouteNotConfigured': 'Маршрут сохранения не настроен для этого провайдера',
'memories.testRouteNotConfigured': 'Маршрут тестирования не настроен для этого провайдера',
'memories.fillRequiredFields': 'Пожалуйста, заполните все обязательные поля',
'journey.search.placeholder': 'Поиск путешествий…',
'journey.search.noResults': 'Путешествий по запросу «{query}» не найдено',
'journey.title': 'Путешествие',
'journey.subtitle': 'Отслеживайте свои путешествия в реальном времени',
'journey.new': 'Новое путешествие',
@@ -1875,6 +1950,7 @@ const ru: Record<string, string> = {
'journey.status.active': 'Активно',
'journey.status.completed': 'Завершено',
'journey.status.upcoming': 'Предстоящее',
'journey.status.archived': 'В архиве',
'journey.checkin.add': 'Отметиться',
'journey.checkin.namePlaceholder': 'Название места',
'journey.checkin.notesPlaceholder': 'Заметки (необязательно)',
@@ -1951,6 +2027,7 @@ const ru: Record<string, string> = {
'journey.verdict.couldBeBetter': 'Могло быть лучше',
'journey.synced.places': 'мест',
'journey.synced.synced': 'синхронизировано',
'journey.editor.discardChangesConfirm': 'У вас есть несохранённые изменения. Отменить?',
'journey.editor.uploadPhotos': 'Загрузить фото',
'journey.editor.uploading': 'Загрузка...',
'journey.editor.fromGallery': 'Из галереи',
@@ -2028,6 +2105,11 @@ const ru: Record<string, string> = {
'journey.settings.name': 'Название',
'journey.settings.subtitle': 'Подзаголовок',
'journey.settings.subtitlePlaceholder': 'напр. Таиланд, Вьетнам и Камбоджа',
'journey.settings.endJourney': 'Архивировать путешествие',
'journey.settings.reopenJourney': 'Восстановить путешествие',
'journey.settings.archived': 'Путешествие архивировано',
'journey.settings.reopened': 'Путешествие возобновлено',
'journey.settings.endDescription': 'Скрывает значок «В эфире». Вы можете возобновить в любое время.',
'journey.settings.delete': 'Удалить',
'journey.settings.deleteJourney': 'Удалить путешествие',
'journey.settings.deleteMessage': 'Удалить «{title}»? Все записи и фото будут потеряны.',
@@ -2163,6 +2245,55 @@ 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) всегда помогает держать свет включённым.',
'transport.addTransport': 'Add transport',
'transport.modalTitle.create': 'Add transport',
'transport.modalTitle.edit': 'Edit transport',
'transport.title': 'Транспорт',
'transport.addManual': 'Ручной транспорт',
}
export default ru
+134 -3
View File
@@ -4,11 +4,15 @@ const zh: Record<string, string> = {
'common.showMore': '显示更多',
'common.showLess': '收起',
'common.cancel': '取消',
'common.clear': '清除',
'common.delete': '删除',
'common.edit': '编辑',
'common.add': '添加',
'common.loading': '加载中...',
'common.import': '导入',
'common.select': '选择',
'common.selectAll': '全选',
'common.deselectAll': '取消全选',
'common.error': '错误',
'common.unknownError': '未知错误',
'common.tooManyAttempts': '尝试次数过多,请稍后再试。',
@@ -307,6 +311,16 @@ const zh: Record<string, string> = {
'settings.about.featureRequest': '功能建议',
'settings.about.featureRequestHint': '建议一个新功能',
'settings.about.wikiHint': '文档和指南',
'settings.about.supporters.badge': '月度支持者',
'settings.about.supporters.title': '与 TREK 同行的伙伴',
'settings.about.supporters.subtitle': '当你在规划下一段路线时,这些人也在一起规划 TREK 的未来。他们每月的支持直接用于开发与真实投入的时间——让 TREK 保持开源。',
'settings.about.supporters.since': '{date} 起的支持者',
'settings.about.supporters.tierEmpty': '成为第一个',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK 是一个自托管的旅行规划工具,帮助你从最初的想法到最后的回忆,全程组织你的旅行。日程规划、预算、行李清单、照片等——一切尽在一处,在你自己的服务器上。',
'settings.about.madeWith': '用',
'settings.about.madeBy': '由 Maurice 和不断壮大的开源社区打造。',
@@ -545,9 +559,29 @@ const zh: Record<string, string> = {
'admin.fileTypesFormat': '以逗号分隔的扩展名(如 jpg,png,pdf,doc)。使用 * 允许所有类型。',
'admin.fileTypesSaved': '文件类型设置已保存',
'admin.placesPhotos.title': '地点照片',
'admin.placesPhotos.subtitle': '从 Google Places API 获取照片。禁用可节省 API 配额。Wikimedia 照片不受影响。',
'admin.placesAutocomplete.title': '地点自动补全',
'admin.placesAutocomplete.subtitle': '使用 Google Places API 提供搜索建议。禁用可节省 API 配额。',
'admin.placesDetails.title': '地点详情',
'admin.placesDetails.subtitle': '从 Google Places API 获取地点详细信息(营业时间、评分、网站)。禁用可节省 API 配额。',
'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': '创建可复用的旅行打包清单',
@@ -833,6 +867,7 @@ const zh: Record<string, string> = {
// Trip Planner
'trip.tabs.plan': '计划',
'trip.tabs.transports': '交通',
'trip.tabs.reservations': '预订',
'trip.tabs.reservationsShort': '预订',
'trip.tabs.packing': '行李清单',
@@ -855,6 +890,8 @@ const zh: Record<string, string> = {
'trip.toast.reservationAdded': '预订已添加',
'trip.toast.deleted': '已删除',
'trip.confirm.deletePlace': '确定要删除这个地点吗?',
'trip.confirm.deletePlaces': '删除 {count} 个地点?',
'trip.toast.placesDeleted': '已删除 {count} 个地点',
// Day Plan Sidebar
'dayplan.emptyDay': '当天暂无计划',
@@ -899,6 +936,17 @@ const zh: Record<string, string> = {
'places.importFileError': '导入失败',
'places.importAllSkipped': '所有地点已在行程中。',
'places.gpxImported': '已从 GPX 导入 {count} 个地点',
'places.gpxImportTypes': '要导入什么?',
'places.gpxImportWaypoints': '路点',
'places.gpxImportRoutes': '路线',
'places.gpxImportTracks': '轨迹(含路径几何)',
'places.gpxImportNoneSelected': '请至少选择一种导入类型。',
'places.kmlImportTypes': '要导入什么?',
'places.kmlImportPoints': '点(Placemarks',
'places.kmlImportPaths': '路径(LineStrings',
'places.kmlImportNoneSelected': '请至少选择一种类型。',
'places.selectionCount': '已选 {count} 项',
'places.deleteSelected': '删除所选',
'places.kmlKmzImported': '已从 KMZ/KML 导入 {count} 个地点',
'places.urlResolved': '已从 URL 导入地点',
'places.importList': '列表导入',
@@ -915,6 +963,7 @@ const zh: Record<string, string> = {
'places.assignToDay': '添加到哪一天?',
'places.all': '全部',
'places.unplanned': '未规划',
'places.filterTracks': '路线',
'places.search': '搜索地点...',
'places.allCategories': '所有分类',
'places.categoriesSelected': '个分类',
@@ -998,10 +1047,20 @@ const zh: Record<string, string> = {
'reservations.meta.flightNumber': '航班号',
'reservations.meta.from': '出发',
'reservations.meta.to': '到达',
'reservations.needsReview': '待确认',
'reservations.needsReviewHint': '无法自动匹配机场 — 请确认位置。',
'reservations.searchLocation': '搜索车站、港口、地址...',
'airport.searchPlaceholder': '机场代码或城市(如 FRA',
'map.connections': '连接',
'map.showConnections': '显示预订路线',
'map.hideConnections': '隐藏预订路线',
'settings.bookingLabels': '预订路线标签',
'settings.bookingLabelsHint': '在地图上显示车站 / 机场名称。关闭时仅显示图标。',
'reservations.meta.trainNumber': '车次',
'reservations.meta.platform': '站台',
'reservations.meta.seat': '座位',
'reservations.meta.checkIn': '入住',
'reservations.meta.checkInUntil': '入住截止',
'reservations.meta.checkOut': '退房',
'reservations.meta.linkAccommodation': '住宿',
'reservations.meta.pickAccommodation': '关联住宿',
@@ -1015,7 +1074,7 @@ const zh: Record<string, string> = {
'reservations.type.hotel': '住宿',
'reservations.type.restaurant': '餐厅',
'reservations.type.train': '火车',
'reservations.type.car': '车',
'reservations.type.car': '车',
'reservations.type.cruise': '邮轮',
'reservations.type.event': '活动',
'reservations.type.tour': '旅游团',
@@ -1076,6 +1135,7 @@ const zh: Record<string, string> = {
'reservations.span.end': '结束',
'reservations.span.ongoing': '进行中',
'reservations.validation.endBeforeStart': '结束日期/时间必须晚于开始日期/时间',
'reservations.addBooking': '添加预订',
// Budget
'budget.title': '预算',
@@ -1486,6 +1546,7 @@ const zh: Record<string, string> = {
'day.noPlacesForHotel': '请先在旅行中添加地点',
'day.allDays': '全部',
'day.checkIn': '入住',
'day.checkInUntil': '截止',
'day.checkOut': '退房',
'day.confirmation': '确认号',
'day.editAccommodation': '编辑住宿',
@@ -1512,6 +1573,7 @@ const zh: Record<string, string> = {
'memories.providerPassword': '密码',
'memories.providerOTP': 'MFA 验证码(如已启用)',
'memories.skipSSLVerification': '跳过 SSL 证书验证',
'memories.immichAutoUpload': '上传 Journey 照片时同步到 Immich',
'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo',
'memories.testConnection': '测试连接',
'memories.testFirst': '请先测试连接',
@@ -1673,6 +1735,7 @@ const zh: Record<string, string> = {
'undo.reorder': '地点已重新排序',
'undo.optimize': '路线已优化',
'undo.deletePlace': '地点已删除',
'undo.deletePlaces': '地点已删除',
'undo.moveDay': '地点已移至另一天',
'undo.lock': '地点锁定已切换',
'undo.importGpx': 'GPX 导入',
@@ -1732,7 +1795,11 @@ const zh: Record<string, string> = {
'todo.unassigned': '未分配',
'todo.noCategory': '无分类',
'todo.hasDescription': '有描述',
'todo.addItem': '添加新任务...',
'todo.addItem': '新建任务',
'todo.sidebar.sortBy': '排序方式',
'todo.priority': '优先级',
'todo.newCategoryLabel': '新建',
'budget.categoriesLabel': '类别',
'todo.newCategory': '分类名称',
'todo.addCategory': '添加分类',
'todo.newItem': '新任务',
@@ -1774,7 +1841,6 @@ const zh: Record<string, string> = {
'settings.ntfyUrl.test': '测试',
'settings.ntfyUrl.testSuccess': '测试 Ntfy 通知发送成功',
'settings.ntfyUrl.testFailed': '测试 Ntfy 通知失败',
'settings.ntfyUrl.clearToken': '清除',
'settings.ntfyUrl.tokenCleared': '访问令牌已清除',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1791,22 +1857,29 @@ const zh: Record<string, string> = {
'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.notifications.tripReminders.title': '行程提醒',
'admin.notifications.tripReminders.hint': '在行程开始前发送提醒通知(需要在行程中设置提醒天数)。',
'admin.notifications.tripReminders.enabled': '行程提醒已启用',
'admin.notifications.tripReminders.disabled': '行程提醒已禁用',
'admin.tabs.notifications': '通知',
'notifications.versionAvailable.title': '有可用更新',
'notifications.versionAvailable.text': 'TREK {version} 现已可用。',
@@ -1854,6 +1927,8 @@ const zh: Record<string, string> = {
'memories.saveRouteNotConfigured': '此提供商未配置保存路由',
'memories.testRouteNotConfigured': '此提供商未配置测试路由',
'memories.fillRequiredFields': '请填写所有必填字段',
'journey.search.placeholder': '搜索旅程…',
'journey.search.noResults': '没有与"{query}"匹配的旅程',
'journey.title': '旅程',
'journey.subtitle': '实时记录你的旅行',
'journey.new': '新建旅程',
@@ -1875,6 +1950,7 @@ const zh: Record<string, string> = {
'journey.status.active': '进行中',
'journey.status.completed': '已完成',
'journey.status.upcoming': '即将开始',
'journey.status.archived': '已归档',
'journey.checkin.add': '签到',
'journey.checkin.namePlaceholder': '地点名称',
'journey.checkin.notesPlaceholder': '备注(可选)',
@@ -1951,6 +2027,7 @@ const zh: Record<string, string> = {
'journey.verdict.couldBeBetter': '有待改进',
'journey.synced.places': '个地点',
'journey.synced.synced': '已同步',
'journey.editor.discardChangesConfirm': '您有未保存的更改。要放弃吗?',
'journey.editor.uploadPhotos': '上传照片',
'journey.editor.uploading': '上传中...',
'journey.editor.fromGallery': '从相册',
@@ -2028,6 +2105,11 @@ const zh: Record<string, string> = {
'journey.settings.name': '名称',
'journey.settings.subtitle': '副标题',
'journey.settings.subtitlePlaceholder': '例如 泰国、越南和柬埔寨',
'journey.settings.endJourney': '归档旅程',
'journey.settings.reopenJourney': '恢复旅程',
'journey.settings.archived': '旅程已归档',
'journey.settings.reopened': '旅程已重新开启',
'journey.settings.endDescription': '隐藏直播标记。您可以随时重新开启。',
'journey.settings.delete': '删除',
'journey.settings.deleteJourney': '删除旅程',
'journey.settings.deleteMessage': '删除"{title}"?所有条目和照片将丢失。',
@@ -2163,6 +2245,55 @@ 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)能让这盏灯一直亮着。',
'transport.addTransport': 'Add transport',
'transport.modalTitle.create': 'Add transport',
'transport.modalTitle.edit': 'Edit transport',
'transport.title': '交通',
'transport.addManual': '手动添加交通',
}
export default zh
+134 -3
View File
@@ -4,11 +4,15 @@ const zhTw: Record<string, string> = {
'common.showMore': '顯示更多',
'common.showLess': '收起',
'common.cancel': '取消',
'common.clear': '清除',
'common.delete': '刪除',
'common.edit': '編輯',
'common.add': '新增',
'common.loading': '載入中...',
'common.import': '匯入',
'common.select': '選擇',
'common.selectAll': '全選',
'common.deselectAll': '取消全選',
'common.error': '錯誤',
'common.unknownError': '未知錯誤',
'common.tooManyAttempts': '嘗試次數過多,請稍後再試。',
@@ -206,7 +210,6 @@ const zhTw: Record<string, string> = {
'settings.ntfyUrl.test': '測試',
'settings.ntfyUrl.testSuccess': '測試 Ntfy 通知傳送成功',
'settings.ntfyUrl.testFailed': '測試 Ntfy 通知失敗',
'settings.ntfyUrl.clearToken': '清除',
'settings.ntfyUrl.tokenCleared': '存取權杖已清除',
'settings.notificationsDisabled': '通知尚未配置。請聯絡管理員啟用電子郵件或 Webhook 通知。',
'settings.notificationsActive': '活躍頻道',
@@ -232,22 +235,29 @@ const zhTw: Record<string, string> = {
'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.notifications.tripReminders.title': '行程提醒',
'admin.notifications.tripReminders.hint': '在行程開始前發送提醒通知(需要在行程中設定提醒天數)。',
'admin.notifications.tripReminders.enabled': '行程提醒已啟用',
'admin.notifications.tripReminders.disabled': '行程提醒已停用',
'admin.smtp.title': '郵件與通知',
'admin.smtp.hint': '用於傳送電子郵件通知的 SMTP 配置。',
'admin.smtp.testButton': '傳送測試郵件',
@@ -360,6 +370,16 @@ const zhTw: Record<string, string> = {
'settings.about.featureRequest': '功能建議',
'settings.about.featureRequestHint': '建議新功能',
'settings.about.wikiHint': '文件與指南',
'settings.about.supporters.badge': '月度支持者',
'settings.about.supporters.title': '與 TREK 同行的夥伴',
'settings.about.supporters.subtitle': '當你規劃下一段路線時,這些人也在一起規劃 TREK 的未來。他們每月的支持直接用於開發與實際投入的時間——讓 TREK 保持開源。',
'settings.about.supporters.since': '自 {date} 起的支持者',
'settings.about.supporters.tierEmpty': '成為第一個',
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description': 'TREK 是一款自架旅遊規劃器,幫助您從最初構想到最後回憶,整理每次旅行。日程規劃、預算、行李清單、照片及更多功能——全部集中在您自己的伺服器上。',
'settings.about.madeWith': '以',
'settings.about.madeBy': '由 Maurice 及不斷成長的開源社群製作。',
@@ -599,9 +619,29 @@ const zhTw: Record<string, string> = {
'admin.fileTypesFormat': '以逗號分隔的副檔名(如 jpg,png,pdf,doc)。使用 * 允許所有型別。',
'admin.fileTypesSaved': '檔案型別設定已儲存',
'admin.placesPhotos.title': '地點照片',
'admin.placesPhotos.subtitle': '從 Google Places API 獲取照片。停用可節省 API 配額。Wikimedia 照片不受影響。',
'admin.placesAutocomplete.title': '地點自動補全',
'admin.placesAutocomplete.subtitle': '使用 Google Places API 提供搜尋建議。停用可節省 API 配額。',
'admin.placesDetails.title': '地點詳情',
'admin.placesDetails.subtitle': '從 Google Places API 獲取地點詳細資訊(營業時間、評分、網站)。停用可節省 API 配額。',
'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': '建立可複用的旅行打包清單',
@@ -887,6 +927,7 @@ const zhTw: Record<string, string> = {
// Trip Planner
'trip.tabs.plan': '計劃',
'trip.tabs.transports': '交通',
'trip.tabs.reservations': '預訂',
'trip.tabs.reservationsShort': '預訂',
'trip.tabs.packing': '行李清單',
@@ -909,6 +950,8 @@ const zhTw: Record<string, string> = {
'trip.toast.reservationAdded': '預訂已新增',
'trip.toast.deleted': '已刪除',
'trip.confirm.deletePlace': '確定要刪除這個地點嗎?',
'trip.confirm.deletePlaces': '刪除 {count} 個地點?',
'trip.toast.placesDeleted': '已刪除 {count} 個地點',
// Day Plan Sidebar
'dayplan.emptyDay': '當天暫無計劃',
@@ -953,6 +996,17 @@ const zhTw: Record<string, string> = {
'places.importFileError': '匯入失敗',
'places.importAllSkipped': '所有地點已在行程中。',
'places.gpxImported': '已從 GPX 匯入 {count} 個地點',
'places.gpxImportTypes': '要匯入什麼?',
'places.gpxImportWaypoints': '路點',
'places.gpxImportRoutes': '路線',
'places.gpxImportTracks': '軌跡(含路徑幾何)',
'places.gpxImportNoneSelected': '請至少選擇一種匯入類型。',
'places.kmlImportTypes': '要匯入什麼?',
'places.kmlImportPoints': '點(Placemarks',
'places.kmlImportPaths': '路徑(LineStrings',
'places.kmlImportNoneSelected': '請至少選擇一種類型。',
'places.selectionCount': '已選 {count} 項',
'places.deleteSelected': '刪除所選',
'places.kmlKmzImported': '已從 KMZ/KML 匯入 {count} 個地點',
'places.urlResolved': '已從 URL 匯入地點',
'places.importList': '列表匯入',
@@ -969,6 +1023,7 @@ const zhTw: Record<string, string> = {
'places.assignToDay': '新增到哪一天?',
'places.all': '全部',
'places.unplanned': '未規劃',
'places.filterTracks': '路線',
'places.search': '搜尋地點...',
'places.allCategories': '所有分類',
'places.categoriesSelected': '個分類',
@@ -1052,10 +1107,20 @@ const zhTw: Record<string, string> = {
'reservations.meta.flightNumber': '航班號',
'reservations.meta.from': '出發',
'reservations.meta.to': '到達',
'reservations.needsReview': '待確認',
'reservations.needsReviewHint': '無法自動匹配機場 — 請確認位置。',
'reservations.searchLocation': '搜尋車站、港口、地址...',
'airport.searchPlaceholder': '機場代碼或城市(例如 FRA',
'map.connections': '連接',
'map.showConnections': '顯示預訂路線',
'map.hideConnections': '隱藏預訂路線',
'settings.bookingLabels': '預訂路線標籤',
'settings.bookingLabelsHint': '在地圖上顯示車站 / 機場名稱。關閉時僅顯示圖示。',
'reservations.meta.trainNumber': '車次',
'reservations.meta.platform': '站臺',
'reservations.meta.seat': '座位',
'reservations.meta.checkIn': '入住',
'reservations.meta.checkInUntil': '入住截止',
'reservations.meta.checkOut': '退房',
'reservations.meta.linkAccommodation': '住宿',
'reservations.meta.pickAccommodation': '關聯住宿',
@@ -1069,7 +1134,7 @@ const zhTw: Record<string, string> = {
'reservations.type.hotel': '住宿',
'reservations.type.restaurant': '餐廳',
'reservations.type.train': '火車',
'reservations.type.car': '車',
'reservations.type.car': '車',
'reservations.type.cruise': '郵輪',
'reservations.type.event': '活動',
'reservations.type.tour': '旅遊團',
@@ -1130,6 +1195,7 @@ const zhTw: Record<string, string> = {
'reservations.span.end': '結束',
'reservations.span.ongoing': '進行中',
'reservations.validation.endBeforeStart': '結束日期/時間必須晚於開始日期/時間',
'reservations.addBooking': '新增預訂',
// Budget
'budget.title': '預算',
@@ -1540,6 +1606,7 @@ const zhTw: Record<string, string> = {
'day.noPlacesForHotel': '請先在旅行中新增地點',
'day.allDays': '全部',
'day.checkIn': '入住',
'day.checkInUntil': '截止',
'day.checkOut': '退房',
'day.confirmation': '確認號',
'day.editAccommodation': '編輯住宿',
@@ -1566,6 +1633,7 @@ const zhTw: Record<string, string> = {
'memories.providerPassword': '密碼',
'memories.providerOTP': 'MFA 驗證碼(如已啟用)',
'memories.skipSSLVerification': '跳過 SSL 憑證驗證',
'memories.immichAutoUpload': '上傳 Journey 照片時同步到 Immich',
'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo',
'memories.testConnection': '測試連線',
'memories.testFirst': '請先測試連線',
@@ -1727,6 +1795,7 @@ const zhTw: Record<string, string> = {
'undo.reorder': '地點已重新排序',
'undo.optimize': '路線已最佳化',
'undo.deletePlace': '地點已刪除',
'undo.deletePlaces': '地點已刪除',
'undo.moveDay': '地點已移至另一天',
'undo.lock': '地點鎖定已切換',
'undo.importGpx': 'GPX 匯入',
@@ -1747,7 +1816,11 @@ const zhTw: Record<string, string> = {
'todo.unassigned': '未指派',
'todo.noCategory': '無分類',
'todo.hasDescription': '有說明',
'todo.addItem': '新增任務...',
'todo.addItem': '新增任務',
'todo.sidebar.sortBy': '排序方式',
'todo.priority': '優先順序',
'todo.newCategoryLabel': '新增',
'budget.categoriesLabel': '類別',
'todo.newCategory': '分類名稱',
'todo.addCategory': '新增分類',
'todo.newItem': '新任務',
@@ -1814,6 +1887,8 @@ const zhTw: Record<string, string> = {
'memories.saveRouteNotConfigured': '此提供商未設定儲存路由',
'memories.testRouteNotConfigured': '此提供商未設定測試路由',
'memories.fillRequiredFields': '請填寫所有必填欄位',
'journey.search.placeholder': '搜尋旅程…',
'journey.search.noResults': '沒有符合「{query}」的旅程',
'journey.title': '旅程',
'journey.subtitle': '即時記錄你的旅行',
'journey.new': '新建旅程',
@@ -1835,6 +1910,7 @@ const zhTw: Record<string, string> = {
'journey.status.active': '進行中',
'journey.status.completed': '已完成',
'journey.status.upcoming': '即將開始',
'journey.status.archived': '已封存',
'journey.checkin.add': '打卡',
'journey.checkin.namePlaceholder': '地點名稱',
'journey.checkin.notesPlaceholder': '備註(可選)',
@@ -1911,6 +1987,7 @@ const zhTw: Record<string, string> = {
'journey.verdict.couldBeBetter': '有待改進',
'journey.synced.places': '個地點',
'journey.synced.synced': '已同步',
'journey.editor.discardChangesConfirm': '您有未儲存的變更。要放棄嗎?',
'journey.editor.uploadPhotos': '上傳照片',
'journey.editor.uploading': '上傳中...',
'journey.editor.fromGallery': '從相簿',
@@ -1988,6 +2065,11 @@ const zhTw: Record<string, string> = {
'journey.settings.name': '名稱',
'journey.settings.subtitle': '副標題',
'journey.settings.subtitlePlaceholder': '例如 泰國、越南和柬埔寨',
'journey.settings.endJourney': '封存旅程',
'journey.settings.reopenJourney': '還原旅程',
'journey.settings.archived': '旅程已封存',
'journey.settings.reopened': '旅程已重新開啟',
'journey.settings.endDescription': '隱藏直播標記。您可以隨時重新開啟。',
'journey.settings.delete': '刪除',
'journey.settings.deleteJourney': '刪除旅程',
'journey.settings.deleteMessage': '刪除「{title}」?所有條目和照片將遺失。',
@@ -2164,6 +2246,55 @@ 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)能讓這盞燈一直亮著。',
'transport.addTransport': 'Add transport',
'transport.modalTitle.create': 'Add transport',
'transport.modalTitle.edit': 'Edit transport',
'transport.title': '交通',
'transport.addManual': '手動新增交通',
}
export default zhTw
+291 -9
View File
@@ -6,6 +6,30 @@ html { height: 100%; overflow: hidden; background-color: var(--bg-primary); }
body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow-scrolling: touch; }
/* Leaflet Popups — Enter-Animation vom Anchor-Tip */
.leaflet-popup {
animation: trek-popover-enter 220ms cubic-bezier(0.23, 1, 0.32, 1);
transform-origin: bottom center;
will-change: transform, opacity;
}
.leaflet-popup-content-wrapper {
border-radius: 14px !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18) !important;
background: var(--bg-card) !important;
color: var(--text-primary) !important;
border: 1px solid var(--border-faint);
}
.leaflet-popup-tip {
background: var(--bg-card) !important;
}
.leaflet-popup-close-button {
transition: color 150ms cubic-bezier(0.23, 1, 0.32, 1), transform 150ms cubic-bezier(0.23, 1, 0.32, 1) !important;
}
.leaflet-popup-close-button:hover {
transform: scale(1.15);
color: var(--text-primary) !important;
}
.atlas-tooltip {
background: rgba(10, 10, 20, 0.6) !important;
backdrop-filter: blur(20px) saturate(180%) !important;
@@ -137,8 +161,268 @@ html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color
to { transform: rotate(360deg); }
}
/* ── Press-Feedback + bessere Easings (Emil Kowalski) ─────────── */
/* Buttons sollen antworten wenn sie gedrückt werden. */
button:not(:disabled):not([data-no-press]),
[role="button"]:not([aria-disabled="true"]):not([data-no-press]) {
transition-property: transform, color, background-color, border-color, box-shadow, opacity, filter !important;
transition-duration: 180ms;
transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
}
button:not(:disabled):not([data-no-press]):active,
[role="button"]:not([aria-disabled="true"]):not([data-no-press]):active {
transform: scale(0.97);
transition-duration: 80ms;
}
/* Tailwind-Default-Easing durch ease-out-quint ersetzen.
Eingebaute CSS-Easings sind kraftlos; ease-out-quint hat Punch. */
.transition,
.transition-all,
.transition-colors,
.transition-opacity,
.transition-transform,
.transition-shadow {
transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
}
/* Input-Focus transitions — border + ring faden weich ein */
input, textarea, select {
transition: border-color 150ms cubic-bezier(0.23, 1, 0.32, 1),
box-shadow 150ms cubic-bezier(0.23, 1, 0.32, 1),
background-color 150ms cubic-bezier(0.23, 1, 0.32, 1);
}
/* Back-Button Icon-Slide on hover */
.trek-back-btn .trek-back-icon {
transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1);
}
.trek-back-btn:hover .trek-back-icon {
transform: translateX(-2px);
}
/* Global focus-visible ring — konsistent überall */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
button:focus-visible, [role="button"]:focus-visible, a:focus-visible {
outline-offset: 3px;
}
input:focus-visible, textarea:focus-visible, select:focus-visible {
outline: none;
}
/* Theme crossfade beim Dark/Light switch, Hauptflächen + Text faden ihre Farben.
Sparingly: nur background-color und color bekommen eine Transition. */
html.trek-theme-transitioning,
html.trek-theme-transitioning body,
html.trek-theme-transitioning *:not(img):not(video):not(canvas):not([class*="trek-skeleton"]):not(.leaflet-layer) {
transition:
background-color 320ms cubic-bezier(0.23, 1, 0.32, 1),
color 320ms cubic-bezier(0.23, 1, 0.32, 1),
border-color 320ms cubic-bezier(0.23, 1, 0.32, 1),
fill 320ms cubic-bezier(0.23, 1, 0.32, 1) !important;
}
/* Touch-Geräte: iOS-Tap-Highlight weg (wir haben eigenes Press-Feedback) */
@media (hover: none) {
button, [role="button"], a {
-webkit-tap-highlight-color: transparent;
}
}
html, body {
-webkit-tap-highlight-color: transparent;
}
/* Tabular-nums global für Time/Date/Currency/Counter */
time, .tabular-nums, [data-tabular],
input[type="number"], input[type="time"], input[type="date"], input[type="datetime-local"] {
font-variant-numeric: tabular-nums;
}
/* Wenn Element explizit ease-in-out nutzt (z.B. Accordions), nicht überschreiben.
Tailwind setzt ease-in-out via eigener Klasse die gewinnt durch letzte Deklaration. */
/* Press-Scale für clickbare Divs (Cards, Tiles) — sanfter als Buttons */
[data-press]:active {
transform: scale(0.985);
transition-duration: 80ms;
}
/* Popover/Dropdown Enter-Animationen
Emil: Popovers sollen von ihrem Trigger aus scalen, nicht vom Center.
Start bei scale(0.95) nichts in der echten Welt poppt aus dem Nichts. */
@keyframes trek-menu-enter {
from { opacity: 0; transform: scale(0.95) translateY(-4px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes trek-popover-enter {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
@keyframes trek-modal-enter {
from { opacity: 0; transform: scale(0.97); }
to { opacity: 1; transform: scale(1); }
}
@keyframes trek-backdrop-enter {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes trek-toast-enter {
from { opacity: 0; transform: translateY(8px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes trek-progress-fill {
from { width: 0%; }
to { width: var(--trek-progress-to, 0%); }
}
/* Pie-Chart Reveal — rotate + fade-in, gibt dem Kreisdiagramm ein "Draw"-Gefühl */
@keyframes trek-pie-reveal {
from { opacity: 0; transform: rotate(-90deg) scale(0.85); }
to { opacity: 1; transform: rotate(0deg) scale(1); }
}
.trek-pie-reveal {
animation: trek-pie-reveal 900ms cubic-bezier(0.23, 1, 0.32, 1) both;
transform-origin: center;
will-change: transform, opacity;
}
/* Bar-Chart Reveal — horizontaler Fill von links */
@keyframes trek-bar-fill {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.trek-bar-fill {
animation: trek-bar-fill 700ms cubic-bezier(0.23, 1, 0.32, 1) both;
transform-origin: left center;
will-change: transform;
}
/* Page-Transition — subtiler Fade-Up beim Mount */
@keyframes trek-page-enter {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.trek-page-enter {
animation: trek-page-enter 220ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
/* Skeleton shimmer — ein fließender Gradient-Strip überquert den Platzhalter */
@keyframes trek-shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
.trek-skeleton {
background: linear-gradient(
90deg,
var(--bg-tertiary) 0%,
var(--bg-hover) 50%,
var(--bg-tertiary) 100%
);
background-size: 200% 100%;
animation: trek-shimmer 1.6s linear infinite;
border-radius: 8px;
color: transparent;
user-select: none;
}
.dark .trek-skeleton {
background: linear-gradient(
90deg,
rgba(255,255,255,0.04) 0%,
rgba(255,255,255,0.08) 50%,
rgba(255,255,255,0.04) 100%
);
background-size: 200% 100%;
}
.trek-menu-enter {
animation: trek-menu-enter 200ms cubic-bezier(0.23, 1, 0.32, 1);
transform-origin: top right;
will-change: transform, opacity;
}
.trek-menu-enter-left {
animation: trek-menu-enter 200ms cubic-bezier(0.23, 1, 0.32, 1);
transform-origin: top left;
will-change: transform, opacity;
}
.trek-popover-enter {
animation: trek-popover-enter 180ms cubic-bezier(0.23, 1, 0.32, 1);
will-change: transform, opacity;
}
.trek-modal-enter {
animation: trek-modal-enter 220ms cubic-bezier(0.23, 1, 0.32, 1);
will-change: transform, opacity;
}
/* Mobile-Drawer-Feel — Modal slidet von unten rein, wird unten am Screen angedockt */
@keyframes trek-drawer-enter {
from { opacity: 0; transform: translateY(100%); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 639px) {
.trek-modal-enter {
animation: trek-drawer-enter 320ms cubic-bezier(0.32, 0.72, 0, 1);
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
margin-top: auto !important;
align-self: flex-end;
}
}
.trek-backdrop-enter {
animation: trek-backdrop-enter 180ms cubic-bezier(0.23, 1, 0.32, 1);
}
.trek-toast-enter {
animation: trek-toast-enter 260ms cubic-bezier(0.23, 1, 0.32, 1);
will-change: transform, opacity;
}
/* Stagger-Helpers für Listen — Enter-Animation mit Offset */
@keyframes trek-fade-up {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.trek-stagger > * {
animation: trek-fade-up 280ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
.trek-stagger > *:nth-child(1) { animation-delay: 0ms; }
.trek-stagger > *:nth-child(2) { animation-delay: 40ms; }
.trek-stagger > *:nth-child(3) { animation-delay: 80ms; }
.trek-stagger > *:nth-child(4) { animation-delay: 120ms; }
.trek-stagger > *:nth-child(5) { animation-delay: 160ms; }
.trek-stagger > *:nth-child(6) { animation-delay: 200ms; }
.trek-stagger > *:nth-child(7) { animation-delay: 240ms; }
.trek-stagger > *:nth-child(8) { animation-delay: 280ms; }
.trek-stagger > *:nth-child(n+9) { animation-delay: 320ms; }
/* Reduced motion — Emil's Accessibility-Regel: fewer and gentler, not zero */
@media (prefers-reduced-motion: reduce) {
.trek-menu-enter, .trek-menu-enter-left, .trek-popover-enter,
.trek-modal-enter, .trek-toast-enter, .trek-stagger > * {
animation: trek-backdrop-enter 120ms ease-out;
}
.trek-skeleton {
animation: none;
background: var(--bg-tertiary);
}
button:not(:disabled):not([data-no-press]):active,
[role="button"]:not([aria-disabled="true"]):not([data-no-press]):active,
[data-press]:active {
transform: none;
}
/* Parallax & lift disablen */
.group:hover img,
.group:hover .cover-img { transform: none !important; }
*:hover { translate: none !important; }
}
/* ── Design tokens ─────────────────────────────── */
:root {
/* Easing curves — stärker als die CSS-Defaults, siehe easing.dev */
--ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out-quint: cubic-bezier(0.77, 0, 0.175, 1);
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
--safe-top: env(safe-area-inset-top, 0px);
--nav-h: 0px;
--bottom-nav-h: 0px;
@@ -323,7 +607,7 @@ body {
display: none;
}
/* Scrollbalken */
/* Scrollbars — styled on desktop, hidden on mobile */
::-webkit-scrollbar {
width: 6px;
height: 6px;
@@ -333,21 +617,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 +691,7 @@ img[alt="TREK"] {
}
.scroll-container {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
@@ -447,11 +734,6 @@ img[alt="TREK"] {
color-scheme: dark;
}
/* Scroll-Container */
.scroll-container {
scrollbar-width: thin;
scrollbar-color: #d1d5db #f1f5f9;
}
/* Toast-Animationen */
@keyframes slideUp {
+162 -21
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'
@@ -10,6 +11,7 @@ import { getApiErrorMessage } from '../types'
import Navbar from '../components/Layout/Navbar'
import Modal from '../components/shared/Modal'
import { useToast } from '../components/shared/Toast'
import { useCountUp } from '../hooks/useCountUp'
import CategoryManager from '../components/Admin/CategoryManager'
import BackupPanel from '../components/Admin/BackupPanel'
import GitHubPanel from '../components/Admin/GitHubPanel'
@@ -160,6 +162,21 @@ function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast
)
}
function AdminStatCard({ label, value, icon: Icon }: { label: string; value: number; icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }> }): React.ReactElement {
const animated = useCountUp(value, 900)
return (
<div className="rounded-xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center gap-4">
<Icon className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
<div>
<p className="text-xl font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{animated}</p>
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{label}</p>
</div>
</div>
</div>
)
}
export default function AdminPage(): React.ReactElement {
const { demoMode, serverTimezone } = useAuthStore()
const { t, locale } = useTranslation()
@@ -169,6 +186,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') },
@@ -192,6 +210,22 @@ export default function AdminPage(): React.ReactElement {
const [bagTrackingEnabled, setBagTrackingEnabled] = useState<boolean>(false)
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
// Places photos
const [placesPhotosEnabled, setPlacesPhotosEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getPlacesPhotos().then(d => setPlacesPhotosEnabledState(d.enabled)).catch(() => {}) }, [])
// Places autocomplete
const [placesAutocompleteEnabled, setPlacesAutocompleteEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getPlacesAutocomplete().then(d => setPlacesAutocompleteEnabledState(d.enabled)).catch(() => {}) }, [])
// Places details
const [placesDetailsEnabled, setPlacesDetailsEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(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)
@@ -236,7 +270,7 @@ export default function AdminPage(): React.ReactElement {
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, logout } = useAuthStore()
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, logout } = useAuthStore()
const navigate = useNavigate()
const toast = useToast()
@@ -547,15 +581,7 @@ export default function AdminPage(): React.ReactElement {
{ label: t('admin.stats.places'), value: stats.totalPlaces, icon: Map },
{ label: t('admin.stats.files'), value: stats.totalFiles || 0, icon: FileText },
].map(({ label, value, icon: Icon }) => (
<div key={label} className="rounded-xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center gap-4">
<Icon className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
<div>
<p className="text-xl font-bold" style={{ color: 'var(--text-primary)' }}>{value}</p>
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{label}</p>
</div>
</div>
</div>
<AdminStatCard key={label} label={label} value={value} icon={Icon} />
))}
</div>
)}
@@ -611,7 +637,7 @@ export default function AdminPage(): React.ReactElement {
<th className="px-5 py-3 text-right">{t('admin.table.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
<tbody className="divide-y divide-slate-100 trek-stagger">
{users.map(u => (
<tr key={u.id} className={`hover:bg-slate-50 transition-colors ${u.id === currentUser?.id ? 'bg-slate-50/60' : ''}`}>
<td className="px-5 py-3">
@@ -797,6 +823,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>
)}
@@ -881,7 +911,7 @@ export default function AdminPage(): React.ReactElement {
</div>
<button
onClick={() => handleToggleAuthSetting('oidc_registration', !oidcRegistration, setOidcRegistration)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
style={{ background: oidcRegistration ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span
@@ -908,7 +938,7 @@ export default function AdminPage(): React.ReactElement {
<button
type="button"
onClick={() => handleToggleRequireMfa(!requireMfa)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
style={{ background: requireMfa ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span
@@ -1013,6 +1043,66 @@ export default function AdminPage(): React.ReactElement {
)}
</div>
{/* Place Photos Toggle */}
<div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.placesPhotos.title')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesPhotos.subtitle')}</p>
</div>
<button
onClick={async () => {
const next = !placesPhotosEnabled
setPlacesPhotosEnabledState(next)
setPlacesPhotosEnabled(next)
try { await adminApi.updatePlacesPhotos(next) } catch { setPlacesPhotosEnabledState(!next); setPlacesPhotosEnabled(!next) }
}}
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
style={{ background: placesPhotosEnabled ? '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: placesPhotosEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
{/* Place Autocomplete Toggle */}
<div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.placesAutocomplete.title')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesAutocomplete.subtitle')}</p>
</div>
<button
onClick={async () => {
const next = !placesAutocompleteEnabled
setPlacesAutocompleteEnabledState(next)
setPlacesAutocompleteEnabled(next)
try { await adminApi.updatePlacesAutocomplete(next) } catch { setPlacesAutocompleteEnabledState(!next); setPlacesAutocompleteEnabled(!next) }
}}
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
style={{ background: placesAutocompleteEnabled ? '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: placesAutocompleteEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
{/* Place Details Toggle */}
<div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.placesDetails.title')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesDetails.subtitle')}</p>
</div>
<button
onClick={async () => {
const next = !placesDetailsEnabled
setPlacesDetailsEnabledState(next)
setPlacesDetailsEnabled(next)
try { await adminApi.updatePlacesDetails(next) } catch { setPlacesDetailsEnabledState(!next); setPlacesDetailsEnabled(!next) }
}}
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
style={{ background: placesDetailsEnabled ? '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: placesDetailsEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
{/* Open-Meteo Weather Info */}
<div className="rounded-lg border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/30 dark:border-emerald-800 overflow-hidden">
<div className="px-4 py-3 flex items-center justify-between">
@@ -1170,6 +1260,7 @@ export default function AdminPage(): React.ReactElement {
const emailActive = activeChans.includes('email')
const webhookActive = activeChans.includes('webhook')
const ntfyActive = activeChans.includes('ntfy')
const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false'
const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => {
const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy'].filter(Boolean).join(',') || 'none'
@@ -1245,7 +1336,7 @@ export default function AdminPage(): React.ReactElement {
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
}}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
style={{ background: smtpValues.smtp_skip_tls_verify === 'true' ? 'var(--text-primary)' : 'var(--border-primary)' }}>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: smtpValues.smtp_skip_tls_verify === 'true' ? 'translateX(20px)' : 'translateX(0)' }} />
@@ -1328,6 +1419,37 @@ export default function AdminPage(): React.ReactElement {
</div>
</div>
{/* Trip Reminders Toggle */}
<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.tripReminders.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.tripReminders.hint')}</p>
</div>
<button
onClick={async () => {
const next = !tripRemindersActive
setSmtpValues(prev => ({ ...prev, notify_trip_reminder: next ? 'true' : 'false' }))
try {
await authApi.updateAppSettings({ notify_trip_reminder: next ? 'true' : 'false' })
toast.success(next ? t('admin.notifications.tripReminders.enabled') : t('admin.notifications.tripReminders.disabled'))
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
}).catch(() => {})
} catch {
setSmtpValues(prev => ({ ...prev, notify_trip_reminder: tripRemindersActive ? 'true' : 'false' }))
toast.error(t('common.error'))
}
}}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
style={{ background: tripRemindersActive ? '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: tripRemindersActive ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
{/* Admin Webhook Panel */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
@@ -1396,6 +1518,7 @@ export default function AdminPage(): React.ReactElement {
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>
@@ -1409,13 +1532,29 @@ export default function AdminPage(): React.ReactElement {
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminNtfyPanel.tokenLabel')}</label>
<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="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 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>
</>
)}
@@ -1476,6 +1615,8 @@ export default function AdminPage(): React.ReactElement {
{activeTab === 'github' && <GitHubPanel isPrerelease={updateInfo?.is_prerelease ?? false} />}
{activeTab === 'defaults' && <DefaultUserSettingsTab />}
{activeTab === 'dev-notifications' && <DevNotificationsPanel />}
</div>
</div>
+1 -1
View File
@@ -938,7 +938,7 @@ export default function AtlasPage(): React.ReactElement {
ref={panelRef}
onMouseMove={handlePanelMouseMove}
onMouseLeave={handlePanelMouseLeave}
className="hidden md:flex flex-col absolute z-10 overflow-hidden transition-all duration-300"
className="hidden md:flex flex-col absolute z-10 overflow-hidden transition-[width,height,transform,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{
bottom: 16,
left: '50%',
+6 -15
View File
@@ -416,15 +416,10 @@ describe('DashboardPage', () => {
expect(screen.getAllByText('Paris Adventure').length).toBeGreaterThan(0);
});
// Find settings button — it's the gear icon button without title or text
// Find settings button — the gear icon button (icon-only, no visible label)
const allBtns = screen.getAllByRole('button');
const settingsButton = allBtns.find(
btn => {
const title = btn.getAttribute('title');
const text = btn.textContent?.trim() || '';
// Settings gear: no title, no meaningful text, not the notification bell
return !title && !text && btn.querySelector('.lucide-settings');
}
const settingsButton = allBtns.find(btn =>
btn.querySelector('.lucide-settings') && !btn.textContent?.trim()
);
expect(settingsButton).toBeDefined();
@@ -646,14 +641,10 @@ describe('DashboardPage', () => {
expect(screen.getAllByText('Paris Adventure').length).toBeGreaterThan(0);
});
// Open widget settings
// Open widget settings — gear icon button (icon-only, no visible label)
const allBtns = screen.getAllByRole('button');
const settingsButton = allBtns.find(
btn => {
const title = btn.getAttribute('title');
const text = btn.textContent?.trim() || '';
return !title && !text && btn.querySelector('.lucide-settings');
}
const settingsButton = allBtns.find(btn =>
btn.querySelector('.lucide-settings') && !btn.textContent?.trim()
);
expect(settingsButton).toBeDefined();
+127 -86
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'
@@ -13,6 +13,7 @@ import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
import TripFormModal from '../components/Trips/TripFormModal'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import { useToast } from '../components/shared/Toast'
import { useCountUp } from '../hooks/useCountUp'
import {
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft, Users,
@@ -152,6 +153,28 @@ interface TripCardProps {
dark?: boolean
}
function SpotlightStats({ trip, totalDays, t }: { trip: DashboardTrip; totalDays: number; t: TripCardProps['t'] }): React.ReactElement {
const days = useCountUp(trip.day_count || totalDays)
const places = useCountUp(trip.place_count || 0)
const buddies = useCountUp(trip.shared_count || 0)
return (
<div className="grid grid-cols-3 gap-2.5 p-3.5 bg-black/25 backdrop-blur-sm border border-white/10 rounded-2xl">
<div className="text-center">
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none tabular-nums">{days}</p>
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.days')}</p>
</div>
<div className="text-center">
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none tabular-nums">{places}</p>
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.places')}</p>
</div>
<div className="text-center">
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none tabular-nums">{buddies}</p>
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.buddies')}</p>
</div>
</div>
)
}
function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: TripCardProps): React.ReactElement {
const status = getTripStatus(trip)
const isLive = status === 'ongoing'
@@ -173,16 +196,16 @@ function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t,
return (
<div
onClick={() => onClick(trip)}
className="group relative rounded-3xl overflow-hidden cursor-pointer mb-8"
style={{ minHeight: 340, boxShadow: '0 8px 40px rgba(0,0,0,0.13)' }}
className="group relative rounded-3xl overflow-hidden cursor-pointer mb-8 transition-[transform,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-1 hover:shadow-[0_16px_60px_rgba(0,0,0,0.22)] active:scale-[0.995]"
style={{ minHeight: 340, boxShadow: '0 8px 40px rgba(0,0,0,0.13)', isolation: 'isolate' }}
>
{/* Background */}
<div className="absolute inset-0" style={{
<div className="absolute inset-0 overflow-hidden rounded-3xl" style={{
background: trip.cover_image ? undefined : tripGradient(trip.id),
}}>
{trip.cover_image && (
<>
<img src={trip.cover_image} className="w-full h-full object-cover" alt="" />
<img src={trip.cover_image} className="w-full h-full object-cover transition-transform duration-[1200ms] ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-[1.06]" alt="" />
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.6) 100%)' }} />
</>
)}
@@ -233,7 +256,14 @@ function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t,
<span className="opacity-70">{t('dashboard.mobile.daysLeft', { count: daysLeft })}</span>
</div>
<div className="h-1.5 bg-white/15 rounded-full overflow-hidden">
<div className="h-full bg-white rounded-full relative" style={{ width: `${progress}%` }}>
<div
className="h-full bg-white rounded-full relative"
style={{
width: `${progress}%`,
animation: 'trek-progress-fill 900ms cubic-bezier(0.23,1,0.32,1) both',
['--trek-progress-to' as string]: `${progress}%`,
}}
>
<span className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-[0_0_12px_rgba(255,255,255,0.9)]" />
</div>
</div>
@@ -241,20 +271,7 @@ function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t,
)}
{/* Stats */}
<div className="grid grid-cols-3 gap-2.5 p-3.5 bg-black/25 backdrop-blur-sm border border-white/10 rounded-2xl">
<div className="text-center">
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{trip.day_count || totalDays}</p>
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.days')}</p>
</div>
<div className="text-center">
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{trip.place_count || 0}</p>
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.places')}</p>
</div>
<div className="text-center">
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{trip.shared_count || 0}</p>
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.buddies')}</p>
</div>
</div>
<SpotlightStats trip={trip} totalDays={totalDays} t={t} />
</div>
</div>
)
@@ -278,13 +295,13 @@ function MobileTripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t,
return (
<div
onClick={() => onClick?.(trip)}
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
style={{ background: 'var(--bg-card)' }}
className="group rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-[transform,box-shadow,border-color] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-md"
style={{ background: 'var(--bg-card)', isolation: 'isolate' }}
>
{/* Cover */}
<div className="relative h-[120px] overflow-hidden" style={{ background: trip.cover_image ? undefined : tripGradient(trip.id) }}>
{trip.cover_image && (
<img src={trip.cover_image} className="absolute inset-0 w-full h-full object-cover" alt="" />
<img src={trip.cover_image} className="absolute inset-0 w-full h-full object-cover transition-transform duration-[800ms] ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-[1.08]" alt="" />
)}
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.5) 100%)' }} />
@@ -370,13 +387,13 @@ function TripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, local
return (
<div
onClick={() => onClick(trip)}
className="group rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-lg hover:border-zinc-300 dark:hover:border-zinc-600"
style={{ background: 'var(--bg-card)' }}
className="group rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-[transform,box-shadow,border-color] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg hover:border-zinc-300 dark:hover:border-zinc-600"
style={{ background: 'var(--bg-card)', isolation: 'isolate' }}
>
{/* Cover */}
<div className="relative h-[140px] overflow-hidden" style={{ background: trip.cover_image ? undefined : tripGradient(trip.id) }}>
{trip.cover_image && (
<img src={trip.cover_image} className="absolute inset-0 w-full h-full object-cover" alt="" />
<img src={trip.cover_image} className="absolute inset-0 w-full h-full object-cover transition-transform duration-[800ms] ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-[1.08]" alt="" />
)}
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.55) 100%)' }} />
@@ -658,11 +675,14 @@ function IconBtn({ onClick, title, danger, loading, children }: { onClick: () =>
// ── Skeleton ─────────────────────────────────────────────────────────────────
function SkeletonCard(): React.ReactElement {
return (
<div style={{ background: 'white', borderRadius: 16, overflow: 'hidden', border: '1px solid #f3f4f6' }}>
<div style={{ height: 120, background: '#f3f4f6', animation: 'pulse 1.5s ease-in-out infinite' }} />
<div style={{ padding: '12px 14px 14px' }}>
<div style={{ height: 14, background: '#f3f4f6', borderRadius: 6, marginBottom: 8, width: '70%' }} />
<div style={{ height: 11, background: '#f3f4f6', borderRadius: 6, width: '50%' }} />
<div
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden"
style={{ background: 'var(--bg-card)' }}
>
<div className="trek-skeleton" style={{ height: 120, borderRadius: 0 }} />
<div style={{ padding: '12px 14px 14px', display: 'flex', flexDirection: 'column', gap: 6 }}>
<div className="trek-skeleton" style={{ height: 14, width: '70%' }} />
<div className="trek-skeleton" style={{ height: 11, width: '50%' }} />
</div>
</div>
)
@@ -689,6 +709,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 +730,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 () => {
@@ -889,61 +917,74 @@ export default function DashboardPage(): React.ReactElement {
</button>
</div>
{/* Desktop header */}
<div className="hidden md:flex" style={{ alignItems: 'center', justifyContent: 'space-between', marginBottom: 28 }}>
<div>
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 800, color: 'var(--text-primary)' }}>{t('dashboard.title')}</h1>
<p style={{ margin: '3px 0 0', fontSize: 13, color: '#9ca3af' }}>
{/* Desktop header — unified toolbar */}
<div className="hidden md:block" style={{ marginBottom: 20 }}>
<div style={{
background: 'var(--bg-tertiary)', borderRadius: 18,
border: '1px solid var(--border-primary)',
boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
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('dashboard.title')}
</h2>
<div style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>
{isLoading ? t('common.loading')
: trips.length > 0 ? `${t(trips.length !== 1 ? 'dashboard.subtitle.activeMany' : 'dashboard.subtitle.activeOne', { count: trips.length })}${archivedTrips.length > 0 ? t('dashboard.subtitle.archivedSuffix', { count: archivedTrips.length }) : ''}`
: t('dashboard.subtitle.empty')}
</p>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
{/* View mode toggle */}
<button
onClick={toggleViewMode}
title={viewMode === 'grid' ? t('dashboard.listView') : t('dashboard.gridView')}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '0 14px', height: 37,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
transition: 'background 0.15s, border-color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.borderColor = 'var(--text-faint)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.borderColor = 'var(--border-primary)' }}
>
{viewMode === 'grid' ? <List size={15} /> : <LayoutGrid size={15} />}
</button>
{/* Widget settings */}
<button
onClick={() => setShowWidgetSettings(s => s ? false : true)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '0 14px', height: 37,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
transition: 'background 0.15s, border-color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.borderColor = 'var(--text-faint)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.borderColor = 'var(--border-primary)' }}
>
<Settings size={15} />
</button>
{can('trip_create') && <button
onClick={() => { setEditingTrip(null); setShowForm(true) }}
style={{
display: 'flex', alignItems: 'center', gap: 7, padding: '9px 18px',
background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 12,
fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
<Plus size={15} /> {t('dashboard.newTrip')}
</button>}
</span>
<div style={{ display: 'inline-flex', gap: 6, alignItems: 'center', marginLeft: 'auto', flexShrink: 0 }}>
<button
onClick={toggleViewMode}
title={viewMode === 'grid' ? t('dashboard.listView') : t('dashboard.gridView')}
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
padding: '7px 11px', borderRadius: 99,
background: 'transparent', color: 'var(--text-muted)',
transition: 'all 0.15s ease',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-muted)' }}
>
{viewMode === 'grid' ? <List size={15} /> : <LayoutGrid size={15} />}
</button>
<button
onClick={() => setShowWidgetSettings(s => s ? false : true)}
title={t('dashboard.widgets') || 'Widgets'}
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
padding: '7px 11px', borderRadius: 99,
background: showWidgetSettings ? 'var(--bg-card)' : 'transparent',
color: showWidgetSettings ? 'var(--text-primary)' : 'var(--text-muted)',
boxShadow: showWidgetSettings ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
transition: 'all 0.15s ease',
}}
onMouseEnter={e => { if (!showWidgetSettings) { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.color = 'var(--text-primary)' } }}
onMouseLeave={e => { if (!showWidgetSettings) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-muted)' } }}
>
<Settings size={15} />
</button>
{can('trip_create') && (
<button
onClick={() => { setEditingTrip(null); setShowForm(true) }}
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: 2,
}}
className="hover:opacity-[0.88]"
>
<Plus size={14} strokeWidth={2.5} /> {t('dashboard.newTrip')}
</button>
)}
</div>
</div>
</div>
@@ -981,7 +1022,7 @@ export default function DashboardPage(): React.ReactElement {
{/* Loading skeletons */}
{isLoading && (
<>
<div style={{ height: 260, background: '#e5e7eb', borderRadius: 20, marginBottom: 32, animation: 'pulse 1.5s ease-in-out infinite' }} />
<div className="trek-skeleton" style={{ height: 260, borderRadius: 24, marginBottom: 32 }} />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
{[1, 2, 3].map(i => <SkeletonCard key={i} />)}
</div>
@@ -1047,7 +1088,7 @@ export default function DashboardPage(): React.ReactElement {
{/* Trips — desktop grid or list */}
{!isLoading && (viewMode === 'grid' ? rest : trips).length > 0 && (
viewMode === 'grid' ? (
<div className="trip-grid hidden md:grid" style={{ gap: 16, marginBottom: 40 }}>
<div className="trip-grid hidden md:grid trek-stagger" style={{ gap: 16, marginBottom: 40 }}>
{rest.map(trip => (
<TripCard
key={trip.id}
@@ -1062,7 +1103,7 @@ export default function DashboardPage(): React.ReactElement {
))}
</div>
) : (
<div className="hidden md:flex" style={{ flexDirection: 'column', gap: 8, marginBottom: 40 }}>
<div className="hidden md:flex trek-stagger" style={{ flexDirection: 'column', gap: 8, marginBottom: 40 }}>
{trips.map(trip => (
<TripListItem
key={trip.id}
+58 -37
View File
@@ -176,7 +176,7 @@ const mockJourneyDetail = {
avatar: null,
},
],
stats: { entries: 2, photos: 1, cities: 2 },
stats: { entries: 2, photos: 1, places: 2 },
};
// ── MSW Handlers ─────────────────────────────────────────────────────────────
@@ -265,8 +265,8 @@ describe('JourneyDetailPage', () => {
await renderAndWait();
const timelineBtn = screen.getByRole('button', { name: /timeline/i });
expect(timelineBtn).toBeInTheDocument();
// Timeline entries are visible by default
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
// Timeline entries are visible by default (gallery also mounted but hidden, so multiple matches are expected)
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
});
});
@@ -274,8 +274,8 @@ describe('JourneyDetailPage', () => {
describe('FE-PAGE-JOURNEYDETAIL-004: Shows entry cards with titles', () => {
it('renders all entry titles in timeline view', async () => {
await renderAndWait();
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
expect(screen.getByText('Florence Day')).toBeInTheDocument();
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1);
});
});
@@ -362,12 +362,12 @@ describe('JourneyDetailPage', () => {
expect(screen.getAllByText('Days').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Entries').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Photos').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Cities').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Places').length).toBeGreaterThanOrEqual(1);
});
it('renders stat values', async () => {
await renderAndWait();
// stats.entries = 2, stats.photos = 1, stats.cities = 2
// stats.entries = 2, stats.photos = 1, stats.places = 2
// Entries count appears in hero and sidebar
const twos = screen.getAllByText('2');
expect(twos.length).toBeGreaterThanOrEqual(1);
@@ -474,7 +474,7 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-018 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-018: Empty state when no entries', () => {
it('shows "No entries yet" when journey has no entries', async () => {
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, cities: 0 } });
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, places: 0 } });
render(<JourneyDetailPage />);
@@ -484,7 +484,7 @@ describe('JourneyDetailPage', () => {
});
it('shows hint text to add a trip', async () => {
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, cities: 0 } });
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, places: 0 } });
render(<JourneyDetailPage />);
@@ -567,7 +567,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [multiPhotoEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 3, cities: 2 },
stats: { entries: 2, photos: 3, places: 2 },
});
render(<JourneyDetailPage />);
@@ -610,12 +610,12 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [...mockJourneyDetail.entries, skeletonEntry],
stats: { entries: 3, photos: 1, cities: 3 },
stats: { entries: 3, photos: 1, places: 3 },
});
render(<JourneyDetailPage />);
await waitFor(() => {
expect(screen.getByText('Venice Visit')).toBeInTheDocument();
expect(screen.getAllByText('Venice Visit').length).toBeGreaterThanOrEqual(1);
});
// Skeleton card shows "Add Entry" CTA
@@ -650,15 +650,15 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [...mockJourneyDetail.entries, checkinEntry],
stats: { entries: 3, photos: 1, cities: 2 },
stats: { entries: 3, photos: 1, places: 2 },
});
render(<JourneyDetailPage />);
await waitFor(() => {
expect(screen.getByText('Quick stop at cafe')).toBeInTheDocument();
expect(screen.getAllByText('Quick stop at cafe').length).toBeGreaterThanOrEqual(1);
});
expect(screen.getByText(/Cafe Roma/)).toBeInTheDocument();
expect(screen.getAllByText(/Cafe Roma/).length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('Grabbed an espresso')).toBeInTheDocument();
});
});
@@ -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');
@@ -704,15 +707,26 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-030 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-030: Active status badge shows Live indicator', () => {
it('renders a "Live" badge for active journeys', async () => {
it('renders a "Live" badge when linked trip spans today', async () => {
setupDefaultHandlers({
trips: [{ trip_id: 5, added_at: now, title: 'Current Trip', start_date: '2020-01-01', end_date: '2099-12-31', cover_image: null, currency: 'EUR', place_count: 8 }],
});
await renderAndWait();
expect(screen.getByText('Live')).toBeInTheDocument();
});
it('does not render "Live" badge when linked trip is in the past', async () => {
await renderAndWait();
expect(screen.queryByText('Live')).not.toBeInTheDocument();
});
});
// ── FE-PAGE-JOURNEYDETAIL-031 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-031: Synced with Trips badge renders', () => {
it('renders the "Synced with Trips" text in the hero', async () => {
it('renders the "Synced with Trips" text in the hero for live journeys', async () => {
setupDefaultHandlers({
trips: [{ trip_id: 5, added_at: now, title: 'Current Trip', start_date: '2020-01-01', end_date: '2099-12-31', cover_image: null, currency: 'EUR', place_count: 8 }],
});
await renderAndWait();
expect(screen.getByText('Synced with Trips')).toBeInTheDocument();
});
@@ -738,7 +752,7 @@ describe('JourneyDetailPage', () => {
it('shows the place count in the sidebar map', async () => {
await renderAndWait();
// The sidebar map shows "N Places" text
expect(screen.getByText(/Places/)).toBeInTheDocument();
expect(screen.getAllByText(/Places/).length).toBeGreaterThanOrEqual(1);
});
});
@@ -1103,8 +1117,9 @@ describe('JourneyDetailPage', () => {
// Map view renders a location list with entry titles/location names
// The MapView component shows entry names in clickable location items
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
expect(screen.getByText('Florence Day')).toBeInTheDocument();
// (timeline is still mounted but hidden, so multiple matches are expected)
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1);
});
});
@@ -1163,8 +1178,8 @@ describe('JourneyDetailPage', () => {
expect(dayBadges.length).toBeGreaterThanOrEqual(2);
// Each day group shows its entries
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
expect(screen.getByText('Florence Day')).toBeInTheDocument();
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1);
});
});
@@ -1714,7 +1729,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [emptyEntry],
stats: { entries: 1, photos: 0, cities: 1 },
stats: { entries: 1, photos: 0, places: 1 },
});
render(<JourneyDetailPage />);
@@ -1864,8 +1879,10 @@ describe('JourneyDetailPage', () => {
expect(screen.getAllByTestId('journey-map').length).toBeGreaterThanOrEqual(1);
});
// Click the "Arrived in Rome" location item
const romeItem = screen.getByText('Arrived in Rome');
// Click the "Arrived in Rome" location item in the map view's location list
// (timeline is still mounted but hidden, so find the one inside a cursor-pointer container)
const romeItems = screen.getAllByText('Arrived in Rome');
const romeItem = romeItems.find(el => el.closest('[class*="cursor-pointer"]')) ?? romeItems[0];
await user.click(romeItem);
// After clicking, the item should gain active styles (translate-x-0.5 on the container)
@@ -1927,7 +1944,7 @@ describe('JourneyDetailPage', () => {
{ ...mockJourneyDetail.entries[0], id: 10, entry_date: '2026-03-15' },
{ ...mockJourneyDetail.entries[1], id: 11, entry_date: '2026-03-15', location_lat: 41.95, location_lng: 12.55 },
];
setupDefaultHandlers({ entries: twoOnSameDay, stats: { entries: 2, photos: 1, cities: 2 } });
setupDefaultHandlers({ entries: twoOnSameDay, stats: { entries: 2, photos: 1, places: 2 } });
render(<JourneyDetailPage />);
await waitFor(() => {
@@ -2002,7 +2019,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [immichEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 1, cities: 2 },
stats: { entries: 2, photos: 1, places: 2 },
});
render(<JourneyDetailPage />);
@@ -2036,7 +2053,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [synologyEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 1, cities: 2 },
stats: { entries: 2, photos: 1, places: 2 },
});
render(<JourneyDetailPage />);
@@ -2633,7 +2650,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [multiPhotoEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 5, cities: 2 },
stats: { entries: 2, photos: 5, places: 2 },
});
render(<JourneyDetailPage />);
@@ -2658,7 +2675,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [twoPhotoEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 2, cities: 2 },
stats: { entries: 2, photos: 2, places: 2 },
});
render(<JourneyDetailPage />);
@@ -3042,7 +3059,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [mockJourneyDetail.entries[0], noLocEntry],
stats: { entries: 2, photos: 1, cities: 1 },
stats: { entries: 2, photos: 1, places: 1 },
});
render(<JourneyDetailPage />);
@@ -3525,7 +3542,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [entryWithMultiPhotos, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 2, cities: 2 },
stats: { entries: 2, photos: 2, places: 2 },
});
server.use(
@@ -3562,8 +3579,8 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-148 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-148: EntryEditor file upload for existing entry calls API directly', () => {
it('uploading a file on an existing entry calls the upload API immediately', async () => {
describe('FE-PAGE-JOURNEYDETAIL-148: EntryEditor queues file uploads until save (#727)', () => {
it('uploading a file on an existing entry stays pending until Save is clicked', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
let uploadCalled = false;
@@ -3601,7 +3618,11 @@ describe('JourneyDetailPage', () => {
const testFile = new File(['data'], 'upload.jpg', { type: 'image/jpeg' });
await user.upload(fileInput, testFile);
// For existing entries, upload happens immediately
// Picked file is queued locally — upload should NOT fire until Save.
expect(uploadCalled).toBe(false);
// Saving triggers the queued upload.
await user.click(screen.getByText('Save'));
await waitFor(() => {
expect(uploadCalled).toBe(true);
});
@@ -3617,7 +3638,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [mockJourneyDetail.entries[0], noTitleEntry],
stats: { entries: 2, photos: 1, cities: 2 },
stats: { entries: 2, photos: 1, places: 2 },
});
render(<JourneyDetailPage />);
+433 -173
View File
@@ -19,8 +19,13 @@ import {
UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil,
Laugh, Smile, Meh, Annoyed, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff,
Archive, ArchiveRestore,
} 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'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
const GRADIENTS = [
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
@@ -84,7 +89,15 @@ export default function JourneyDetailPage() {
const fullMapRef = useRef<JourneyMapHandle>(null)
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
const isMobile = useIsMobile()
// Role-based permissions (server-provided via my_role). Fall back to
// "owner" when the field isn't present yet (legacy responses) so behavior
// matches the pre-permissions era.
const myRole = (current as any)?.my_role ?? 'owner'
const canEditEntries = myRole === 'owner' || myRole === 'editor'
const canEditJourney = myRole === 'owner'
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)
@@ -158,6 +171,12 @@ export default function JourneyDetailPage() {
setActiveLocationId(id)
}, [])
useEffect(() => {
if (view === 'map') {
requestAnimationFrame(() => fullMapRef.current?.invalidateSize())
}
}, [view])
const mapEntries = useMemo(
() => (current?.entries || []).filter(e => e.location_lat && e.location_lng),
[current?.entries]
@@ -202,10 +221,100 @@ export default function JourneyDetailPage() {
const dayGroups = groupByDate(timelineEntries)
const sortedDates = [...dayGroups.keys()].sort()
const tripDateMin = current.trips.length
? current.trips.reduce((min: string, t: any) => t.start_date && (!min || t.start_date < min) ? t.start_date : min, '')
: null
const tripDateMax = current.trips.length
? current.trips.reduce((max: string, t: any) => t.end_date && (!max || t.end_date > max) ? t.end_date : max, '')
: null
const lifecycle = computeJourneyLifecycle(current.status, tripDateMin || null, tripDateMax || null)
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')}
readOnly={!canEditEntries}
onEntryClick={(entry) => setViewingEntry(entry)}
onAddEntry={canEditEntries ? () => {
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)
} : undefined}
/>
)}
{/* Fullscreen entry view (mobile) */}
{viewingEntry && (
<MobileEntryView
entry={viewingEntry}
readOnly={!canEditEntries}
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 top bar on mobile combined view: back | tabs+title | settings */}
{showMobileCombined && (
<div
className="fixed left-0 right-0 z-30 flex items-start justify-between gap-2 px-4"
style={{ top: 'calc(var(--nav-h, 56px) + 12px)' }}
>
<button
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className="w-10 h-10 flex-shrink-0 rounded-lg bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 shadow-lg text-zinc-700 dark:text-zinc-200 flex items-center justify-center hover:bg-white dark:hover:bg-zinc-800 active:scale-95 transition-transform"
>
<ArrowLeft size={16} />
</button>
<div className="flex-1 min-w-0 flex flex-col items-center gap-1">
<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>
{current?.title && (
<div className="max-w-full truncate text-center text-[11px] font-medium text-zinc-700 dark:text-zinc-200 px-2.5 py-0.5 rounded-full bg-white/80 dark:bg-zinc-800/80 backdrop-blur-md border border-zinc-200/60 dark:border-zinc-700/60 shadow-sm">
{current.title}
</div>
)}
</div>
{canEditJourney ? (
<button
onClick={() => setShowSettings(true)}
aria-label={t('journey.settings.title')}
className="w-10 h-10 flex-shrink-0 rounded-lg bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 shadow-lg text-zinc-700 dark:text-zinc-200 flex items-center justify-center hover:bg-white dark:hover:bg-zinc-800 active:scale-95 transition-transform"
>
<MoreHorizontal size={16} />
</button>
) : (
<div className="w-10 h-10 flex-shrink-0" aria-hidden />
)}
</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 */}
@@ -228,16 +337,28 @@ export default function JourneyDetailPage() {
<div className="relative z-[3] flex items-center justify-between mb-5">
{/* Desktop: badges */}
<div className="hidden md:flex items-center gap-2">
{current.status === 'active' && (
{lifecycle === 'live' && (
<div className="inline-flex items-center gap-2 px-2.5 py-1 bg-white/15 backdrop-blur rounded-full text-[10px] font-semibold uppercase">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
Live
{t('journey.frontpage.live')}
</div>
)}
{lifecycle !== 'archived' && current.trips.length > 0 && (
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
<RefreshCw size={11} />
{t('journey.detail.syncedWithTrips')}
</div>
)}
{lifecycle !== 'live' && lifecycle !== 'archived' && (
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
{t(`journey.status.${lifecycle === 'upcoming' ? 'upcoming' : lifecycle === 'draft' ? 'draft' : 'completed'}`)}
</div>
)}
{lifecycle === 'archived' && (
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
{t('journey.status.archived')}
</div>
)}
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
<RefreshCw size={11} />
{t('journey.detail.syncedWithTrips')}
</div>
</div>
{/* Mobile: back button on the left */}
<button
@@ -263,7 +384,9 @@ export default function JourneyDetailPage() {
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
</span>
</div>
<button onClick={() => setShowSettings(true)} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><MoreHorizontal size={14} /></button>
{canEditJourney && (
<button onClick={() => setShowSettings(true)} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><MoreHorizontal size={14} /></button>
)}
</div>
</div>
@@ -276,7 +399,7 @@ export default function JourneyDetailPage() {
<div className="flex gap-8">
{[
{ value: sortedDates.length, label: t('journey.stats.days') },
{ value: current.stats.cities, label: t('journey.stats.cities') },
{ value: current.stats.places, label: t('journey.stats.places') },
{ value: current.stats.entries, label: t('journey.stats.entries') },
{ value: current.stats.photos, label: t('journey.stats.photos') },
].map(s => (
@@ -298,11 +421,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,22 +446,22 @@ export default function JourneyDetailPage() {
</button>
))}
</div>
{view === 'timeline' && (
{canEditEntries && (!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' && (
<div className="flex flex-col gap-6 pb-24 md:pb-6">
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */}
{!isMobile && (
<div className={`flex flex-col gap-6 pb-24 md:pb-6${view === 'timeline' ? '' : ' hidden'}`}>
{sortedDates.length === 0 && (
<div className="text-center py-16">
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
@@ -349,7 +478,7 @@ export default function JourneyDetailPage() {
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
return (
<div key={date} className="flex flex-col gap-3">
<div key={date} className="flex flex-col gap-3 trek-stagger">
<div className="sticky top-0 md:top-[68px] z-[5] bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[13px] font-bold">
@@ -367,12 +496,13 @@ export default function JourneyDetailPage() {
{entries.map(entry => (
<div key={entry.id} data-entry-id={String(entry.id)}>
{entry.type === 'skeleton' ? (
<SkeletonCard entry={entry} onClick={() => setEditingEntry(entry)} />
<SkeletonCard entry={entry} onClick={canEditEntries ? () => setEditingEntry(entry) : undefined} />
) : entry.type === 'checkin' ? (
<CheckinCard entry={entry} onClick={() => setEditingEntry(entry)} />
<CheckinCard entry={entry} onClick={canEditEntries ? () => setEditingEntry(entry) : undefined} />
) : (
<EntryCard
entry={entry}
readOnly={!canEditEntries}
onEdit={() => setEditingEntry(entry)}
onDelete={() => setDeleteTarget(entry)}
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 })}
@@ -387,7 +517,7 @@ export default function JourneyDetailPage() {
)}
{/* Gallery View */}
{view === 'gallery' && (
<div className={view === 'gallery' ? '' : 'hidden'}>
<GalleryView
entries={current.entries}
journeyId={current.id}
@@ -396,17 +526,21 @@ export default function JourneyDetailPage() {
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 })}
onRefresh={() => loadJourney(Number(id))}
/>
)}
</div>
{/* Full Map View */}
{view === 'map' && <div className="pb-24 md:pb-6"><MapView
entries={current.entries}
mapEntries={mapEntries}
sortedDates={sortedDates}
activeLocationId={activeLocationId}
fullMapRef={fullMapRef}
onLocationClick={handleLocationClick}
/></div>}
{/* Full Map View (desktop only — mobile uses combined view) */}
{!isMobile && (
<div className={`pb-24 md:pb-6${view === 'map' ? '' : ' hidden'}`}>
<MapView
entries={current.entries}
mapEntries={mapEntries}
sortedDates={sortedDates}
activeLocationId={activeLocationId}
fullMapRef={fullMapRef}
onLocationClick={handleLocationClick}
/>
</div>
)}
</div>
{/* Right sidebar — hidden on mobile */}
@@ -433,7 +567,7 @@ export default function JourneyDetailPage() {
{ value: sortedDates.length, label: t('journey.stats.days') },
{ value: current.stats.entries, label: t('journey.stats.entries') },
{ value: current.stats.photos, label: t('journey.stats.photos') },
{ value: current.stats.cities, label: t('journey.stats.cities') },
{ value: current.stats.places, label: t('journey.stats.places') },
].map(s => (
<div key={s.label} className="rounded-lg bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-zinc-700/50 px-3 py-2.5">
<div className="text-[18px] font-bold tracking-[-0.02em] text-zinc-900 dark:text-white leading-none mb-0.5">{s.value}</div>
@@ -819,12 +953,14 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
if (!files?.length) return
setGalleryUploading(true)
try {
// find existing "Gallery" entry or create one
// find existing "Gallery" entry or create one. The stored title is the
// literal 'Gallery' (server-side checks look for this exact string) —
// do not send a translated label here.
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
let entryId = galleryEntry?.id
if (!entryId) {
const entry = await journeyApi.createEntry(journeyId, {
title: t('journey.share.gallery'),
title: 'Gallery',
entry_date: new Date().toISOString().split('T')[0],
type: 'entry',
})
@@ -908,11 +1044,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')}
@@ -960,12 +1096,12 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
trips={trips}
existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))}
onClose={() => setShowPicker(false)}
onAdd={async (assetIds, entryId) => {
onAdd={async (groups, entryId) => {
let targetId = entryId
if (!targetId) {
try {
const entry = await journeyApi.createEntry(journeyId, {
title: t('journey.share.gallery'),
title: 'Gallery',
entry_date: new Date().toISOString().split('T')[0],
type: 'entry',
})
@@ -973,10 +1109,12 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
} catch { return }
}
let added = 0
try {
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds)
added = result.added || 0
} catch {}
for (const group of groups) {
try {
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase)
added += result.added || 0
} catch {}
}
if (added > 0) {
toast.success(t('journey.photosAdded', { count: added }))
onRefresh()
@@ -1139,8 +1277,9 @@ function VerdictSection({ pros, cons }: { pros: string[]; cons: string[] }) {
// ── Entry Card ────────────────────────────────────────────────────────────
function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
entry: JourneyEntry
readOnly?: boolean
onEdit: () => void
onDelete: () => void
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
@@ -1157,7 +1296,7 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
const hasProscons = prosArr.length > 0 || consArr.length > 0
return (
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden transition-all hover:border-zinc-400 dark:hover:border-zinc-500 hover:shadow-sm">
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:border-zinc-400 dark:hover:border-zinc-500 hover:shadow-sm">
{/* Hero area: photos with title overlay */}
{photos.length > 0 ? (
@@ -1183,20 +1322,22 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
</div>
{/* Menu top-right */}
<div className="absolute top-2.5 right-3 z-[2]">
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-8 h-8 rounded-[10px] bg-black/40 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/50">
<MoreHorizontal size={14} />
</button>
{menuOpen && (
<>
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
</div>
</>
)}
</div>
{!readOnly && (
<div className="absolute top-2.5 right-3 z-[2]">
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-8 h-8 rounded-[10px] bg-black/40 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/50">
<MoreHorizontal size={14} />
</button>
{menuOpen && (
<>
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
</div>
</>
)}
</div>
)}
{/* Title on photo */}
{entry.title && (
@@ -1220,20 +1361,22 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
</span>
)}
</div>
<div className="relative">
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-7 h-7 rounded-md flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<MoreHorizontal size={14} />
</button>
{menuOpen && (
<>
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
</div>
</>
)}
</div>
{!readOnly && (
<div className="relative">
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-7 h-7 rounded-md flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<MoreHorizontal size={14} />
</button>
{menuOpen && (
<>
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
</div>
</>
)}
</div>
)}
</div>
)}
@@ -1272,12 +1415,12 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
)
}
function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => void }) {
function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) {
const { t } = useTranslation()
return (
<div
onClick={onClick}
className="bg-white dark:bg-zinc-900 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-3.5 flex items-center gap-3 transition-all hover:border-solid hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer"
className={`bg-white dark:bg-zinc-900 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-3.5 flex items-center gap-3 transition-[border-color,border-style] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:border-solid hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer' : ''}`}
>
<div className="w-9 h-9 rounded-lg bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 flex-shrink-0">
<MapPin size={14} />
@@ -1297,11 +1440,11 @@ function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick: () =>
)
}
function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => void }) {
function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) {
return (
<div
onClick={onClick}
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3.5 py-2.5 flex items-center gap-2.5 transition-all hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer"
className={`bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3.5 py-2.5 flex items-center gap-2.5 transition-colors duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer' : ''}`}
>
<div className="w-7 h-7 rounded-lg bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center flex-shrink-0">
<MapPin size={13} />
@@ -1423,6 +1566,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 }: {
@@ -1432,20 +1593,21 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
trips: JourneyTrip[]
existingAssetIds: Set<string>
onClose: () => void
onAdd: (assetIds: string[], entryId: number | null) => Promise<void>
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string }>, entryId: number | null) => Promise<void>
}) {
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)
const [searchPage, setSearchPage] = useState(1)
const [searchFrom, setSearchFrom] = useState('')
const [searchTo, setSearchTo] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string }>>(new Map())
const [customFrom, setCustomFrom] = useState('')
const [customTo, setCustomTo] = useState('')
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
@@ -1500,13 +1662,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)
@@ -1536,8 +1699,12 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
const toggleAsset = (id: string) => {
setSelected(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
const next = new Map(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase })
}
return next
})
}
@@ -1547,7 +1714,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 */}
@@ -1567,7 +1734,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{[
{ id: 'trip' as const, label: t('journey.picker.tripPeriod') },
{ id: 'custom' as const, label: t('journey.picker.dateRange') },
{ id: 'all' as const, label: t('journey.picker.allPhotos') },
{ id: 'all' as const, label: t('journey.picker.allPhotos'), short: t('common.all') },
{ id: 'album' as const, label: t('journey.picker.albums') },
].map(f => (
<button
@@ -1579,7 +1746,12 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
: 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'
}`}
>
{f.label}
{f.short ? (
<>
<span className="hidden sm:inline">{f.label}</span>
<span className="sm:hidden">{f.short}</span>
</>
) : f.label}
</button>
))}
</div>
@@ -1625,7 +1797,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'
@@ -1699,9 +1871,9 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
<button
onClick={() => {
if (allSelected) {
setSelected(new Set())
setSelected(new Map())
} else {
setSelected(new Set(selectable.map((a: any) => a.id)))
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase }])))
}
}}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
@@ -1732,51 +1904,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>
@@ -1794,7 +1975,16 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{t('common.cancel')}
</button>
<button
onClick={() => onAdd([...selected], targetEntryId)}
onClick={() => {
const groupMap = new Map<string | undefined, string[]>()
for (const [assetId, { passphrase }] of selected.entries()) {
const list = groupMap.get(passphrase) || []
list.push(assetId)
groupMap.set(passphrase, list)
}
const groups = [...groupMap.entries()].map(([passphrase, assetIds]) => ({ assetIds, passphrase }))
onAdd(groups, targetEntryId)
}}
disabled={selected.size === 0}
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-40 disabled:cursor-not-allowed"
>
@@ -1838,7 +2028,7 @@ function DatePicker({ value, onChange, tripDates }: {
for (let i = 0; i < firstDow; i++) cells.push(null)
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : t('journey.picker.selectDate')
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null
return (
<div className="relative">
@@ -1847,7 +2037,14 @@ function DatePicker({ value, onChange, tripDates }: {
onClick={() => setOpen(!open)}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-left flex items-center justify-between"
>
<span>{formatted}</span>
{formatted ? (
<span>{formatted}</span>
) : (
<span>
<span className="hidden sm:inline">{t('journey.picker.selectDate')}</span>
<span className="sm:hidden">{t('common.date')}</span>
</span>
)}
<Calendar size={13} className="text-zinc-400" />
</button>
@@ -1946,6 +2143,31 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
const fileRef = useRef<HTMLInputElement>(null)
const storyRef = useRef<HTMLTextAreaElement>(null)
// Track which fields differ from the entry we started editing so we can
// warn before discarding on close/cancel.
const originalPros = (entry.pros_cons?.pros ?? []).join('\n')
const originalCons = (entry.pros_cons?.cons ?? []).join('\n')
const isDirty = (
title !== (entry.title || '') ||
story !== (entry.story || '') ||
entryDate !== (entry.entry_date || new Date().toISOString().split('T')[0]) ||
entryTime !== (entry.entry_time || '') ||
locationName !== (entry.location_name || '') ||
(locationLat ?? null) !== (entry.location_lat ?? null) ||
(locationLng ?? null) !== (entry.location_lng ?? null) ||
mood !== (entry.mood || '') ||
weather !== (entry.weather || '') ||
pros.filter(p => p.trim()).join('\n') !== originalPros ||
cons.filter(c => c.trim()).join('\n') !== originalCons ||
pendingFiles.length > 0 ||
pendingLinkIds.length > 0
)
const handleClose = () => {
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
onClose()
}
const handleSave = async () => {
setSaving(true)
try {
@@ -1960,7 +2182,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
mood: mood || null,
weather: weather || null,
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
type: (entry.type === 'skeleton' && story.trim()) ? 'entry' : undefined,
type: ((entry.type === 'skeleton' && (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0)) ? 'entry' : undefined),
})
// upload queued files after entry is created
if (pendingFiles.length > 0 && entryId) {
@@ -1983,29 +2205,19 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files?.length) return
if (entry.id === 0) {
// queue files for upload after save
setPendingFiles(prev => [...prev, ...Array.from(files)])
} else {
setUploading(true)
try {
const formData = new FormData()
for (const f of files) formData.append('photos', f)
const newPhotos = await onUploadPhotos(entry.id, formData)
if (newPhotos?.length) setPhotos(prev => [...prev, ...newPhotos])
} finally {
setUploading(false)
}
}
// Queue files locally until Save so cancel/close actually discards. This
// keeps photo behavior consistent with text fields — no silent persistence.
setPendingFiles(prev => [...prev, ...Array.from(files)])
}
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]" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
<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>
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<button onClick={handleClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>
@@ -2158,7 +2370,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 +2404,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 +2468,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 +2515,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,8 +2548,8 @@ 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">
<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>
<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={handleClose} 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')}
</button>
@@ -2384,7 +2598,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 +2695,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">
@@ -2727,6 +2941,21 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
}
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [archiving, setArchiving] = useState(false)
const handleArchiveToggle = async () => {
setArchiving(true)
try {
const newStatus = journey.status === 'archived' ? 'active' : 'archived'
await updateJourney(journey.id, { status: newStatus })
toast.success(newStatus === 'archived' ? t('journey.settings.archived') : t('journey.settings.reopened'))
onSaved()
} catch {
toast.error(t('journey.settings.saveFailed'))
} finally {
setArchiving(false)
}
}
const handleDelete = async () => {
try {
@@ -2738,8 +2967,8 @@ 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="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="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: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
<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">{t('journey.settings.title')}</h2>
@@ -2832,6 +3061,25 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
</div>
<div className="flex-1 text-[12px] font-medium text-zinc-900 dark:text-white">{c.username}</div>
<span className={`text-[9px] font-medium px-1.5 py-0.5 rounded-full ${c.role === 'owner' ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'}`}>{c.role}</span>
{c.role !== 'owner' && (
<button
onClick={async () => {
if (!window.confirm(t('journey.contributors.removeConfirm', { username: c.username }))) return
try {
await journeyApi.removeContributor(journey.id, c.user_id)
toast.success(t('journey.contributors.removed'))
onSaved()
} catch {
toast.error(t('journey.contributors.removeFailed'))
}
}}
aria-label={t('journey.contributors.remove')}
title={t('journey.contributors.remove')}
className="w-7 h-7 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-500 transition-colors"
>
<X size={13} />
</button>
)}
</div>
))}
<button
@@ -2851,16 +3099,28 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
</div>
{/* Footer */}
<div className="flex flex-wrap items-center gap-2 px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<div className="flex items-center gap-1.5 px-4 md:px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<button
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2 mr-auto"
aria-label={t('journey.settings.delete')}
title={t('journey.settings.delete')}
className="flex items-center justify-center gap-1.5 h-9 min-w-9 px-2 md:px-2.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
>
<Trash2 size={13} />
{t('journey.settings.delete')}
<Trash2 size={14} />
<span className="hidden md:inline">{t('journey.settings.delete')}</span>
</button>
<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 || !title.trim()} 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-40">
<button
onClick={handleArchiveToggle}
disabled={archiving}
aria-label={journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}
title={t('journey.settings.endDescription')}
className="flex items-center justify-center gap-1.5 h-9 min-w-9 px-2 md:px-2.5 text-[12px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded-lg mr-auto disabled:opacity-40"
>
{journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />}
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
</button>
<button onClick={onClose} className="h-9 px-3.5 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 || !title.trim()} className="h-9 px-3.5 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-40">
{saving ? t('common.saving') : t('common.save')}
</button>
</div>
+16 -8
View File
@@ -43,7 +43,9 @@ function buildJourneyListItem(overrides: Record<string, unknown> = {}) {
status: 'draft' as const,
entry_count: 0,
photo_count: 0,
city_count: 0,
place_count: 0,
trip_date_min: null as string | null,
trip_date_max: null as string | null,
created_at: Date.now(),
updated_at: Date.now(),
...overrides,
@@ -194,7 +196,7 @@ describe('JourneyPage', () => {
// FE-PAGE-JOURNEY-008
it('FE-PAGE-JOURNEY-008: shows active journey hero when active journey exists', async () => {
const active = buildJourneyListItem({ id: 10, title: 'Active Trip', status: 'active' });
const active = buildJourneyListItem({ id: 10, title: 'Active Trip', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
const other = buildJourneyListItem({ id: 11, title: 'Completed Trip', status: 'completed' });
setupDefaultHandlers([active, other]);
@@ -320,13 +322,13 @@ describe('JourneyPage', () => {
});
// FE-PAGE-JOURNEY-013
it('FE-PAGE-JOURNEY-013: journey card shows entry/photo/city counts', async () => {
it('FE-PAGE-JOURNEY-013: journey card shows entry/photo/place counts', async () => {
const j1 = buildJourneyListItem({
id: 20,
title: 'Stats Journey',
entry_count: 12,
photo_count: 47,
city_count: 5,
place_count: 5,
});
setupDefaultHandlers([j1]);
@@ -335,7 +337,7 @@ describe('JourneyPage', () => {
expect(screen.getByText('Stats Journey')).toBeInTheDocument();
});
// The card renders entry_count, photo_count, city_count values
// The card renders entry_count, photo_count, place_count values
expect(screen.getByText('12')).toBeInTheDocument();
expect(screen.getByText('47')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
@@ -361,6 +363,8 @@ describe('JourneyPage', () => {
id: 40,
title: 'Recent Active',
status: 'active',
trip_date_min: '2020-01-01',
trip_date_max: '2099-12-31',
updated_at: Date.now() - 60000, // 1 minute ago
});
setupDefaultHandlers([active]);
@@ -380,6 +384,8 @@ describe('JourneyPage', () => {
id: 41,
title: 'Hours Active',
status: 'active',
trip_date_min: '2020-01-01',
trip_date_max: '2099-12-31',
updated_at: Date.now() - 3 * 3600000, // 3 hours ago
});
setupDefaultHandlers([active]);
@@ -399,6 +405,8 @@ describe('JourneyPage', () => {
id: 42,
title: 'Days Active',
status: 'active',
trip_date_min: '2020-01-01',
trip_date_max: '2099-12-31',
updated_at: Date.now() - 5 * 24 * 3600000, // 5 days ago
});
setupDefaultHandlers([active]);
@@ -414,7 +422,7 @@ describe('JourneyPage', () => {
// FE-PAGE-JOURNEY-018
it('FE-PAGE-JOURNEY-018: active journey hero shows "Continue writing" button', async () => {
const active = buildJourneyListItem({ id: 50, title: 'Writing Journey', status: 'active' });
const active = buildJourneyListItem({ id: 50, title: 'Writing Journey', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
setupDefaultHandlers([active]);
render(<JourneyPage />);
@@ -427,7 +435,7 @@ describe('JourneyPage', () => {
// FE-PAGE-JOURNEY-019
it('FE-PAGE-JOURNEY-019: active journey hero shows Live and Synced badges', async () => {
const active = buildJourneyListItem({ id: 51, title: 'Live Journey', status: 'active' });
const active = buildJourneyListItem({ id: 51, title: 'Live Journey', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
setupDefaultHandlers([active]);
render(<JourneyPage />);
@@ -442,7 +450,7 @@ describe('JourneyPage', () => {
// FE-PAGE-JOURNEY-020
it('FE-PAGE-JOURNEY-020: clicking active journey hero navigates to its detail page', async () => {
const user = userEvent.setup();
const active = buildJourneyListItem({ id: 60, title: 'Clickable Hero', status: 'active' });
const active = buildJourneyListItem({ id: 60, title: 'Clickable Hero', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
setupDefaultHandlers([active]);
render(<JourneyPage />);
+123 -44
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from 'react'
import { useEffect, useState, useMemo, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useJourneyStore } from '../store/journeyStore'
import { journeyApi } from '../api/client'
@@ -10,6 +10,7 @@ import {
Check, X, ChevronRight, RefreshCw, Users,
} from 'lucide-react'
import type { Journey } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
const GRADIENTS = [
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
@@ -43,6 +44,9 @@ export default function JourneyPage() {
const [newTitle, setNewTitle] = useState('')
const [availableTrips, setAvailableTrips] = useState<any[]>([])
const [selectedTripIds, setSelectedTripIds] = useState<Set<number>>(new Set())
const [searchOpen, setSearchOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
// suggestion
const [suggestions, setSuggestions] = useState<any[]>([])
@@ -56,12 +60,22 @@ export default function JourneyPage() {
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
const activeJourney = useMemo(() => {
return journeys.find(j => j.status === 'active') || null
}, [journeys])
if (searchQuery.trim()) return null
return journeys.find(j => {
const j2 = j as any
return computeJourneyLifecycle(j.status, j2.trip_date_min, j2.trip_date_max) === 'live'
}) || null
}, [journeys, searchQuery])
const otherJourneys = useMemo(() => {
return journeys.filter(j => j.id !== activeJourney?.id)
}, [journeys, activeJourney])
const filteredJourneys = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
if (!q) return journeys.filter(j => j.id !== activeJourney?.id)
return journeys.filter(j => {
const inTitle = j.title.toLowerCase().includes(q)
const inSubtitle = j.subtitle?.toLowerCase().includes(q) ?? false
return inTitle || inSubtitle
})
}, [journeys, activeJourney, searchQuery])
const openCreateModal = async (preSelectedTripId?: number) => {
setShowCreate(true)
@@ -99,35 +113,79 @@ export default function JourneyPage() {
<div style={{ paddingTop: 'var(--nav-h, 56px)' }}>
<div className="max-w-[1440px] mx-auto">
{/* Header — mobile: just a create button */}
<div className="md:hidden px-5 pt-5 pb-4">
<button
onClick={() => openCreateModal()}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[14px] font-semibold active:scale-[0.98] transition-transform"
>
<Plus size={16} strokeWidth={2.5} />
{t('journey.frontpage.createJourney')}
</button>
</div>
{/* Header — desktop */}
<div className="hidden md:flex items-start justify-between px-8 pt-10 pb-7">
<div>
<h1 className="text-[32px] font-extrabold tracking-[-0.025em] text-zinc-900 dark:text-white leading-none">{t('journey.title')}</h1>
<p className="text-[13px] text-zinc-500 mt-1.5">{t("journey.frontpage.subtitle")}</p>
</div>
{/* Header — mobile */}
<div className="md:hidden px-5 pt-5 pb-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<button className="w-9 h-9 rounded-[10px] border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-700">
<Search size={15} />
<button
onClick={() => {
if (searchOpen) {
setSearchOpen(false)
setSearchQuery('')
} else {
setSearchOpen(true)
setTimeout(() => searchInputRef.current?.focus(), 50)
}
}}
className="w-10 h-10 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex-shrink-0"
>
{searchOpen ? <X size={15} /> : <Search size={15} />}
</button>
<button
onClick={() => openCreateModal()}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-[10px] 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 transition-all hover:-translate-y-px"
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[14px] font-semibold active:scale-[0.98] transition-transform"
>
<Plus size={14} />
<Plus size={16} strokeWidth={2.5} />
{t('journey.frontpage.createJourney')}
</button>
</div>
{searchOpen && (
<input
ref={searchInputRef}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Escape') { setSearchQuery(''); setSearchOpen(false) } }}
placeholder={t('journey.search.placeholder')}
className="w-full px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white focus:border-zinc-400 focus:outline-none"
/>
)}
</div>
{/* Header — desktop (unified toolbar) */}
<div className="hidden md:block px-8 pt-10 pb-7">
<div style={{
background: 'var(--bg-tertiary)', borderRadius: 18,
border: '1px solid var(--border-primary)',
boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
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('journey.title')}
</h2>
<div style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>
{t('journey.frontpage.subtitle')}
</span>
<div style={{ display: 'inline-flex', gap: 6, alignItems: 'center', marginLeft: 'auto', flexShrink: 0 }}>
<button
onClick={() => openCreateModal()}
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: 2,
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} />
{t('journey.frontpage.createJourney')}
</button>
</div>
</div>
</div>
<div className="px-4 md:px-8 pb-16">
@@ -180,7 +238,7 @@ export default function JourneyPage() {
<div
onClick={() => navigate(`/journey/${activeJourney.id}`)}
className="relative rounded-3xl overflow-hidden cursor-pointer transition-all duration-300 hover:-translate-y-1 hover:shadow-xl h-[340px] md:h-[400px]"
className="relative rounded-3xl overflow-hidden cursor-pointer transition-[transform,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-1 hover:shadow-xl h-[340px] md:h-[400px]"
style={{ background: pickGradient(activeJourney.id) }}
>
{/* Cover image */}
@@ -226,7 +284,7 @@ export default function JourneyPage() {
{[
{ val: (activeJourney as any).entry_count ?? '--', label: t("journey.stats.entries") },
{ val: (activeJourney as any).photo_count ?? '--', label: t("journey.stats.photos") },
{ val: (activeJourney as any).city_count ?? '--', label: t("journey.stats.cities") },
{ val: (activeJourney as any).place_count ?? '--', label: t("journey.stats.places") },
].map(s => (
<div key={s.label} className="flex flex-col gap-1">
<span className="text-[28px] font-extrabold tracking-[-0.02em] leading-none">{s.val}</span>
@@ -243,11 +301,24 @@ export default function JourneyPage() {
</div>
)}
{/* Search results info */}
{searchQuery.trim() && (
<div className="mb-4 flex items-center gap-2">
<span className="text-[13px] text-zinc-500">
{filteredJourneys.length === 0
? t('journey.search.noResults', { query: searchQuery.trim() })
: `${filteredJourneys.length} ${t('journey.frontpage.journeys')}`}
</span>
</div>
)}
{/* All Journeys */}
<div className="mb-4 flex items-center justify-between">
<span className="text-[11px] font-bold tracking-[0.14em] uppercase text-zinc-500">{t("journey.frontpage.allJourneys")}</span>
<span className="text-[11px] text-zinc-400">{journeys.length} {t('journey.frontpage.journeys')}</span>
</div>
{!searchQuery.trim() && (
<div className="mb-4 flex items-center justify-between">
<span className="text-[11px] font-bold tracking-[0.14em] uppercase text-zinc-500">{t("journey.frontpage.allJourneys")}</span>
<span className="text-[11px] text-zinc-400">{journeys.length} {t('journey.frontpage.journeys')}</span>
</div>
)}
{loading && journeys.length === 0 ? (
<div className="flex justify-center py-16">
@@ -255,16 +326,16 @@ export default function JourneyPage() {
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-[18px]">
{otherJourneys.map(j => (
{filteredJourneys.map(j => (
<JourneyCard key={j.id} journey={j} onClick={() => navigate(`/journey/${j.id}`)} />
))}
{/* Create card */}
<button
onClick={() => openCreateModal()}
className="group min-h-[320px] rounded-2xl border-[1.5px] border-dashed border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex flex-col items-center justify-center gap-2.5 hover:border-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-all cursor-pointer hover:-translate-y-0.5"
className="group min-h-[320px] rounded-2xl border-[1.5px] border-dashed border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex flex-col items-center justify-center gap-2.5 hover:border-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-[border-color,background-color,transform] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] cursor-pointer hover:-translate-y-0.5"
>
<div className="w-14 h-14 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-400 group-hover:bg-white dark:group-hover:bg-zinc-700 transition-all group-hover:rotate-90 duration-300">
<div className="w-14 h-14 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-400 group-hover:bg-white dark:group-hover:bg-zinc-700 transition-[background-color,transform] group-hover:rotate-90 duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]">
<Plus size={22} />
</div>
<span className="text-[14px] font-semibold text-zinc-700 dark:text-zinc-300">{t("journey.frontpage.createNew")}</span>
@@ -279,7 +350,7 @@ export default function JourneyPage() {
{/* Create Modal */}
{showCreate && (
<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="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" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
{/* Header */}
<div className="px-7 pt-6 pb-5 border-b border-zinc-200 dark:border-zinc-700">
@@ -323,7 +394,7 @@ export default function JourneyPage() {
return next
})
}}
className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-all ${
className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-[border-color,background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${
selected
? 'border-zinc-900 dark:border-zinc-400 bg-zinc-50 dark:bg-zinc-800'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500'
@@ -386,17 +457,18 @@ export default function JourneyPage() {
)
}
function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?: number; photo_count?: number; city_count?: number }; onClick: () => void }) {
function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?: number; photo_count?: number; place_count?: number; trip_date_min?: string | null; trip_date_max?: string | null }; onClick: () => void }) {
const { t } = useTranslation()
const j = journey
const entryCount = j.entry_count ?? 0
const photoCount = j.photo_count ?? 0
const cityCount = j.city_count ?? 0
const placeCount = j.place_count ?? 0
const lifecycle = computeJourneyLifecycle(j.status, j.trip_date_min, j.trip_date_max)
return (
<div
onClick={onClick}
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden cursor-pointer transition-all duration-250 hover:border-zinc-400 hover:-translate-y-1 hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] flex flex-col"
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden cursor-pointer transition-[transform,box-shadow,border-color] duration-250 ease-[cubic-bezier(0.23,1,0.32,1)] hover:border-zinc-400 hover:-translate-y-1 hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] flex flex-col"
>
{/* Cover */}
<div className="h-[170px] relative overflow-hidden" style={{ background: pickGradient(j.id) }}>
@@ -424,15 +496,22 @@ function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?:
{j.subtitle && (
<p className="text-[12px] text-zinc-500 mt-1">{j.subtitle}</p>
)}
{j.status === 'draft' && (
<span className="inline-flex self-start mt-1.5 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 uppercase tracking-wide">{t('journey.status.draft')}</span>
{lifecycle !== 'live' && (
<span className={`inline-flex self-start mt-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wide ${
lifecycle === 'archived' ? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500' :
lifecycle === 'upcoming' ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' :
lifecycle === 'completed' ? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400' :
'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'
}`}>
{t(`journey.status.${lifecycle}`)}
</span>
)}
<div className="grid grid-cols-3 gap-2.5 mt-auto pt-3.5 border-t border-zinc-100 dark:border-zinc-800" style={{ marginTop: j.subtitle ? 14 : 'auto' }}>
{[
{ val: entryCount, label: t('journey.stats.entries') },
{ val: photoCount, label: t('journey.stats.photos') },
{ val: cityCount, label: t('journey.stats.cities') },
{ val: placeCount, label: t('journey.stats.places') },
].map(s => (
<div key={s.label} className="flex flex-col gap-1">
<span className={`text-[16px] font-bold leading-none tracking-[-0.01em] ${s.val > 0 ? 'text-zinc-900 dark:text-white' : 'text-zinc-300 dark:text-zinc-600'}`}>
+3 -3
View File
@@ -109,7 +109,7 @@ const mockJourneyData = {
stats: {
entries: 2,
photos: 1,
cities: 2,
places: 2,
},
};
@@ -354,7 +354,7 @@ describe('JourneyPublicPage', () => {
],
},
],
stats: { entries: 1, photos: 3, cities: 0 },
stats: { entries: 1, photos: 3, places: 0 },
};
server.use(
@@ -383,7 +383,7 @@ describe('JourneyPublicPage', () => {
it('FE-PAGE-PUBLICJOURNEY-015: stats display shows entries, photos, and cities counts', async () => {
const customData = {
...mockJourneyData,
stats: { entries: 14, photos: 83, cities: 7 },
stats: { entries: 14, photos: 83, places: 7 },
};
server.use(
http.get('/api/public/journey/test-share-token', () => HttpResponse.json(customData)),
+18 -3
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()
@@ -173,7 +176,7 @@ export default function JourneyPublicPage() {
<span style={{ fontSize: 11, opacity: 0.4 }}>·</span>
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><Camera size={12} /> {stats.photos} {t('journey.stats.photos')}</span>
<span style={{ fontSize: 11, opacity: 0.4 }}>·</span>
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><MapPin size={12} /> {stats.cities} {t('journey.stats.places')}</span>
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><MapPin size={12} /> {stats.places} {t('journey.stats.places')}</span>
</div>
<div className="relative" style={{ marginTop: 12, fontSize: 9, fontWeight: 500, letterSpacing: 1.5, textTransform: 'uppercase', opacity: 0.25 }}>{t('journey.public.readOnly')}</div>
@@ -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)!
+18 -6
View File
@@ -574,7 +574,7 @@ export default function LoginPage(): React.ReactElement {
{ Icon: FolderOpen, label: t('login.features.files'), desc: t('login.features.filesDesc') },
{ Icon: Route, label: t('login.features.routes'), desc: t('login.features.routesDesc') },
].map(({ Icon, label, desc }) => (
<div key={label} style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 14, padding: '14px 12px', border: '1px solid rgba(255,255,255,0.06)', textAlign: 'left', transition: 'all 0.2s' }}
<div key={label} style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 14, padding: '14px 12px', border: '1px solid rgba(255,255,255,0.06)', textAlign: 'left', transition: 'background 200ms cubic-bezier(0.23,1,0.32,1), border-color 200ms cubic-bezier(0.23,1,0.32,1)' }}
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'rgba(255,255,255,0.08)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)' }}
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.06)' }}>
<Icon size={17} style={{ color: 'rgba(255,255,255,0.7)', marginBottom: 7 }} />
@@ -619,7 +619,7 @@ export default function LoginPage(): React.ReactElement {
border: 'none', borderRadius: 12,
fontSize: 14, fontWeight: 700, cursor: 'pointer',
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
textDecoration: 'none', transition: 'all 0.15s',
textDecoration: 'none', transition: 'background 180ms cubic-bezier(0.23,1,0.32,1)',
boxSizing: 'border-box',
}}
onMouseEnter={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#1f2937' }}
@@ -764,9 +764,21 @@ export default function LoginPage(): React.ReactElement {
/>
<button type="button" onClick={() => setShowPassword(v => !v)} style={{
position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)',
background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: '#9ca3af',
background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#9ca3af',
width: 22, height: 22,
}}>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
<Eye size={16} style={{
position: 'absolute', inset: 3,
opacity: showPassword ? 0 : 1,
transform: showPassword ? 'scale(0.7) rotate(-20deg)' : 'scale(1) rotate(0)',
transition: 'opacity 180ms cubic-bezier(0.23,1,0.32,1), transform 180ms cubic-bezier(0.23,1,0.32,1)',
}} />
<EyeOff size={16} style={{
position: 'absolute', inset: 3,
opacity: showPassword ? 1 : 0,
transform: showPassword ? 'scale(1) rotate(0)' : 'scale(0.7) rotate(20deg)',
transition: 'opacity 180ms cubic-bezier(0.23,1,0.32,1), transform 180ms cubic-bezier(0.23,1,0.32,1)',
}} />
</button>
</div>
</div>
@@ -816,7 +828,7 @@ export default function LoginPage(): React.ReactElement {
border: '1px solid #d1d5db', borderRadius: 12,
fontSize: 14, fontWeight: 600, cursor: 'pointer',
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
textDecoration: 'none', transition: 'all 0.15s',
textDecoration: 'none', transition: 'background 180ms cubic-bezier(0.23,1,0.32,1), border-color 180ms cubic-bezier(0.23,1,0.32,1)',
boxSizing: 'border-box',
}}
onMouseEnter={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#f9fafb'; e.currentTarget.style.borderColor = '#9ca3af' }}
@@ -837,7 +849,7 @@ export default function LoginPage(): React.ReactElement {
color: '#451a03', border: 'none', borderRadius: 14,
fontSize: 15, fontWeight: 700, cursor: isLoading ? 'default' : 'pointer',
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
opacity: isLoading ? 0.7 : 1, transition: 'all 0.2s',
opacity: isLoading ? 0.7 : 1, transition: 'transform 200ms cubic-bezier(0.23,1,0.32,1), box-shadow 200ms cubic-bezier(0.23,1,0.32,1), opacity 200ms cubic-bezier(0.23,1,0.32,1)',
boxShadow: '0 2px 12px rgba(245, 158, 11, 0.3)',
}}
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { if (!isLoading) e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 4px 16px rgba(245, 158, 11, 0.4)' }}
+4 -4
View File
@@ -100,7 +100,7 @@ export default function RegisterPage(): React.ReactElement {
required
placeholder="johndoe"
minLength={3}
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-[border-color,box-shadow] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
/>
</div>
</div>
@@ -115,7 +115,7 @@ export default function RegisterPage(): React.ReactElement {
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
required
placeholder="your@email.com"
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-[border-color,box-shadow] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
/>
</div>
</div>
@@ -130,7 +130,7 @@ export default function RegisterPage(): React.ReactElement {
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
required
placeholder={t('register.minChars')}
className="w-full pl-10 pr-12 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
className="w-full pl-10 pr-12 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-[border-color,box-shadow] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
/>
<button
type="button"
@@ -152,7 +152,7 @@ export default function RegisterPage(): React.ReactElement {
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPassword(e.target.value)}
required
placeholder={t('register.repeatPassword')}
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-[border-color,box-shadow] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
/>
</div>
</div>

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