Compare commits

..

71 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
160 changed files with 31798 additions and 2198 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ client/public/icons/*.png
*.sqlite-wal
# User data
server/data/
server/data/*
server/uploads/
# Environment
-75
View File
@@ -2367,9 +2367,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2387,9 +2384,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2407,9 +2401,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2427,9 +2418,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2447,9 +2435,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2467,9 +2452,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2487,9 +2469,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2513,9 +2492,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2539,9 +2515,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2565,9 +2538,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2591,9 +2561,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2617,9 +2584,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3399,9 +3363,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3416,9 +3377,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3433,9 +3391,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3450,9 +3405,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3467,9 +3419,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3484,9 +3433,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3501,9 +3447,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3518,9 +3461,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3535,9 +3475,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3552,9 +3489,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3569,9 +3503,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3586,9 +3517,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3603,9 +3531,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
+5 -2
View File
@@ -100,7 +100,7 @@ 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()
@@ -116,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)
@@ -125,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) {
+26 -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,12 @@ 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),
@@ -331,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),
@@ -365,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),
+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>
+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 -1
View File
@@ -308,7 +308,7 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
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)
+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>
)
}
@@ -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 }}>
<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();
});
+339 -97
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'
@@ -12,6 +12,8 @@ import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
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
@@ -25,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
@@ -32,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()
@@ -110,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)
})
}
@@ -126,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 => {
@@ -140,12 +270,16 @@ 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 (filter === 'tracks' && !p.route_geometry) return false
if (categoryFilters.size > 0) {
if (p.category_id == null) {
if (!categoryFilters.has('uncategorized')) return false
@@ -159,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}
@@ -220,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' }}>
@@ -240,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,
@@ -263,9 +463,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
? (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',
@@ -273,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,
@@ -343,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)' }}>
@@ -363,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}
/>
)
})
)}
@@ -602,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,34 +101,26 @@ 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 () => {
@@ -139,12 +131,10 @@ describe('ReservationModal', () => {
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 () => {
@@ -183,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 ──────────────────────────────────────────────────────────────
@@ -232,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' })
);
});
@@ -439,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' })
);
});
@@ -473,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}
@@ -575,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' })
);
});
@@ -638,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 () => {
@@ -734,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_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
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,15 +121,6 @@ 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 || '',
@@ -152,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_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
@@ -199,23 +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
@@ -224,23 +175,24 @@ 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,
@@ -338,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 }} />
@@ -365,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>
@@ -528,8 +442,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
/>
</div>
</div>
{/* Check-in/out times + Status */}
<div className="grid grid-cols-2 sm:grid-cols-4 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)} />
@@ -542,42 +455,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<label style={labelStyle}>{t('reservations.meta.checkOut')}</label>
<CustomTimePicker value={form.meta_check_out_time} onChange={v => set('meta_check_out_time', v)} />
</div>
<div>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
</div>
</div>
</>
)}
{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>
@@ -596,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)
@@ -634,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={{
@@ -678,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 }}>
@@ -686,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>
@@ -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'
@@ -69,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()
@@ -109,6 +110,21 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
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', display: 'flex', flexDirection: 'column',
@@ -142,6 +158,17 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
<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={{
@@ -175,6 +202,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{/* 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>
)}
{/* Date / Time row */}
{hasDate && (
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasTime ? '1fr 1fr' : '1fr' }}>
@@ -218,15 +254,35 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
</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 (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 (!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 })
@@ -351,10 +407,20 @@ 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: 28 }}>
<button onClick={() => setOpen(o => !o)} style={{
@@ -389,9 +455,11 @@ 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)
@@ -442,7 +510,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
{t('reservations.title')}
{t(titleKey)}
</h2>
{reservations.length > 0 && (
@@ -516,7 +584,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
<Plus size={14} strokeWidth={2.5} />
<span className="hidden sm:inline">{t('reservations.addManual')}</span>
<span className="hidden sm:inline">{t(addManualKey)}</span>
</button>
)}
</div>
@@ -537,13 +605,13 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
) : (
<>
{allPending.length > 0 && (
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
<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">
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
<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>
@@ -112,8 +112,9 @@ describe('ModalRenderer', () => {
});
it('FE-SN-MODAL-005: CTA nav button dismisses all notices (not just current)', async () => {
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } });
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
// 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');
@@ -121,6 +122,12 @@ describe('ModalRenderer', () => {
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);
@@ -299,17 +306,22 @@ describe('ModalRenderer', () => {
expect(screen.getByText('Notice A')).toBeTruthy();
expect(screen.getByText('1 / 3')).toBeTruthy();
// Dismiss notice A — store shrinks, parent re-renders with [B, C]
// Navigate to last page where X button is available
await act(async () => {
fireEvent.click(screen.getByLabelText('Dismiss'));
useSystemNoticeStore.setState({ notices: [noticeB, noticeC], loaded: true });
rerender(<ModalRenderer notices={[noticeB, noticeC]} />);
fireEvent.click(screen.getByLabelText('Go to notice 3'));
});
await flushGraceDelay();
// Must show B (idx=0), not C (idx=1 — the old buggy behavior)
expect(screen.getByText('Notice B')).toBeTruthy();
expect(screen.getByText('1 / 2')).toBeTruthy();
// 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 () => {
@@ -321,6 +333,12 @@ describe('ModalRenderer', () => {
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'));
});
@@ -330,7 +348,7 @@ describe('ModalRenderer', () => {
expect(dismissSpy).toHaveBeenCalledTimes(2);
});
it('FE-SN-MODAL-018: ESC key dismisses all notices when current is dismissible', async () => {
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 });
@@ -339,6 +357,12 @@ describe('ModalRenderer', () => {
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' });
});
@@ -1,4 +1,5 @@
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';
@@ -62,6 +63,7 @@ interface ContentProps {
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
@@ -69,125 +71,168 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
: DefaultIcon;
return (
<div className="flex flex-col relative">
{/* Dismiss X button */}
{notice.dismissible && (
<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 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"
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>
)}
{/* 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>
)}
<div className="p-8">
{/* Severity icon (when no hero) */}
{!notice.media && (
<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 */}
<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-center text-slate-600 dark:text-slate-400 max-w-[340px] mx-auto mb-4"
>
<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-blue-600 dark:text-blue-400 underline hover:no-underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
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' && (
{/* 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 rounded-lg mb-4 max-w-[340px] mx-auto"
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>
)}
{/* Highlights */}
{notice.highlights && notice.highlights.length > 0 && (
<ul className="max-w-[340px] 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>
{/* 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 mb-4">
<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="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
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>
@@ -211,7 +256,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
onClick={onNext}
disabled={!canPage || currentPage === total - 1}
aria-label={t('system_notice.pager.next')}
className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
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>
@@ -226,8 +271,8 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
)}
{/* CTA + dismiss link */}
<div className="flex flex-col items-center gap-3 mt-2">
{ctaLabel ? (
<div className="flex flex-col items-center gap-3">
{ctaLabel && isLastPage ? (
<button
id={`notice-cta-${notice.id}`}
onClick={onCTA}
@@ -235,16 +280,16 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
>
{ctaLabel}
</button>
) : (
) : (notice.dismissible || isLastPage) && (
<button
id={`notice-cta-${notice.id}`}
onClick={onDismissAll}
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 && ctaLabel && (
{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"
@@ -283,7 +328,13 @@ export function ModalRenderer({ notices }: Props) {
// 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;
@@ -295,7 +346,15 @@ export function ModalRenderer({ notices }: Props) {
// contentWrapperRef: the div wrapping NoticeContent — we animate its transform directly.
const isPageNavRef = useRef(false);
const slideDirRef = useRef<'left' | 'right'>('right');
const contentWrapperRef = useRef<HTMLDivElement>(null);
// Mobile 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(() => {
@@ -368,15 +427,16 @@ export function ModalRenderer({ notices }: Props) {
};
}, [notice?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// ESC key — closes all modal notices (same as clicking X)
// 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) return;
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]); // eslint-disable-line react-hooks/exhaustive-deps
}, [visible, notice?.dismissible, isLastPage]); // eslint-disable-line react-hooks/exhaustive-deps
// Arrow-key pager navigation
useEffect(() => {
@@ -410,6 +470,12 @@ export function ModalRenderer({ notices }: Props) {
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')
@@ -453,6 +519,17 @@ export function ModalRenderer({ notices }: Props) {
}
}
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.
@@ -531,6 +608,38 @@ export function ModalRenderer({ notices }: Props) {
? (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 */}
@@ -538,30 +647,150 @@ export function ModalRenderer({ notices }: Props) {
{/* Backdrop */}
<div
className={`absolute inset-0 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
onClick={notice.dismissible ? handleDismiss : undefined}
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 max-h-[85dvh] overflow-y-auto bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-all ${dur} ${ease} ${mobileMotion}`}
onTouchStart={e => { touchStartY.current = e.touches[0].clientY; }}
onTouchEnd={e => {
if (touchStartY.current !== null && notice.dismissible) {
const delta = e.changedTouches[0].clientY - touchStartY.current;
if (delta > 80) handleDismiss();
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 */}
<div className="pt-3 pb-1 flex justify-center">
{/* 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>
<div ref={contentWrapperRef}>
<NoticeContent {...contentProps} />
{/* 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>
@@ -569,7 +798,7 @@ export function ModalRenderer({ notices }: Props) {
}
// Desktop centered modal
const maxWidth = notice.severity === 'critical' ? 'max-w-[560px]' : 'max-w-[480px]';
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]');
@@ -578,7 +807,7 @@ export function ModalRenderer({ notices }: Props) {
<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 ? handleDismiss : undefined}
onClick={notice.dismissible && isLastPage ? handleDismissAll : undefined}
>
{/* Screen-reader page announcements */}
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{pageAnnouncement}</span>
@@ -588,7 +817,7 @@ export function ModalRenderer({ notices }: Props) {
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={bodyId}
className={`w-full ${maxWidth} rounded-2xl overflow-hidden shadow-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 transition-all ${dur} ${ease} ${desktopMotion}`}
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}>
@@ -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 }
}
+74 -2
View File
@@ -14,6 +14,9 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'common.add': 'إضافة',
'common.loading': 'جارٍ التحميل...',
'common.import': 'استيراد',
'common.select': 'تحديد',
'common.selectAll': 'تحديد الكل',
'common.deselectAll': 'إلغاء تحديد الكل',
'common.error': 'خطأ',
'common.unknownError': 'خطأ غير معروف',
'common.tooManyAttempts': 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
@@ -313,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': 'بواسطة موريس ومجتمع مفتوح المصدر متنامٍ.',
@@ -588,6 +601,12 @@ 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': 'الدردشة',
@@ -851,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': 'قائمة التجهيز',
@@ -873,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': 'لا توجد أماكن مخططة لهذا اليوم',
@@ -917,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': 'استيراد قائمة',
@@ -933,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': 'فئات',
@@ -1017,6 +1051,15 @@ 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': 'المقعد',
@@ -1035,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': 'جولة',
@@ -1096,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': 'الميزانية',
@@ -1533,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': 'اختبر الاتصال أولاً',
@@ -1570,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} صورة',
@@ -1603,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': 'من المعرض',
@@ -1740,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',
@@ -1799,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': 'مهمة جديدة',
@@ -1876,6 +1935,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'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} متاح الآن.',
@@ -2019,6 +2082,15 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'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
+74 -2
View File
@@ -10,6 +10,9 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'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.',
@@ -240,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.',
@@ -546,6 +559,12 @@ 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',
@@ -821,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',
@@ -842,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
@@ -887,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',
@@ -903,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',
@@ -986,6 +1020,15 @@ 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',
@@ -1004,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',
@@ -1065,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',
@@ -1572,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',
@@ -1689,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',
@@ -1748,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',
@@ -1825,6 +1875,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'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.',
@@ -1872,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',
@@ -1893,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)',
@@ -1969,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',
@@ -2046,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.',
@@ -2222,6 +2285,15 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'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
+74 -2
View File
@@ -10,6 +10,9 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'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.',
@@ -264,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.',
@@ -546,6 +559,12 @@ 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',
@@ -849,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í',
@@ -871,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',
@@ -915,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',
@@ -931,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í',
@@ -1015,6 +1049,15 @@ 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',
@@ -1033,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',
@@ -1094,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',
@@ -1531,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í',
@@ -1692,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',
@@ -1753,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',
@@ -1830,6 +1880,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'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.',
@@ -1877,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',
@@ -1898,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é)',
@@ -1974,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',
@@ -2051,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.',
@@ -2226,6 +2289,15 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'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
+83 -5
View File
@@ -10,6 +10,9 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'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.',
@@ -145,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',
@@ -176,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',
@@ -311,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.',
@@ -549,6 +564,12 @@ 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',
@@ -852,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',
@@ -874,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',
@@ -884,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',
@@ -908,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',
@@ -918,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',
@@ -934,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',
@@ -1017,6 +1055,13 @@ 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',
@@ -1035,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',
@@ -1096,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',
@@ -1533,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',
@@ -1697,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',
@@ -1756,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',
@@ -1833,6 +1885,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'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.',
@@ -1874,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',
@@ -1895,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)',
@@ -1975,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',
@@ -2025,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',
@@ -2052,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.',
@@ -2226,6 +2295,15 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'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
+81 -2
View File
@@ -10,6 +10,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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.',
@@ -176,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',
@@ -251,6 +256,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -366,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.',
@@ -605,6 +624,12 @@ 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',
@@ -905,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',
@@ -927,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',
@@ -937,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',
@@ -971,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',
@@ -987,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',
@@ -1070,6 +1112,13 @@ 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',
@@ -1088,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',
@@ -1149,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',
@@ -1588,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',
@@ -1762,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',
@@ -1818,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',
@@ -1877,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',
@@ -1898,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)',
@@ -1958,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',
@@ -1986,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',
@@ -2044,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',
@@ -2075,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.',
@@ -2248,6 +2318,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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.',
@@ -2263,6 +2337,11 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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
+74 -2
View File
@@ -10,6 +10,9 @@ const es: Record<string, string> = {
'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.',
@@ -309,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.',
@@ -541,6 +554,12 @@ 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',
@@ -824,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',
@@ -846,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',
@@ -890,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',
@@ -906,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',
@@ -990,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',
@@ -1051,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',
@@ -1481,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',
@@ -1618,6 +1654,15 @@ 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',
@@ -1699,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',
@@ -1758,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',
@@ -1835,6 +1885,10 @@ const es: Record<string, string> = {
'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.',
@@ -1879,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',
@@ -1900,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)',
@@ -1976,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',
@@ -2053,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.',
@@ -2228,6 +2291,15 @@ const es: Record<string, string> = {
'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
+74 -2
View File
@@ -10,6 +10,9 @@ const fr: Record<string, string> = {
'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.',
@@ -308,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.',
@@ -545,6 +558,12 @@ 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',
@@ -848,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',
@@ -870,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',
@@ -914,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',
@@ -930,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',
@@ -1013,6 +1047,15 @@ 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',
@@ -1031,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',
@@ -1092,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',
@@ -1529,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',
@@ -1693,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',
@@ -1752,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',
@@ -1829,6 +1879,10 @@ const fr: Record<string, string> = {
'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.',
@@ -1873,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',
@@ -1894,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)',
@@ -1970,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',
@@ -2047,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.',
@@ -2222,6 +2285,15 @@ const fr: Record<string, string> = {
'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
+74 -2
View File
@@ -10,6 +10,9 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'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.',
@@ -263,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.',
@@ -546,6 +559,12 @@ 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',
@@ -849,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',
@@ -870,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
@@ -915,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',
@@ -931,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',
@@ -1015,6 +1049,15 @@ 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',
@@ -1033,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',
@@ -1093,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',
@@ -1600,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',
@@ -1691,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',
@@ -1750,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',
@@ -1827,6 +1877,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'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ő.',
@@ -1874,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ó',
@@ -1895,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)',
@@ -1971,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',
@@ -2048,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.',
@@ -2223,6 +2286,15 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'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
+74 -2
View File
@@ -10,6 +10,9 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'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.',
@@ -251,6 +254,10 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -366,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.',
@@ -606,6 +623,12 @@ 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',
@@ -905,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',
@@ -927,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',
@@ -971,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',
@@ -987,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',
@@ -1070,6 +1108,15 @@ 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',
@@ -1088,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',
@@ -1149,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',
@@ -1588,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',
@@ -1762,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',
@@ -1818,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',
@@ -1877,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',
@@ -1898,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)',
@@ -1986,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',
@@ -2075,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.',
@@ -2264,6 +2327,15 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'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;
+74 -2
View File
@@ -10,6 +10,9 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'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.',
@@ -263,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.',
@@ -545,6 +558,12 @@ 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',
@@ -849,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',
@@ -870,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
@@ -915,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',
@@ -931,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',
@@ -1014,6 +1048,15 @@ 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',
@@ -1032,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',
@@ -1093,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',
@@ -1530,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',
@@ -1695,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',
@@ -1753,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à',
@@ -1830,6 +1880,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'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.',
@@ -1874,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',
@@ -1895,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)',
@@ -1971,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',
@@ -2048,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.',
@@ -2223,6 +2286,15 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'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
+74 -2
View File
@@ -10,6 +10,9 @@ const nl: Record<string, string> = {
'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.',
@@ -308,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.',
@@ -546,6 +559,12 @@ 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',
@@ -848,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',
@@ -870,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',
@@ -914,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',
@@ -930,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',
@@ -1013,6 +1047,15 @@ 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',
@@ -1031,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',
@@ -1092,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',
@@ -1529,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',
@@ -1693,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',
@@ -1752,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',
@@ -1829,6 +1879,10 @@ const nl: Record<string, string> = {
'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.',
@@ -1873,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',
@@ -1894,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)',
@@ -1970,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',
@@ -2047,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.',
@@ -2222,6 +2285,15 @@ const nl: Record<string, string> = {
'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
+73 -1
View File
@@ -281,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.',
@@ -518,6 +528,12 @@ 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',
@@ -816,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',
@@ -837,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ń',
@@ -881,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}',
@@ -888,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',
@@ -989,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',
@@ -1049,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',
@@ -1484,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',
@@ -1580,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',
@@ -1633,6 +1678,10 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'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.',
@@ -1749,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',
@@ -1801,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',
@@ -1866,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',
@@ -1887,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)',
@@ -1963,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',
@@ -2040,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.',
@@ -2215,6 +2278,15 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'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
+74 -2
View File
@@ -10,6 +10,9 @@ const ru: Record<string, string> = {
'common.add': 'Добавить',
'common.loading': 'Загрузка...',
'common.import': 'Импорт',
'common.select': 'Выбрать',
'common.selectAll': 'Выбрать всё',
'common.deselectAll': 'Снять выделение со всех',
'common.error': 'Ошибка',
'common.unknownError': 'Неизвестная ошибка',
'common.tooManyAttempts': 'Слишком много попыток. Попробуйте позже.',
@@ -308,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 сообществом.',
@@ -546,6 +559,12 @@ 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': 'Чат',
@@ -848,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': 'Список вещей',
@@ -870,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': 'На этот день мест не запланировано',
@@ -914,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': 'Импорт списка',
@@ -930,6 +963,7 @@ const ru: Record<string, string> = {
'places.assignToDay': 'Добавить в какой день?',
'places.all': 'Все',
'places.unplanned': 'Незапланированные',
'places.filterTracks': 'Треки',
'places.search': 'Поиск мест...',
'places.allCategories': 'Все категории',
'places.categoriesSelected': 'категорий',
@@ -1013,6 +1047,15 @@ 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': 'Место',
@@ -1031,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': 'Экскурсия',
@@ -1092,6 +1135,7 @@ const ru: Record<string, string> = {
'reservations.span.end': 'Конец',
'reservations.span.ongoing': 'Продолжается',
'reservations.validation.endBeforeStart': 'Дата/время окончания должны быть позже даты/времени начала',
'reservations.addBooking': 'Добавить бронирование',
// Budget
'budget.title': 'Бюджет',
@@ -1529,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': 'Сначала проверьте подключение',
@@ -1690,6 +1735,7 @@ const ru: Record<string, string> = {
'undo.reorder': 'Места переупорядочены',
'undo.optimize': 'Маршрут оптимизирован',
'undo.deletePlace': 'Место удалено',
'undo.deletePlaces': 'Места удалены',
'undo.moveDay': 'Место перемещено в другой день',
'undo.lock': 'Блокировка места изменена',
'undo.importGpx': 'Импорт GPX',
@@ -1749,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': 'Новая задача',
@@ -1826,6 +1876,10 @@ const ru: Record<string, string> = {
'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} теперь доступен.',
@@ -1873,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': 'Новое путешествие',
@@ -1894,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': 'Заметки (необязательно)',
@@ -1970,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': 'Из галереи',
@@ -2047,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}»? Все записи и фото будут потеряны.',
@@ -2222,6 +2285,15 @@ const ru: Record<string, string> = {
'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
+74 -2
View File
@@ -10,6 +10,9 @@ const zh: Record<string, string> = {
'common.add': '添加',
'common.loading': '加载中...',
'common.import': '导入',
'common.select': '选择',
'common.selectAll': '全选',
'common.deselectAll': '取消全选',
'common.error': '错误',
'common.unknownError': '未知错误',
'common.tooManyAttempts': '尝试次数过多,请稍后再试。',
@@ -308,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 和不断壮大的开源社区打造。',
@@ -546,6 +559,12 @@ 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': '聊天',
@@ -848,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': '行李清单',
@@ -870,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': '当天暂无计划',
@@ -914,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': '列表导入',
@@ -930,6 +963,7 @@ const zh: Record<string, string> = {
'places.assignToDay': '添加到哪一天?',
'places.all': '全部',
'places.unplanned': '未规划',
'places.filterTracks': '路线',
'places.search': '搜索地点...',
'places.allCategories': '所有分类',
'places.categoriesSelected': '个分类',
@@ -1013,6 +1047,15 @@ 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': '座位',
@@ -1031,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': '旅游团',
@@ -1092,6 +1135,7 @@ const zh: Record<string, string> = {
'reservations.span.end': '结束',
'reservations.span.ongoing': '进行中',
'reservations.validation.endBeforeStart': '结束日期/时间必须晚于开始日期/时间',
'reservations.addBooking': '添加预订',
// Budget
'budget.title': '预算',
@@ -1529,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': '请先测试连接',
@@ -1690,6 +1735,7 @@ const zh: Record<string, string> = {
'undo.reorder': '地点已重新排序',
'undo.optimize': '路线已优化',
'undo.deletePlace': '地点已删除',
'undo.deletePlaces': '地点已删除',
'undo.moveDay': '地点已移至另一天',
'undo.lock': '地点锁定已切换',
'undo.importGpx': 'GPX 导入',
@@ -1749,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': '新任务',
@@ -1826,6 +1876,10 @@ const zh: Record<string, string> = {
'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} 现已可用。',
@@ -1873,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': '新建旅程',
@@ -1894,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': '备注(可选)',
@@ -1970,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': '从相册',
@@ -2047,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}"?所有条目和照片将丢失。',
@@ -2222,6 +2285,15 @@ const zh: Record<string, string> = {
'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
+74 -2
View File
@@ -10,6 +10,9 @@ const zhTw: Record<string, string> = {
'common.add': '新增',
'common.loading': '載入中...',
'common.import': '匯入',
'common.select': '選擇',
'common.selectAll': '全選',
'common.deselectAll': '取消全選',
'common.error': '錯誤',
'common.unknownError': '未知錯誤',
'common.tooManyAttempts': '嘗試次數過多,請稍後再試。',
@@ -251,6 +254,10 @@ const zhTw: Record<string, string> = {
'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': '傳送測試郵件',
@@ -363,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 及不斷成長的開源社群製作。',
@@ -602,6 +619,12 @@ 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': '聊天',
@@ -904,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': '行李清單',
@@ -926,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': '當天暫無計劃',
@@ -970,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': '列表匯入',
@@ -986,6 +1023,7 @@ const zhTw: Record<string, string> = {
'places.assignToDay': '新增到哪一天?',
'places.all': '全部',
'places.unplanned': '未規劃',
'places.filterTracks': '路線',
'places.search': '搜尋地點...',
'places.allCategories': '所有分類',
'places.categoriesSelected': '個分類',
@@ -1069,6 +1107,15 @@ 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': '座位',
@@ -1087,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': '旅遊團',
@@ -1148,6 +1195,7 @@ const zhTw: Record<string, string> = {
'reservations.span.end': '結束',
'reservations.span.ongoing': '進行中',
'reservations.validation.endBeforeStart': '結束日期/時間必須晚於開始日期/時間',
'reservations.addBooking': '新增預訂',
// Budget
'budget.title': '預算',
@@ -1585,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': '請先測試連線',
@@ -1746,6 +1795,7 @@ const zhTw: Record<string, string> = {
'undo.reorder': '地點已重新排序',
'undo.optimize': '路線已最佳化',
'undo.deletePlace': '地點已刪除',
'undo.deletePlaces': '地點已刪除',
'undo.moveDay': '地點已移至另一天',
'undo.lock': '地點鎖定已切換',
'undo.importGpx': 'GPX 匯入',
@@ -1766,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': '新任務',
@@ -1833,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': '新建旅程',
@@ -1854,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': '備註(可選)',
@@ -1930,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': '從相簿',
@@ -2007,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}」?所有條目和照片將遺失。',
@@ -2223,6 +2286,15 @@ const zhTw: Record<string, string> = {
'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
+284
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;
+126 -14
View File
@@ -11,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'
@@ -161,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()
@@ -194,6 +210,18 @@ 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(() => {}) }, [])
@@ -242,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()
@@ -553,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>
)}
@@ -617,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">
@@ -891,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
@@ -918,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
@@ -1023,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">
@@ -1180,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'
@@ -1255,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)' }} />
@@ -1338,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">
+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();
+118 -85
View File
@@ -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>
)
@@ -897,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>
@@ -989,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>
@@ -1055,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}
@@ -1070,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}
+54 -36
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();
});
});
@@ -707,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();
});
@@ -741,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);
});
});
@@ -1106,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);
});
});
@@ -1166,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);
});
});
@@ -1717,7 +1729,7 @@ describe('JourneyDetailPage', () => {
};
setupDefaultHandlers({
entries: [emptyEntry],
stats: { entries: 1, photos: 0, cities: 1 },
stats: { entries: 1, photos: 0, places: 1 },
});
render(<JourneyDetailPage />);
@@ -1867,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)
@@ -1930,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(() => {
@@ -2005,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 />);
@@ -2039,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 />);
@@ -2636,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 />);
@@ -2661,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 />);
@@ -3045,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 />);
@@ -3528,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(
@@ -3565,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;
@@ -3604,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);
});
@@ -3620,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 />);
+360 -127
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} />
@@ -1450,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)
@@ -1518,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)
@@ -1554,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
})
}
@@ -1585,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
@@ -1597,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>
@@ -1643,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'
@@ -1717,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"
@@ -1773,13 +1927,13 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
}`}
>
<img
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail`}
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`
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`
if (!img.src.includes('/original')) img.src = original
}}
/>
@@ -1821,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"
>
@@ -1865,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">
@@ -1874,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>
@@ -1973,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 {
@@ -1987,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) {
@@ -2010,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.75)' }}>
<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>
@@ -2185,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}
@@ -2219,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}
@@ -2283,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>
@@ -2330,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>
@@ -2361,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>
@@ -2754,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 {
@@ -2766,7 +2968,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
return (
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'var(--bottom-nav-h)' }} onClick={e => e.stopPropagation()}>
<div className="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>
@@ -2859,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
@@ -2878,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>
+271 -69
View File
@@ -12,22 +12,26 @@ import PlaceInspector from '../components/Planner/PlaceInspector'
import DayDetailPanel from '../components/Planner/DayDetailPanel'
import PlaceFormModal from '../components/Planner/PlaceFormModal'
import TripFormModal from '../components/Trips/TripFormModal'
import SlidingTabs from '../components/shared/SlidingTabs'
import TripMembersModal from '../components/Trips/TripMembersModal'
import { ReservationModal } from '../components/Planner/ReservationModal'
import { TransportModal } from '../components/Planner/TransportModal'
// MemoriesPanel moved to Journey addon
import ReservationsPanel from '../components/Planner/ReservationsPanel'
import PackingListPanel from '../components/Packing/PackingListPanel'
import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
import TodoListPanel from '../components/Todo/TodoListPanel'
import FileManager from '../components/Files/FileManager'
import BudgetPanel from '../components/Budget/BudgetPanel'
import CollabPanel from '../components/Collab/CollabPanel'
import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast'
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Users } from 'lucide-react'
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
import { useTranslation } from '../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client'
import { accommodationRepo } from '../repo/accommodationRepo'
import { offlineDb } from '../db/offlineDb'
import { useAuthStore } from '../store/authStore'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import { useResizablePanels } from '../hooks/useResizablePanels'
import { useTripWebSocket } from '../hooks/useTripWebSocket'
@@ -35,36 +39,129 @@ import { useRouteCalculation } from '../hooks/useRouteCalculation'
import { usePlaceSelection } from '../hooks/usePlaceSelection'
import { usePlannerHistory } from '../hooks/usePlannerHistory'
import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types'
import { ListTodo } from 'lucide-react'
import { ListTodo, Upload, Plus, Trash2, FolderPlus } from 'lucide-react'
function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) {
const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => {
return (sessionStorage.getItem(`trip-lists-subtab-${tripId}`) as 'packing' | 'todo') || 'packing'
})
const setSubTabPersist = (tab: 'packing' | 'todo') => { setSubTab(tab); sessionStorage.setItem(`trip-lists-subtab-${tripId}`, tab) }
const [importPackingSignal, setImportPackingSignal] = useState(0)
const [clearCheckedSignal, setClearCheckedSignal] = useState(0)
const [saveTemplateSignal, setSaveTemplateSignal] = useState(0)
const [addTodoSignal, setAddTodoSignal] = useState(0)
const { t } = useTranslation()
const tabs = [
{ id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck, count: packingItems.length },
{ id: 'todo' as const, label: t('todo.subtab.todo'), icon: ListTodo, count: todoItems.length },
]
return (
<div>
<div style={{ display: 'flex', gap: 4, padding: '4px 16px 0', borderBottom: '1px solid var(--border-faint)', marginBottom: 8 }}>
{([
{ id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck },
{ id: 'todo' as const, label: t('todo.subtab.todo'), icon: ListTodo },
]).map(tab => (
<button key={tab.id} onClick={() => setSubTabPersist(tab.id)}
style={{
display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, fontWeight: 500, padding: '8px 14px',
border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: 'none',
color: subTab === tab.id ? 'var(--text-primary)' : 'var(--text-faint)',
borderBottom: subTab === tab.id ? '2px solid var(--text-primary)' : '2px solid transparent',
marginBottom: -1, transition: 'color 0.15s',
}}>
<tab.icon size={14} />
{tab.label}
</button>
))}
<div 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('trip.tabs.lists')}
</h2>
<div className="hidden md:block" style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
<div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
{tabs.map(tab => {
const active = subTab === tab.id
const Icon = tab.icon
return (
<button key={tab.id} onClick={() => setSubTabPersist(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: 'background 180ms cubic-bezier(0.23,1,0.32,1), color 180ms cubic-bezier(0.23,1,0.32,1), box-shadow 180ms cubic-bezier(0.23,1,0.32,1)',
}}
>
<Icon size={13} style={{ color: active ? 'var(--text-primary)' : 'var(--text-faint)' }} />
<span className="hidden sm:inline">{tab.label}</span>
<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',
}}>{tab.count}</span>
</button>
)
})}
</div>
{subTab === 'packing' && (() => {
const packingAbgehakt = packingItems.filter(i => i.checked).length
const sharedBtnClass = 'inline-flex items-center gap-1.5 px-2.5 sm:px-[14px] py-[7px] sm:py-[9px] hover:opacity-[0.88]'
const sharedBtnStyle: React.CSSProperties = {
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
borderRadius: 10, fontSize: 13, fontWeight: 500,
}
return (
<div style={{ display: 'flex', gap: 6, flexShrink: 0, marginLeft: 'auto', flexWrap: 'wrap' }}>
{packingAbgehakt > 0 && (
<button onClick={() => setClearCheckedSignal(s => s + 1)}
className={`hidden sm:inline-flex items-center gap-1.5 px-[14px] py-[9px] hover:opacity-[0.88]`}
style={{ ...sharedBtnStyle, background: 'rgba(239,68,68,0.14)', color: '#ef4444' }}
>
<Trash2 size={14} strokeWidth={2.5} />
<span>{t('packing.clearChecked', { count: packingAbgehakt })}</span>
</button>
)}
<ApplyTemplateButton
tripId={tripId}
className={sharedBtnClass}
style={{ ...sharedBtnStyle, background: 'var(--accent)', color: 'var(--accent-text)' }}
/>
{packingItems.length > 0 && (
<button onClick={() => setSaveTemplateSignal(s => s + 1)}
className={sharedBtnClass}
style={{ ...sharedBtnStyle, background: 'var(--accent)', color: 'var(--accent-text)' }}
>
<FolderPlus size={14} strokeWidth={2.5} />
<span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span>
</button>
)}
<button onClick={() => setImportPackingSignal(s => s + 1)}
className={sharedBtnClass}
style={{ ...sharedBtnStyle, background: 'var(--accent)', color: 'var(--accent-text)' }}
>
<Upload size={14} strokeWidth={2.5} />
<span className="hidden sm:inline">{t('packing.import')}</span>
</button>
</div>
)
})()}
{subTab === 'todo' && (
<button onClick={() => setAddTodoSignal(s => s + 1)}
className="hover:opacity-[0.88]"
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',
}}
>
<Plus size={14} strokeWidth={2.5} />
<span className="hidden sm:inline">{t('todo.addItem')}</span>
</button>
)}
</div>
</div>
<div style={{ padding: '16px 28px 0' }} className="max-md:!px-4">
{subTab === 'packing' && <PackingListPanel tripId={tripId} items={packingItems} openImportSignal={importPackingSignal} clearCheckedSignal={clearCheckedSignal} saveTemplateSignal={saveTemplateSignal} inlineHeader={false} />}
{subTab === 'todo' && <TodoListPanel tripId={tripId} items={todoItems} addItemSignal={addTodoSignal} />}
</div>
{subTab === 'packing' && <PackingListPanel tripId={tripId} items={packingItems} />}
{subTab === 'todo' && <TodoListPanel tripId={tripId} items={todoItems} />}
</div>
)
}
@@ -75,6 +172,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
const toast = useToast()
const { t, language } = useTranslation()
const { settings } = useSettingsStore()
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
const trip = useTripStore(s => s.trip)
const days = useTripStore(s => s.days)
const places = useTripStore(s => s.places)
@@ -124,8 +222,11 @@ export default function TripPlannerPage(): React.ReactElement | null {
}).catch(() => {})
}, [])
const TRANSPORT_TYPES = new Set(['flight', 'train', 'car', 'cruise', 'bus'])
const TRIP_TABS = [
{ id: 'plan', label: t('trip.tabs.plan'), icon: Map },
{ id: 'transports', label: t('trip.tabs.transports'), icon: Train },
{ id: 'buchungen', label: t('trip.tabs.reservations'), shortLabel: t('trip.tabs.reservationsShort'), icon: Ticket },
...(enabledAddons.packing ? [{ id: 'listen', label: t('trip.tabs.lists'), shortLabel: t('trip.tabs.listsShort'), icon: PackageCheck }] : []),
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget'), icon: Wallet }] : []),
@@ -164,9 +265,41 @@ export default function TripPlannerPage(): React.ReactElement | null {
const [showMembersModal, setShowMembersModal] = useState<boolean>(false)
const [showReservationModal, setShowReservationModal] = useState<boolean>(false)
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
const [bookingForAssignmentId, setBookingForAssignmentId] = useState<number | null>(null)
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
const [fitKey, setFitKey] = useState<number>(0)
const initialFitTripId = useRef<number | null>(null)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
const [deletePlaceIds, setDeletePlaceIds] = useState<number[] | null>(null)
useEffect(() => {
if (!trip) return
if (initialFitTripId.current === trip.id) return
const hasGeoPlaces = places.some(p => p.lat != null && p.lng != null)
if (!hasGeoPlaces) return
initialFitTripId.current = trip.id
setFitKey(k => k + 1)
}, [trip, places])
const connectionsStorageKey = tripId ? `trek:visible-connections:${tripId}` : null
const [visibleConnections, setVisibleConnections] = useState<number[]>(() => {
if (typeof window === 'undefined' || !connectionsStorageKey) return []
try {
const stored = window.localStorage.getItem(connectionsStorageKey)
return stored ? JSON.parse(stored) as number[] : []
} catch { return [] }
})
useEffect(() => {
if (typeof window === 'undefined' || !connectionsStorageKey) return
window.localStorage.setItem(connectionsStorageKey, JSON.stringify(visibleConnections))
}, [connectionsStorageKey, visibleConnections])
const toggleConnection = useCallback((id: number) => {
setVisibleConnections(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}, [])
const [mapTransportDetail, setMapTransportDetail] = useState<Reservation | null>(null)
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
useEffect(() => {
@@ -178,7 +311,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
// Start photo fetches during splash screen so images are ready when map mounts
useEffect(() => {
if (isLoading || !places || places.length === 0) return
if (isLoading || !places || places.length === 0 || !placesPhotosEnabled) return
for (const p of places) {
if (p.image_url) continue
const cacheKey = p.google_place_id || p.osm_id || `${p.lat},${p.lng}`
@@ -248,6 +381,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
return places.filter(p => {
if (!p.lat || !p.lng) return false
if (mapPlacesFilter === 'tracks' && !p.route_geometry) return false
if (mapCategoryFilter.size > 0) {
if (p.category_id == null) {
if (!mapCategoryFilter.has('uncategorized')) return false
@@ -361,6 +495,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
try {
await tripActions.deletePlace(tripId, deletePlaceId)
if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null)
updateRouteForDay(selectedDayId)
toast.success(t('trip.toast.placeDeleted'))
if (capturedPlace) {
pushUndo(t('undo.deletePlace'), async () => {
@@ -380,7 +515,38 @@ export default function TripPlannerPage(): React.ReactElement | null {
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [deletePlaceId, tripId, toast, selectedPlaceId, pushUndo])
}, [deletePlaceId, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
const confirmDeletePlaces = useCallback(async (ids?: number[]) => {
const targetIds = ids ?? deletePlaceIds
if (!targetIds?.length) return
const state = useTripStore.getState()
const capturedPlaces = state.places.filter(p => targetIds.includes(p.id))
const capturedAssignments = Object.entries(state.assignments).flatMap(([dayId, as]) =>
as.filter(a => a.place?.id != null && targetIds.includes(a.place.id)).map(a => ({ dayId: Number(dayId), placeId: a.place!.id, orderIndex: a.order_index }))
)
try {
await tripActions.deletePlacesMany(tripId, targetIds)
if (selectedPlaceId != null && targetIds.includes(selectedPlaceId)) setSelectedPlaceId(null)
if (!ids) setDeletePlaceIds(null)
updateRouteForDay(selectedDayId)
toast.success(t('trip.toast.placesDeleted', { count: capturedPlaces.length }))
if (capturedPlaces.length > 0) {
pushUndo(t('undo.deletePlaces'), async () => {
for (const place of capturedPlaces) {
const newPlace = await tripActions.addPlace(tripId, {
name: place.name, description: place.description,
lat: place.lat, lng: place.lng, address: place.address,
category_id: place.category_id, icon: place.icon, price: place.price,
})
for (const a of capturedAssignments.filter(x => x.placeId === place.id)) {
await tripActions.assignPlaceToDay(tripId, a.dayId, newPlace.id, a.orderIndex)
}
}
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [deletePlaceIds, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
const handleAssignToDay = useCallback(async (placeId, dayId, position) => {
const target = dayId || selectedDayId
@@ -406,6 +572,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
const capturedOrderIndex = capturedAssignment?.order_index ?? 0
try {
await tripActions.removeAssignment(tripId, dayId, assignmentId)
updateRouteForDay(dayId)
if (capturedPlaceId != null) {
const capturedDayId = dayId
const capturedPos = capturedOrderIndex
@@ -429,17 +596,11 @@ export default function TripPlannerPage(): React.ReactElement | null {
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
})
})
.catch(() => {})
// Update route immediately from orderedIds
const dayItems = useTripStore.getState().assignments[String(dayId)] || []
const ordered = orderedIds.map(id => dayItems.find(a => a.id === id)).filter(Boolean)
const waypoints = ordered.map(a => a.place).filter(p => p?.lat && p?.lng)
if (waypoints.length >= 2) setRoute(waypoints.map(p => [p.lat, p.lng]))
else setRoute(null)
setRouteInfo(null)
.catch(err => toast.error(err instanceof Error ? err.message : t('trip.toast.reorderError')))
updateRouteForDay(dayId)
}
catch { toast.error(t('trip.toast.reorderError')) }
}, [tripId, toast, pushUndo])
}, [tripId, toast, pushUndo, updateRouteForDay])
const handleUpdateDayTitle = useCallback(async (dayId, title) => {
try { await tripActions.updateDayTitle(tripId, dayId, title) }
@@ -469,6 +630,21 @@ export default function TripPlannerPage(): React.ReactElement | null {
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
const handleSaveTransport = async (data) => {
try {
if (editingTransport) {
await tripActions.updateReservation(tripId, editingTransport.id, data)
toast.success(t('trip.toast.reservationUpdated'))
} else {
await tripActions.addReservation(tripId, data)
toast.success(t('trip.toast.reservationAdded'))
}
setShowTransportModal(false)
setEditingTransport(null)
setTransportModalDayId(null)
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
const handleDeleteReservation = async (id) => {
try {
await tripActions.deleteReservation(tripId, id)
@@ -573,34 +749,17 @@ export default function TripPlannerPage(): React.ReactElement | null {
WebkitBackdropFilter: 'blur(16px)',
borderBottom: '1px solid var(--border-faint)',
height: 44,
overflowX: 'auto', scrollbarWidth: 'none', msOverflowStyle: 'none',
gap: 2,
}}>
{TRIP_TABS.map(tab => {
const isActive = activeTab === tab.id
const TabIcon = tab.icon
return (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
title={tab.label}
style={{
flexShrink: 0,
padding: '5px 14px', borderRadius: 20, border: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: isActive ? 600 : 400,
background: isActive ? 'var(--accent)' : 'transparent',
color: isActive ? 'var(--accent-text)' : 'var(--text-muted)',
fontFamily: 'inherit', transition: 'all 0.15s',
display: 'flex', alignItems: 'center', gap: 5,
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = isActive ? 'var(--accent-text)' : 'var(--text-primary)' }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = isActive ? 'var(--accent-text)' : 'var(--text-muted)' }}
>
{TabIcon && <><TabIcon size={20} className="sm:hidden" /><TabIcon size={15} className="hidden sm:block" /></>}
<span className="hidden sm:inline">{tab.shortLabel || tab.label}</span>
</button>
)
})}
<SlidingTabs
tabs={TRIP_TABS.map(tab => ({
id: tab.id,
label: <span className="hidden sm:inline">{tab.shortLabel || tab.label}</span>,
title: tab.label,
icon: tab.icon,
}))}
activeTab={activeTab}
onChange={handleTabChange}
/>
</div>
{/* Offset by navbar + tab bar (44px) */}
@@ -626,6 +785,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
rightWidth={rightCollapsed ? 0 : rightWidth}
hasInspector={!!selectedPlace}
hasDayDetail={!!showDayDetail && !selectedPlace}
reservations={reservations}
showReservationStats={settings.route_calculation !== false}
visibleConnectionIds={visibleConnections}
onReservationClick={(rid) => {
const r = reservations.find(x => x.id === rid)
if (r) setMapTransportDetail(r)
}}
/>
@@ -670,9 +836,16 @@ export default function TripPlannerPage(): React.ReactElement | null {
onReorder={handleReorder}
onUpdateDayTitle={handleUpdateDayTitle}
onAssignToDay={handleAssignToDay}
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }}
onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }}
reservations={reservations}
visibleConnectionIds={visibleConnections}
onToggleConnection={toggleConnection}
externalTransportDetail={mapTransportDetail}
onExternalTransportDetailHandled={() => setMapTransportDetail(null)}
onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true) }}
onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true) } : undefined}
onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true) } : undefined}
onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true) } : undefined}
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }}
onRemoveAssignment={handleRemoveAssignment}
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
@@ -684,6 +857,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
canUndo={canUndo}
lastActionLabel={lastActionLabel}
onUndo={handleUndo}
onRouteRefresh={() => { if (selectedDayId) updateRouteForDay(selectedDayId) }}
onAddBookingToAssignment={can('day_edit', trip) ? (dayId, assignmentId) => { tripActions.setSelectedDay(dayId); setBookingForAssignmentId(assignmentId); setEditingReservation(null); setShowReservationModal(true) } : undefined}
/>
{!leftCollapsed && (
<div
@@ -743,6 +918,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
onAssignToDay={handleAssignToDay}
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
onCategoryFilterChange={setMapCategoryFilter}
onPlacesFilterChange={setMapPlacesFilter}
pushUndo={pushUndo}
@@ -838,7 +1014,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
)}
{selectedPlace && isMobile && ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', background: 'rgba(0,0,0,0.3)' }} onClick={() => setSelectedPlaceId(null)}>
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', background: 'rgba(0,0,0,0.3)', paddingBottom: 'var(--bottom-nav-h)' }} onClick={() => setSelectedPlaceId(null)}>
<div style={{ width: '100%', maxHeight: '85vh' }} onClick={e => e.stopPropagation()}>
<PlaceInspector
place={selectedPlace}
@@ -900,8 +1076,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
}
</div>
</div>
@@ -911,11 +1087,29 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div>
)}
{activeTab === 'buchungen' && (
<div style={{ height: '100%', maxWidth: 1800, margin: '0 auto', width: '100%', display: 'flex', flexDirection: 'column', overflowY: 'auto', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
{activeTab === 'transports' && (
<div style={{ height: '100%', width: '100%', display: 'flex', flexDirection: 'column', overflowY: 'auto', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
<ReservationsPanel
tripId={tripId}
reservations={reservations}
reservations={reservations.filter(r => TRANSPORT_TYPES.has(r.type))}
days={days}
assignments={assignments}
files={files}
onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }}
onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }}
onDelete={handleDeleteReservation}
onNavigateToFiles={() => handleTabChange('dateien')}
titleKey="transport.title"
addManualKey="transport.addManual"
/>
</div>
)}
{activeTab === 'buchungen' && (
<div style={{ height: '100%', width: '100%', display: 'flex', flexDirection: 'column', overflowY: 'auto', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
<ReservationsPanel
tripId={tripId}
reservations={reservations.filter(r => !TRANSPORT_TYPES.has(r.type))}
days={days}
assignments={assignments}
files={files}
@@ -928,13 +1122,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
)}
{activeTab === 'listen' && (
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1800, margin: '0 auto', width: '100%', padding: '8px 0', paddingBottom: 'calc(var(--bottom-nav-h) + 8px)' }}>
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', width: '100%', paddingBottom: 'var(--bottom-nav-h)' }}>
<ListsContainer tripId={tripId} packingItems={packingItems} todoItems={todoItems} />
</div>
)}
{activeTab === 'finanzplan' && (
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1800, margin: '0 auto', width: '100%', padding: '8px 0', paddingBottom: 'calc(var(--bottom-nav-h) + 8px)' }}>
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', width: '100%', paddingBottom: 'var(--bottom-nav-h)' }}>
<BudgetPanel tripId={tripId} tripMembers={tripMembers} />
</div>
)}
@@ -966,7 +1160,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} />
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} />}
<ConfirmDialog
isOpen={!!deletePlaceId}
onClose={() => setDeletePlaceId(null)}
@@ -974,6 +1169,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
title={t('common.delete')}
message={t('trip.confirm.deletePlace')}
/>
<ConfirmDialog
isOpen={!!deletePlaceIds?.length}
onClose={() => setDeletePlaceIds(null)}
onConfirm={confirmDeletePlaces}
title={t('common.delete')}
message={t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })}
/>
</div>
)
}
+42 -11
View File
@@ -92,7 +92,7 @@ export default function VacayPage(): React.ReactElement {
<div className="grid grid-cols-4 gap-1">
{years.map(y => (
<div key={y} onClick={() => setSelectedYear(y)}
className="group relative py-1.5 rounded-lg text-xs font-medium transition-all text-center cursor-pointer"
className="group relative py-1.5 rounded-lg text-xs font-medium transition-[background-color,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] text-center cursor-pointer"
style={{
background: y === selectedYear ? 'var(--text-primary)' : 'var(--bg-secondary)',
color: y === selectedYear ? 'var(--bg-card)' : 'var(--text-muted)',
@@ -138,19 +138,15 @@ export default function VacayPage(): React.ReactElement {
<div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="max-w-[1800px] mx-auto px-3 sm:px-4 py-4 sm:py-6">
{/* Header */}
<div className="flex items-center justify-between mb-4 sm:mb-5">
{/* Mobile + tablet header (filter toggle lives here) */}
<div className="lg:hidden flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
<CalendarDays size={18} style={{ color: 'var(--text-primary)' }} />
</div>
<div>
<h1 className="text-lg sm:text-xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.catalog.vacay.name')}</h1>
<p className="text-xs hidden sm:block" style={{ color: 'var(--text-muted)' }}>{t('vacay.subtitle')}</p>
</div>
<h1 className="text-lg font-bold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.catalog.vacay.name')}</h1>
</div>
<div className="flex items-center gap-2">
{/* Mobile sidebar toggle */}
<button
onClick={() => setShowMobileSidebar(true)}
className="lg:hidden flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors"
@@ -164,11 +160,46 @@ export default function VacayPage(): React.ReactElement {
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
>
<Settings size={14} />
<span className="hidden sm:inline">{t('vacay.settings')}</span>
</button>
</div>
</div>
{/* Desktop header — unified toolbar (sidebar is always visible at this width) */}
<div className="hidden lg: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('admin.addons.catalog.vacay.name')}
</h2>
<div style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>
{t('vacay.subtitle')}
</span>
<div style={{ display: 'inline-flex', gap: 6, alignItems: 'center', marginLeft: 'auto', flexShrink: 0 }}>
<button
onClick={() => setShowSettings(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,
transition: 'opacity 0.15s ease',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
<Settings size={14} strokeWidth={2.5} /> {t('vacay.settings')}
</button>
</div>
</div>
</div>
{/* Main layout */}
<div className="flex gap-4 items-start">
{/* Desktop Sidebar */}
@@ -231,8 +262,8 @@ export default function VacayPage(): React.ReactElement {
<div className="fixed inset-0 flex items-center justify-center px-4"
style={{ zIndex: 99995, backgroundColor: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(8px)' }}>
{incomingInvites.map(inv => (
<div key={inv.id} className="w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
style={{ background: 'var(--bg-card)', animation: 'modalIn 0.25s ease-out' }}>
<div key={inv.id} className="trek-modal-enter w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
style={{ background: 'var(--bg-card)' }}>
<div className="px-6 pt-6 pb-4 text-center">
<div className="w-14 h-14 rounded-full mx-auto mb-4 flex items-center justify-center text-lg font-bold"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
+22
View File
@@ -84,4 +84,26 @@ export const placeRepo = {
offlineDb.places.delete(Number(id))
return result
},
async deleteMany(tripId: number | string, ids: number[]): Promise<unknown> {
if (!navigator.onLine) {
await offlineDb.places.bulkDelete(ids)
for (const id of ids) {
const mutId = generateUUID()
await mutationQueue.enqueue({
id: mutId,
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/places/${id}`,
body: undefined,
resource: 'places',
entityId: id,
})
}
return { deleted: ids, count: ids.length }
}
const result = await placesApi.bulkDelete(tripId, ids)
await offlineDb.places.bulkDelete(ids)
return result
},
}
+65 -27
View File
@@ -12,6 +12,29 @@ const listeners = new Map<string, Set<(entry: PhotoEntry) => void>>()
// Separate thumb listeners — called when thumbDataUrl becomes available after initial load
const thumbListeners = new Map<string, Set<(thumb: string) => void>>()
// Concurrency limiter — at most N photo API requests in flight at once.
// Prevents flooding the server (and external APIs it calls) when many places appear at once.
const MAX_CONCURRENT = 5
let activeRequests = 0
const requestQueue: Array<() => void> = []
function acquireRequestSlot(): Promise<void> {
if (activeRequests < MAX_CONCURRENT) {
activeRequests++
return Promise.resolve()
}
return new Promise(resolve => requestQueue.push(resolve))
}
function releaseRequestSlot(): void {
const next = requestQueue.shift()
if (next) {
next()
} else {
activeRequests--
}
}
function notify(key: string, entry: PhotoEntry) {
listeners.get(key)?.forEach(fn => fn(entry))
listeners.delete(key)
@@ -85,38 +108,53 @@ export function fetchPhoto(
return
}
// If photoId is already our stable proxy URL, use it directly — no API round-trip needed
if (photoId && photoId.startsWith('/api/maps/place-photo/')) {
const entry: PhotoEntry = { photoUrl: photoId, thumbDataUrl: null }
cache.set(cacheKey, entry)
callback?.(entry)
notify(cacheKey, entry)
// Generate base64 thumb in background
urlToBase64(photoId).then(thumb => {
if (thumb) { entry.thumbDataUrl = thumb; notifyThumb(cacheKey, thumb) }
})
return
}
inFlight.add(cacheKey)
mapsApi.placePhoto(photoId, lat, lng, name)
.then(async (data: { photoUrl?: string }) => {
const photoUrl = data.photoUrl || null
if (!photoUrl) {
acquireRequestSlot().then(() =>
mapsApi.placePhoto(photoId, lat, lng, name)
.then(async (data: { photoUrl?: string }) => {
const photoUrl = data.photoUrl || null
if (!photoUrl) {
const entry: PhotoEntry = { photoUrl: null, thumbDataUrl: null }
cache.set(cacheKey, entry)
callback?.(entry)
notify(cacheKey, entry)
return
}
// Store URL first — sidebar can show immediately
const entry: PhotoEntry = { photoUrl, thumbDataUrl: null }
cache.set(cacheKey, entry)
callback?.(entry)
notify(cacheKey, entry)
// Generate base64 thumb in background
const thumb = await urlToBase64(photoUrl)
if (thumb) {
entry.thumbDataUrl = thumb
notifyThumb(cacheKey, thumb)
}
})
.catch(() => {
const entry: PhotoEntry = { photoUrl: null, thumbDataUrl: null }
cache.set(cacheKey, entry)
callback?.(entry)
notify(cacheKey, entry)
return
}
// Store URL first — sidebar can show immediately
const entry: PhotoEntry = { photoUrl, thumbDataUrl: null }
cache.set(cacheKey, entry)
callback?.(entry)
notify(cacheKey, entry)
// Generate base64 thumb in background
const thumb = await urlToBase64(photoUrl)
if (thumb) {
entry.thumbDataUrl = thumb
notifyThumb(cacheKey, thumb)
}
})
.catch(() => {
const entry: PhotoEntry = { photoUrl: null, thumbDataUrl: null }
cache.set(cacheKey, entry)
callback?.(entry)
notify(cacheKey, entry)
})
.finally(() => { inFlight.delete(cacheKey) })
})
.finally(() => { inFlight.delete(cacheKey); releaseRequestSlot() })
)
}
export function getAllThumbs(): Record<string, string> {
+25
View File
@@ -0,0 +1,25 @@
import { weatherApi } from '../api/client'
const MAX_CONCURRENT = 3
let active = 0
const queue: Array<() => void> = []
function acquire(): Promise<void> {
if (active < MAX_CONCURRENT) { active++; return Promise.resolve() }
return new Promise(resolve => queue.push(resolve))
}
function release(): void {
const next = queue.shift()
if (next) next()
else active--
}
export async function fetchWeather(lat: number, lng: number, date: string) {
await acquire()
try {
return await weatherApi.get(lat, lng, date)
} finally {
release()
}
}
+12
View File
@@ -33,6 +33,9 @@ interface AuthState {
/** Server policy: all users must enable MFA */
appRequireMfa: boolean
tripRemindersEnabled: boolean
placesPhotosEnabled: boolean
placesAutocompleteEnabled: boolean
placesDetailsEnabled: boolean
login: (email: string, password: string) => Promise<LoginResult>
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
@@ -53,6 +56,9 @@ interface AuthState {
setServerTimezone: (tz: string) => void
setAppRequireMfa: (val: boolean) => void
setTripRemindersEnabled: (val: boolean) => void
setPlacesPhotosEnabled: (val: boolean) => void
setPlacesAutocompleteEnabled: (val: boolean) => void
setPlacesDetailsEnabled: (val: boolean) => void
demoLogin: () => Promise<AuthResponse>
}
@@ -74,6 +80,9 @@ export const useAuthStore = create<AuthState>()(
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
appRequireMfa: false,
tripRemindersEnabled: false,
placesPhotosEnabled: true,
placesAutocompleteEnabled: true,
placesDetailsEnabled: true,
login: async (email: string, password: string) => {
authSequence++
@@ -257,6 +266,9 @@ export const useAuthStore = create<AuthState>()(
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
setPlacesPhotosEnabled: (val: boolean) => set({ placesPhotosEnabled: val }),
setPlacesAutocompleteEnabled: (val: boolean) => set({ placesAutocompleteEnabled: val }),
setPlacesDetailsEnabled: (val: boolean) => set({ placesDetailsEnabled: val }),
demoLogin: async () => {
authSequence++
+2 -2
View File
@@ -8,7 +8,7 @@ export interface Journey {
subtitle?: string | null
cover_gradient?: string | null
cover_image?: string | null
status: 'draft' | 'active' | 'completed'
status: 'draft' | 'active' | 'completed' | 'archived'
created_at: number
updated_at: number
}
@@ -81,7 +81,7 @@ export interface JourneyDetail extends Journey {
entries: JourneyEntry[]
trips: JourneyTrip[]
contributors: JourneyContributor[]
stats: { entries: number; photos: number; cities: number }
stats: { entries: number; photos: number; places: number }
hide_skeletons?: boolean
}
+25
View File
@@ -12,6 +12,7 @@ export interface PlacesSlice {
addPlace: (tripId: number | string, placeData: Partial<Place>) => Promise<Place>
updatePlace: (tripId: number | string, placeId: number, placeData: Partial<Place>) => Promise<Place>
deletePlace: (tripId: number | string, placeId: number) => Promise<void>
deletePlacesMany: (tripId: number | string, placeIds: number[]) => Promise<void>
}
export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => ({
@@ -80,4 +81,28 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
throw new Error(getApiErrorMessage(err, 'Error deleting place'))
}
},
deletePlacesMany: async (tripId, placeIds) => {
if (placeIds.length === 0) return
try {
await placeRepo.deleteMany(tripId, placeIds)
const idSet = new Set(placeIds)
set(state => {
const updatedAssignments = { ...state.assignments }
let changed = false
for (const [dayId, items] of Object.entries(state.assignments)) {
if (items.some((a: Assignment) => a.place?.id != null && idSet.has(a.place.id))) {
updatedAssignments[dayId] = items.filter((a: Assignment) => !idSet.has(a.place?.id!))
changed = true
}
}
return {
places: state.places.filter(p => !idSet.has(p.id)),
...(changed ? { assignments: updatedAssignments } : {}),
}
})
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error deleting places'))
}
},
})
+18
View File
@@ -137,6 +137,20 @@ export interface BudgetMember {
paid: boolean
}
export interface ReservationEndpoint {
id?: number
reservation_id?: number
role: 'from' | 'to' | 'stop'
sequence: number
name: string
code: string | null
lat: number
lng: number
timezone: string | null
local_time: string | null
local_date: string | null
}
export interface Reservation {
id: number
trip_id: number
@@ -153,11 +167,14 @@ export interface Reservation {
notes: string | null
url: string | null
day_id?: number | null
end_day_id?: number | null
place_id?: number | null
assignment_id?: number | null
accommodation_id?: number | null
day_plan_position?: number | null
metadata?: Record<string, string> | string | null
needs_review?: number
endpoints?: ReservationEndpoint[]
created_at: string
}
@@ -196,6 +213,7 @@ export interface Settings {
show_place_description: boolean
route_calculation?: boolean
blur_booking_codes?: boolean
map_booking_labels?: boolean
}
export interface AssignmentsMap {
+32
View File
@@ -0,0 +1,32 @@
export type JourneyLifecycle = 'archived' | 'live' | 'upcoming' | 'completed' | 'draft'
export function computeJourneyLifecycle(
status: string,
tripDateMin: string | null | undefined,
tripDateMax: string | null | undefined,
): JourneyLifecycle {
if (status === 'archived') return 'archived'
if (tripDateMin && tripDateMax) {
const today = new Date().toISOString().split('T')[0]
if (tripDateMin <= today && today <= tripDateMax) return 'live'
if (tripDateMin > today) return 'upcoming'
return 'completed'
}
if (!tripDateMin && !tripDateMax) {
return 'draft'
}
// Single boundary: only start or only end
if (tripDateMin && !tripDateMax) {
const today = new Date().toISOString().split('T')[0]
return tripDateMin > today ? 'upcoming' : 'live'
}
if (!tripDateMin && tripDateMax) {
const today = new Date().toISOString().split('T')[0]
return tripDateMax < today ? 'completed' : 'live'
}
return 'completed'
}
@@ -2,6 +2,7 @@ import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useRouteCalculation } from '../../../src/hooks/useRouteCalculation';
import { useSettingsStore } from '../../../src/store/settingsStore';
import { useTripStore } from '../../../src/store/tripStore';
import { buildAssignment, buildPlace } from '../../helpers/factories';
import type { TripStoreState } from '../../../src/store/tripStore';
import type { RouteSegment } from '../../../src/types';
@@ -17,6 +18,10 @@ vi.mock('../../../src/components/Map/RouteCalculator', () => ({
const { calculateSegments } = await import('../../../src/components/Map/RouteCalculator');
function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssignment>[]> = {}): Partial<TripStoreState> {
// Also populate the real Zustand store so updateRouteForDay (which reads from
// useTripStore.getState()) sees the same assignments as the hook's tripStore param.
// Reset reservations and days to empty so transport-split logic doesn't interfere.
useTripStore.setState({ assignments, reservations: [], days: [] } as any);
return { assignments } as Partial<TripStoreState>;
}
@@ -35,6 +40,8 @@ describe('useRouteCalculation', () => {
vi.clearAllMocks();
// Default: route_calculation disabled
useSettingsStore.setState({ settings: { route_calculation: false } as any });
// Reset trip store assignments so each test starts clean
useTripStore.setState({ assignments: {} } as any);
(calculateSegments as ReturnType<typeof vi.fn>).mockResolvedValue(MOCK_SEGMENTS);
});
@@ -71,9 +78,9 @@ describe('useRouteCalculation', () => {
);
await act(async () => {});
// route is an array of segments; no transport → single segment with all places
expect(result.current.route).toEqual([
[p1.lat, p1.lng],
[p2.lat, p2.lng],
[[p1.lat, p1.lng], [p2.lat, p2.lng]],
]);
});
@@ -133,8 +140,7 @@ describe('useRouteCalculation', () => {
// After sort: a2 (order_index=0) first, then a1 (order_index=1)
expect(result.current.route).toEqual([
[p2.lat, p2.lng],
[p1.lat, p1.lng],
[[p2.lat, p2.lng], [p1.lat, p1.lng]],
]);
});
@@ -266,7 +272,7 @@ describe('useRouteCalculation', () => {
expect(result.current.setRouteInfo).toBeTypeOf('function');
});
it('FE-HOOK-ROUTE-013: hook uses tripStoreRef — late store updates reflected correctly', async () => {
it('FE-HOOK-ROUTE-013: route recalculates when assignments change via store update', async () => {
useSettingsStore.setState({ settings: { route_calculation: true } as any });
const p1 = buildPlace({ lat: 10, lng: 10 });
@@ -283,14 +289,13 @@ describe('useRouteCalculation', () => {
await act(async () => {});
expect(result.current.route).toEqual([
[p1.lat, p1.lng],
[p2.lat, p2.lng],
[[p1.lat, p1.lng], [p2.lat, p2.lng]],
]);
// Now add a third place
// Now add a third place — update both the local store object and the Zustand store
const p3 = buildPlace({ lat: 30, lng: 30 });
const a3 = buildAssignment({ day_id: 5, order_index: 2, place: p3 });
storeData = buildMockStore({ '5': [a1, a2, a3] });
storeData = buildMockStore({ '5': [a1, a2, a3] }); // also calls useTripStore.setState
await act(async () => {
rerender();
@@ -299,9 +304,7 @@ describe('useRouteCalculation', () => {
await act(async () => {});
expect(result.current.route).toEqual([
[p1.lat, p1.lng],
[p2.lat, p2.lng],
[p3.lat, p3.lng],
[[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]],
]);
});
});
+9
View File
@@ -25,6 +25,15 @@ afterAll(() => server.close());
// ── jsdom stubs ────────────────────────────────────────────────────────────────
// Force en-US locale for toLocaleDateString so tests are deterministic on
// non-US dev machines (Windows-de-DE returns "Sonntag" instead of "Sunday").
// Only affects calls without an explicit locale — callers that pass a locale
// keep their behavior.
const _origToLocaleDateString = Date.prototype.toLocaleDateString
Date.prototype.toLocaleDateString = function (locales?: Intl.LocalesArgument, options?: Intl.DateTimeFormatOptions) {
return _origToLocaleDateString.call(this, locales ?? 'en-US', options)
}
// window.matchMedia — used by dark mode / responsive components
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -134,6 +134,8 @@ describe('fetchPhoto — in-flight deduplication', () => {
svc.fetchPhoto('k', 'pid', undefined, undefined, undefined, cb1);
svc.fetchPhoto('k', 'pid', undefined, undefined, undefined, cb2);
// acquireRequestSlot() is async (Promise.resolve), so flush microtasks before asserting
await flush();
expect(mockPlacePhoto).toHaveBeenCalledTimes(1);
resolve({ photoUrl: 'https://example.com/photo.jpg' });

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