Compare commits

..

30 Commits

Author SHA1 Message Date
Maurice 247433fb2a feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile (#1106)
* fix(journey): authorize reads of the journey share link

GET /api/journeys/:id/share-link now requires journey access (canAccessJourney),
matching the create/delete share-link routes and the get_journey_share_link MCP
tool. Returns no link when the caller lacks access to the journey.

* feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile

Renames the Budget addon to "Costs" (UI only) and reworks it into a Tricount/
Splitwise-style cost tracker: multiple payers per expense, equal split across
chosen members, settle-up with persisted history + undo, 12 fixed categories,
per-expense currency with live FX conversion to a user-set display currency
(Settings -> Display), and locale-correct money formatting. Adds a desktop and a
dedicated mobile layout. A migration backfills existing budget items (single
payer, split members, currency). Closes #551 (per-expense currency).

Also switches the app font to self-hosted Poppins (Geist for secondary subtext),
replacing the Google Fonts CDN dependency.

* fix(costs): neutral dashboard dark palette + liquid glass, full page width, entry-count badge

- Dark mode used a warm oklch palette that read brownish; switch to the
  neutral zinc tokens used by the dashboard (#121215 bg, #f4f4f5 ink) and add a
  subtle backdrop-blur glass on cards.
- Costs now uses the full available page width on desktop instead of a 1280px cap.
- Render the expense count next to the Expenses title as a badge.
- Adapt budget/journey unit tests to the new payer-based settlement model and the
  Costs rename (category default 'other', Costs tab/CostsPanel).

* fix(costs): drop the entry-count badge, always show row edit/delete actions

Removes the count badge next to the Expenses title and makes the per-row
edit/delete actions permanently visible (no longer hover-only) on desktop too.

* feat(costs): currency-native money formatting, custom select/date, rename addon to Costs

- Format every amount in its own currency convention (symbol position, grouping
  and decimal separators) regardless of app language, via a currency->locale map
  (EUR -> '12,00 €', USD -> '$12.00', JPY -> '¥12', ...). Previously Intl used the
  app locale, so EUR showed the symbol in front under an English UI.
- Use TREK's CustomSelect (searchable, with symbols) and CustomDatePicker in the
  add/edit expense modal instead of the native <select>/<input type=date>.
- Rename the 'Budget Planner' add-on to 'Costs' in the admin list (display only;
  id/tables/permissions/MCP stay 'budget') via seed + a migration for existing DBs.

* feat(auth): configurable session duration via SESSION_DURATION

Adds a SESSION_DURATION env var (ms-style strings: 1h, 7d, 30d, ...) controlling
how long a session stays valid before re-login. It drives both the trek_session
JWT exp claim and the cookie maxAge from one source, so they never drift. Invalid
values warn at startup and fall back to the default (24h — unchanged). The MFA
challenge token and MCP OAuth tokens keep their own TTL.

Implements the request from discussion #946. Documented in the env-var wiki page,
.env.example and docker-compose.yml.
2026-06-05 01:38:25 +02:00
jubnl 6ef3c7ae6b feat(reservations): native booking-confirmation import via KDE KItinerary (#1102)
* feat(reservations): native booking-confirmation import via KDE KItinerary

Adds a two-step preview → confirm flow for importing booking emails,
PDFs, PKPass and HTML confirmations. The server invokes the KDE
kitinerary-extractor binary, maps JSON-LD schema.org output to TREK
reservation shapes, and persists via the existing createReservation
pipeline (accommodations, budget, places, WebSocket broadcasts).

- NestJS BookingImportModule: preview + confirm endpoints under
  /api/trips/:tripId/reservations/import/booking{,/confirm}
- KitineraryExtractorService: spawns the binary, filters stderr noise,
  handles QDateTime (@value) timezone-aware datetimes
- kitinerary-mapper: FlightReservation, TrainReservation, BusReservation,
  BoatReservation, LodgingReservation, FoodEstablishmentReservation,
  RentalCarReservation, EventReservation → typed preview items
- BookingImportService: auto-creates place rows; geocodes venues without
  coordinates via Nominatim (name+address → address → name fallback);
  resolves day IDs for accommodation linking
- BookingImportModal: drag-and-drop multi-file upload, preview cards
  with type icons, per-item exclude toggle, confirm step
- Shared Zod contracts: BookingImportPreviewItem, PreviewResponse,
  ConfirmRequest, ConfirmResponse — consumed by controller, service,
  API client and modal
- Dockerfile: node:24-trixie-slim runtime; amd64 downloads KDE static
  binary + locales; arm64 installs libkitinerary-bin + symlinks to
  fixed path; ENV KITINERARY_EXTRACTOR_PATH set for both arches
- /api/health/features exposes { bookingImport: boolean } so the UI
  hides the Import button when the binary is absent
- i18n keys (English), wiki docs, API.md, README one-liner

* i18n: add booking import translations for all 19 non-English locales

Adds 17 reservations.import.* keys and undo.importBooking to ar, br, cs,
de, es, fr, gr, hu, id, it, ja, ko, nl, pl, ru, tr, uk, zh, zh-TW.

* chore: enforce i18n parity

* docs(wiki): add KItinerary local setup instructions to dev environment guide
2026-06-04 20:40:57 +02:00
Maurice abe1c549bd feat(transport): add bus, taxi, bicycle, ferry and other transport types (#1105)
Closes #718. Adds five new transport reservation types alongside the
existing flight/train/car/cruise: bus, taxi, bicycle, ferry and a generic
'transport_other' catch-all. The new types are treated as first-class
transports everywhere — the transport modal, day plan, route calculation,
map overlays, file grouping and the PDF export — and are translated across
all 20 locales.

A dedicated 'transport_other' value is used for the catch-all so existing
'other' bookings are not reclassified as transport.
2026-06-04 20:39:11 +02:00
jubnl 10bea35a91 fix(journey): raise PhotoLightbox z-index above MobileEntryView (#1101) 2026-06-03 12:53:45 +02:00
Larinel a77ee4b4d5 fix(pwa): removed orientation from the manifest (#1058) 2026-06-01 22:08:43 +02:00
Maurice 9bec97fc19 Fix a batch of reported bugs: Atlas regions, planner overlays, imports, Safari modals (#1094)
* Start the Journey date picker week on Monday (#1078)

The Journey entry date picker started the week on Sunday (firstDow = getDay(), headers Su-first) while every other picker (CustomDateTimePicker, VacayCalendar) starts on Monday. Align it: Monday-first leading offset ((getDay()+6)%7) and Mo-first weekday headers.

* Fix Taiwan resolving to CN-TW in the Atlas country search (#1049)

natural-earth gives Taiwan ISO_A2='CN-TW' (a subdivision-style value) with ADM0_A3='TWN'. The dynamic A2_TO_A3 augmentation added 'CN-TW'->'TWN', which then overwrote the legitimate TWN->TW entry in the reverse map, so Taiwan's country option resolved to 'CN-TW' — unresolvable by Intl.DisplayNames (no name, broken flag, not searchable). Only augment A2_TO_A3 with real 2-letter codes.

* Drop empty leftover dateless days when a trip gets a shorter dated range (#1083)

generateDays kept all unused dateless placeholder days after switching to an explicit (shorter) date range, so day_count (COUNT(*) FROM days) stayed inflated. Delete the empty leftovers (no assignments/notes/accommodations) like the dateless path already does, while preserving any that still hold content. Adds TRIP-SVC-017.

* Render GPX and route overlays once the Mapbox style has loaded (#1036)

The GPX and route geojson effects ran before the map 'load' event had
attached their sources, so on the first paint they hit the early return
and never re-ran. Add mapReady to their dependencies so they fire again
the moment the sources exist.

* Convert HEIC trip and journey covers to JPEG before upload (#1085)

HEIC/HEIF covers coming straight off an iPhone could not be rendered in
the preview or stored as a usable image. Route both cover pickers through
normalizeImageFile, the same conversion the journal entry editor already
uses, so the file becomes a JPEG before it leaves the browser.

* Name GPX routes and tracks after their source file so multiple imports stick (#1054)

Unnamed routes and tracks all fell back to the same generic 'GPX Route' /
'GPX Track' label, so the name-based import dedup dropped every one after
the first - importing several files (or one file with several tracks) only
kept a single place. Derive the default name from the source filename with
an index suffix when a file holds more than one geometry, thread the
filename down through the controller, and let the import modal take more
than one file at a time. Adds PLACE-SVC-037/038.

* Namespace the modal backdrop class so content blockers stop hiding it (#1027)

Generic class names like .modal-backdrop sit on the cosmetic filter lists
that content blockers (1Blocker, EasyList Annoyances) ship, and get hidden
with display:none. The shared Modal - used by New Trip and Add Place -
carried that class, so Safari users running such a blocker saw the modal
silently fail to open with no error and no network request. Rename it to
.trek-modal-backdrop.

* Highlight GB regions by resolving England/Scotland/Wales/NI to finer admin-1 codes (#1067)

A zoom-8 reverse geocode of a UK place only resolves to the constituent
country (GB-ENG/SCT/WLS/NIR), but Natural Earth's admin-1 polygons for GB
are counties and boroughs (GB-LND, GB-MAN, GB-CON, ...). Those four codes
match no polygon, so places in England never highlighted in the Atlas
while CH/IT/NL/etc. worked. When a GB lookup lands on a constituent
country, re-resolve it at a finer zoom where Nominatim exposes the
county/borough code the polygons actually carry. Other countries keep the
exact zoom-8 behaviour. Adds ATLAS-UNIT-021.

* Surface the real place-search error instead of a generic toast (#1092)

When a place search or detail lookup fails, the backend already forwards the
upstream reason - including descriptive Google Places API messages such as
'Places API (New) has not been used in project ... or it is disabled'. The
planner discarded it and always showed 'Place search failed', so a key that
is mis-enabled, unbilled, or pointed at the legacy API instead of Places API
(New) looked like an unexplained silent failure. Show the server-provided
message when present, and stop the Atlas bucket-list search from swallowing
its error without a trace.

* Await the async cover normalization in the TripFormModal paste test (#1085)

handleCoverSelect now normalizes the pasted file before previewing it, so
URL.createObjectURL is called a microtask later. The assertion moves into
waitFor; a non-HEIC file still passes through unchanged.
2026-05-31 23:28:16 +02:00
Maurice 20791a29a7 Migrate TREK 3 to NestJS + React 19 (shared Zod contracts) (#1087)
* Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer

Brownfield strangler migration of the backend onto NestJS modules
(auth, trips, days, places, assignments, packing, todo, budget,
reservations, collab, files, photos, journey, share, settings, backup,
oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories,
tags, notifications, system-notices) served through a per-prefix
dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT
httpOnly cookie auth, with behavioural parity for every route.

Client: React 19 upgrade, "page = wiring container + data hook"
pattern across all pages, per-domain Zustand stores bound to
@trek/shared contracts, and decomposition of the large components
(DayPlanSidebar, PackingListPanel, CollabNotes, FileManager,
MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal,
BudgetPanel, PlaceFormModal, ...) into focused render units backed by
in-file hooks.

Apply the shared global request pipeline (helmet/CSP, CORS, HSTS,
forced HTTPS, the global MFA policy and request logging) to the NestJS
instance as well, so a migrated route is protected identically to the
legacy fallback rather than bypassing it.

* Finish the NestJS migration — drop the legacy Express app

NestJS now serves the whole surface: every /api domain plus the platform
routes (uploads, /mcp, the OAuth/MCP SDK + /.well-known metadata and the
production SPA fallback). Removed server/src/app.ts, all of
server/src/routes/* and the strangler dispatcher; index.ts and the
integration suite share a single buildApp() bootstrap so prod and tests
can't drift.

- Platform/transport routes extracted to nest/platform/platform.routes.ts
  and mounted before app.init() — Nest's router answers an unmatched
  request with a 404, so a route registered after init is never reached.
  The SPA fallback is a NotFoundException filter and the catch-all uses a
  RegExp (Express 5's path-to-regexp rejects a bare '*').
- New modules: memories (/api/integrations/memories — the Journey
  gallery's Immich/Synology proxy), addons (GET /api/addons) and the
  cross-trip GET /api/reservations/upcoming.
- TrekExceptionFilter reproduces the old multer / err.statusCode handling
  so upload rejections keep their 400/413 { error } body and non-ASCII
  filenames survive (defParamCharset).
- addTripToJourney and the MCP get_journey_share_link tool gained the
  trip-access check they were missing.
- Re-pointed the 34 integration tests + the websocket test onto the Nest
  app; removed the now-meaningless Express-vs-Nest parity tests and a few
  orphaned client components.

* Restore the reset-password rate limit and fix copyTrip reservation links

Two correctness/security gaps the NestJS migration introduced:

- POST /api/auth/reset-password lost its per-IP rate limiter. Restore it
  (5 attempts / 15 min on a dedicated bucket, same as the old resetLimiter)
  so reset tokens can't be brute-forced unthrottled. Covered by AUTH-019.
- copyTripById did not copy reservations.end_day_id (a day reference — now
  remapped through dayMap like day_id) or needs_review, so a duplicated trip
  lost multi-day transport end-day links and reset the review flag.

* Clean up dead code, dedupe helpers, fix the reset-password contract

- Remove server exports orphaned by the Express removal: the immich
  album-link helpers, seven route-only service exports, getFileByIdFull;
  de-export internal-only helpers (utcSuffix).
- De-duplicate verifyTripAccess (9 identical copies -> services/tripAccess.ts)
  and avatarUrl (3 -> services/avatarUrl.ts); name the bcrypt cost
  (BCRYPT_COST) and the email regex (EMAIL_REGEX). Public API unchanged.
- resetPasswordRequestSchema declared `password`, but the client sends and
  the service reads `new_password` — rename it so the contract matches and
  the client types resolve.
- Make ATLAS-013 deterministic: stub the admin-1 GeoJSON download instead of
  fetching ~4600 features from GitHub during the test (it hung the suite).

* Make the client typecheck runnable (vitest/vite ambient types)

The client had no `typecheck` script and tsc couldn't even start (the
baseUrl deprecation errored out, same as server/shared already silence).
Add `ignoreDeprecations: "6.0"` to match the other workspaces, a `typecheck`
npm script, and a src/vite-env.d.ts referencing vite/client + vitest/globals
so tsc knows the test globals (describe/it/expect/vi). This turns ~3600
phantom "Cannot find name" errors into a real, measurable count (~590 actual
type errors remain, to be worked down). Type-only; no runtime change.

* Derive client domain types from the shared schema contracts

Add entity/response Zod schemas to @trek/shared (place, trip, assignment, day, budget, packing, reservation), each matched against the producing server service, and re-export them from client types.ts instead of the hand-written duplicates that had drifted (name/title, amount/total_price, owner_id/user_id, cover_url/cover_image, ...). Updates the call sites and test fixtures the corrected types surfaced; type-only, no runtime behaviour change.

* chore(db): log swallowed errors in addon-disable migration + guard against destructive migrations

The migration that disables the legacy "memories" addon swallowed any
error in an empty catch, as did ~30 other catch blocks in the migration
runner (column adds, the journey rebuild, index probes). Replace each
silent catch with the existing console.warn('[migrations] ...') log so
failures are visible. Control flow is unchanged: every step stays
non-fatal, nothing new is thrown.

Add a static guardrail test that scans the migration source and fails
when a new destructive statement (DROP TABLE / DROP COLUMN / TRUNCATE /
DELETE FROM / ALTER ... DROP) appears outside a reviewed allowlist, and
when an empty/silent catch block is reintroduced. The existing
destructive statements are all legitimate table rebuilds or
bounded cleanups and are recorded in the allowlist with a reason.

* Re-check SSRF on every redirect hop when resolving short links

Replace the one-shot checkSsrf + fetch(redirect:'follow') in the maps and place short-link resolvers with safeFetchFollow, which follows redirects manually and re-runs checkSsrf against the DNS-pinned IP of each hop (max 5). A redirect to an internal/loopback address is now blocked even when the initial URL is public, while legitimate cross-host redirects (goo.gl -> maps.google.com) still resolve.

* Reject WebSocket tokens minted before a password change

Stamp the user's password_version onto the ephemeral ws token and verify it on connect, closing the socket (4001) when it no longer matches, so a token issued before a password reset can't be replayed. Tokens minted without a version are treated as version 0, matching the JWT pv-claim semantics.

* fix(i18n): guard locale key parity and finish the OAuth consent page strings

Every non-en locale now exposes the exact same flat key set as en. Keys that
had drifted out of sync are backfilled with the English source value (tagged
en-fallback) so t() resolves a real string instead of relying on the silent
runtime fallback; no existing translation was touched and no key was removed.

Add a parity test that imports each aggregated locale bundle and asserts its
key set matches en, with a diagnostic listing of any missing/extra keys. This
complements the file-level check in shared/scripts by guarding the merged
export the app actually serves.

Finish internationalising OAuthAuthorizePage: the ~15 remaining hardcoded
English chrome strings now go through oauth.authorize.* keys (English source
in en, en-fallback placeholders elsewhere). Markup and behaviour are unchanged.

* Add semantic theme color tokens to Tailwind

Map the CSS theme variables from src/index.css (:root light / .dark dark) to named Tailwind utilities — bg-surface, text-content, border-edge, bg-accent and their variants. This gives components a Tailwind-native target for the theme colors so we can replace inline `style={{ ... 'var(--...)' }}` with utility classes without changing the rendered values.

* Surface silent store failures to the user and validate API responses in dev

Reservation toggle, todo/packing toggle and budget reorder were swallowing API errors after rolling back, so the user saw the change silently snap back with no explanation. Route those failures through the existing toast channel (new store/notify.ts bridges to window.__addToast, the same channel SystemNoticeBanner uses); the reservation toggle re-throws so ReservationsPanel's own translated toast finally fires. Also wire the existing parseInDev/checkInDev response validation into the maps and notification-test endpoints to catch contract drift in dev.

* Migrate static theme inline styles to Tailwind utilities and extract page sub-components

Replace the static, color-only inline `style={{ ... 'var(--bg-primary)' ... }}` props with the new semantic Tailwind utilities (bg-surface, text-content, border-edge, ...) wherever the result is byte-identical; dynamic/conditional theme styles and hardcoded status colors are left inline. Extract the Atlas country-search autocomplete, the Admin update banner, and two Journey dialogs into their own presentational components to shrink the oversized page files, keeping behaviour and markup identical.

* Remove the unrouted photos page and its dead photo components

PhotosPage was never wired into the router and its usePhotos hook read a tripStore photos slice that was never implemented; the Photos gallery, lightbox and upload components were only reachable through it. Per-trip photos now live in the Journey gallery (Immich/Synology). Removed the dead page, hook and components — the live Journey PhotoLightbox is a separate component and stays.

* Resolve the remaining client type errors and the trip.title navbar bug

Drive the client typecheck to zero without any/ts-ignore: convert the tripId route param to a number once at the page boundary so it matches the numeric props and store actions it feeds, fix trip.name -> trip.title (the wire field is title, so the old read rendered blank in the files/offline views), and tighten the scattered handler-arity, DOM-cast and untyped-payload sites. No runtime behaviour change.

* Convert the remaining dynamic and hardcoded inline styles to Tailwind utilities

Second styling pass over the components and pages: move conditional theme colors into className ternaries (bg-accent / bg-surface-hover etc.), turn reused CSSProperties constants into className constants, and express static hardcoded hex/rgba colors as Tailwind arbitrary values so the exact rendered colour is preserved. Truly dynamic styling (computed geometry, gradients, multi-part shadows, data-driven colours, the undefined --sidebar/--nav layout vars) stays inline as it cannot be expressed as a static class. Updated three component tests that asserted the old inline active-state styles to assert the equivalent utility class instead.

Verified: client typecheck 0, full client suite green, and a live light/dark render check in the dev server confirms the semantic theme tokens resolve correctly (the earlier 'transparent popups' were a stale dev server that pre-dated the tailwind.config token addition, not a code issue).

* Add eslint flat-config for client and server and gate typecheck, lint and pages in CI

client and server had lint scripts but no eslint config (only shared was linted in CI). Add flat configs mirroring shared's stack (js + typescript-eslint recommended + eslint-config-prettier) plus the client's react-hooks/react-refresh plugins. Pre-existing patterns in this never-linted code (explicit any, require() in the CommonJS server, empty catches, exhaustive-deps) are set to 'warn' rather than 'error' so the gate passes at 0 errors without a repo-wide reformat — these can be ratcheted to errors over time. Wire blocking typecheck + lint + lint:pages steps into the client and server CI jobs (now that both typechecks are clean) and promote the server typecheck from informational to blocking.

* Decompose the remaining God Components into hooks, helpers and sub-components

FE6: split the oversized page and panel components into thin layout shells plus colocated use<Component> hooks, .constants.ts, .helpers.ts (with tests) and presentational sub-components, following the established 'logic in a hook, render in slices' pattern. Behaviour, markup, classes and effect order are unchanged. Largest reductions: PackingListPanel 1598->42, FileManager 1055->36, AdminPage 1525->167, BudgetPanel 1266->146, JourneyDetailPage 2822->547, PlacesSidebar 945->66, CollabChat 861->106, CollabNotes 1417->532. DayPlanSidebar's drag-and-drop render body was left intact (ref-identity sensitive) and only its toolbar/modals/constants were extracted.

* Fix duplicate React keys in the file-assign place list

When a place is assigned to the same day more than once it appeared twice in a day's list, so the place-button key={p.id} collided and React warned about duplicate keys. Key by place id + render index so siblings stay unique. Pre-existing in the old FileManager; behaviour unchanged.

* Format the shared package and drop an unused import to satisfy the lint gate

The i18n and schema changes added code that wasn't prettier-formatted, and place.schema.ts imported categorySchema without using it. Run prettier over shared and remove the import so 'npm run lint' + 'format:check' pass.

* Install all workspaces in the server CI job so SWC's native binary is present

The server vitest config transforms via unplugin-swc, which needs @swc/core's platform-specific native binary. A workspace-scoped 'npm ci --workspace server' skips that optional dependency, so vitest failed to load the config on the Linux runner. Use a full 'npm ci'.

* Re-resolve dependencies with npm install in the server CI job for SWC

Full 'npm ci' still skipped @swc/core's Linux native binary because the committed lockfile was generated on Windows and lacks the Linux optional-dep install metadata. 'npm install' re-resolves and fetches the platform-matching binary, which the server's unplugin-swc transform needs to load vitest.config.ts.

* Install @swc/core's Linux binary explicitly in the server CI job

Neither npm ci nor npm install fetched @swc/core-linux-x64-gnu on the Linux runner because the lockfile was generated on Windows and lacks the Linux optional-dep metadata. Add a step that installs the matching @swc/core-linux-x64-gnu version (no-save, no-lockfile) so unplugin-swc can load the server's vitest config.

* Use legacy-peer-deps when installing the SWC Linux binary in CI

The explicit @swc/core-linux-x64-gnu install re-resolved the tree and hit the pre-existing lucide-react/react-19 peer conflict that the lockfile was generated around. Add --legacy-peer-deps so the step matches the project's resolution and installs the binary.

* Keep the lockfile when installing the SWC binary so other deps stay pinned

Dropping --no-package-lock made npm re-resolve the whole tree and upgrade eslint, whose newer recommended config flagged no-useless-assignment as an error in the server lint step. Keep the lockfile so only @swc/core-linux-x64-gnu is added and every other dependency (incl. eslint) stays at its locked version.
2026-05-31 21:10:00 +02:00
Maurice 6d2dd37414 feat(dashboard): mobile layout, glass UI, context bottom nav + OIDC PKCE (#1079)
* feat(dashboard): mobile layout, glass tiles, plain-text countdown, place photos

- Rework the mobile dashboard: cover hero, separate boarding-pass card,
  trimmed atlas (trips + days only), stacked widgets
- New floating bottom tab bar with a centred context-aware + button
  (new trip / place / journey / entry depending on the page)
- Move profile + notifications into a small top strip on the dashboard
- Desktop: glassmorphic tiles (light + dark), neutral dark palette,
  plain-text countdown module, real place photos in the boarding pass

* i18n(dashboard): translate new dashboard keys across all locales

Fill the dashboard-rework keys (hero, atlas, fx, tz, upcoming, copy
dialog, aria labels, countdown) that were left as English placeholders,
plus the new startsIn/aria keys, for all 19 languages.

* feat(oidc): send PKCE (S256) in the OIDC login flow

The OIDC client now generates a code_verifier per login, sends the
S256 code_challenge on the authorize request and the code_verifier on
the token exchange. Works whether the provider has PKCE optional or
required (fixes login against providers that require PKCE, e.g. Pocket ID).
2026-05-27 23:19:03 +02:00
jufy111 0d2657ee37 feat: Updated border of map markers to reflect category color. (#1062) 2026-05-27 22:54:41 +02:00
Julien G. 0a8fb1f53b Merge branch 'feat/dashboard-rework' into dev 2026-05-27 17:53:46 +02:00
jubnl 2fe6657edd chore: enforce prettier & lint on shared package 2026-05-27 17:42:23 +02:00
jubnl 5f964b9524 chore: prettier + lint 2026-05-27 17:35:10 +02:00
Ahmet Yılmaz 8bda980028 i18n: complete Turkish (tr) translation (#1075)
Fill in the remaining ~2100 UI strings in shared/src/i18n/tr so Turkish
matches the English catalog. Brand names, URLs, and technical placeholders
are left untranslated by design.
2026-05-27 17:31:37 +02:00
Dimitris Kafetzis 831a4fd478 feat(i18n): add Greek translation (#1061) 2026-05-27 17:31:03 +02:00
Maurice 4ff4435f8b refactor(dashboard): replace hardcoded strings with i18n keys
Hero, atlas row, trip cards, filters, currency and timezone widgets now resolve all visible copy through t() instead of hardcoded English/German.
2026-05-26 23:25:51 +02:00
Maurice 69b699c9bf i18n(dashboard): sync all locales to one key set + German copy-dialog strings
Brings every locale's dashboard namespace to the same 149-key set (missing keys backfilled from English) and translates the previously English-only copy-trip dialog into German.
2026-05-26 23:25:50 +02:00
Maurice 98032fda0c feat(dashboard): boarding-pass hero, atlas row, live widgets + modal portal fix
Reworked dashboard layout: boarding-pass hero with hover + days-left countdown, atlas stats row with real flags, searchable currency widget, editable timezone widget, new-trip FAB. Modals now portal to document.body to avoid inheriting dashboard-scoped button/font styles.
2026-05-26 23:12:08 +02:00
Maurice e04ceeb1ee i18n(dashboard): dashboard keys across locales 2026-05-26 23:12:08 +02:00
Maurice e5000ff7dd feat(dashboard): upcoming reservations endpoint + travel-stats country/distance
Adds GET /api/reservations/upcoming for the dashboard widget, switches travel-stats to the same country source as Atlas (manual + place-derived, ISO codes), and a distance service for flown km.
2026-05-26 23:12:07 +02:00
Julien G. 126f2df21b chore: move i18n to shared package (#1066)
* chore: move i18n to shared package

* chore: move server translations to shared package and apply linter and prettier on entire shared package
2026-05-26 20:27:29 +02:00
Maurice 324d930ca3 remove route_calculation setting, always use OSRM routing (#1064)
The per-user route_calculation toggle was a second, hidden on/off layer
on top of the day footer's show-route button, and made it easy to end up
with straight-line routes for no obvious reason. Drop the setting
entirely: routing is always on, the footer toggle stays the single
switch. Old stored values are simply ignored (settings are key-value, no
migration needed).
2026-05-26 16:21:10 +02:00
Maurice e050814c42 feat(planner): real road routes (OSRM) with travel-time connectors (#1060)
* feat(planner): real road routes (OSRM) with travel-time connectors

Replace the straight-line "as the crow flies" route with real OSRM road
geometry (FOSSGIS routed-car/-foot) and an Apple-Maps style render
(blue casing under a lighter core) on both the Leaflet and Mapbox GL
maps. Routes are off by default and toggled per session, with a
driving/walking mode switch in the day footer.

Each day shows per-segment travel time/distance connectors between
places, computed from the OSRM legs and split at transport bookings.

Also redesigns the day header for visual consistency: vertical
number+weather capsule, name with a divider before the date, subtle
hotel/rental pills that stay on one line, and a hover-revealed 2x2
action square (edit / add transport / add note / collapse). Drops the
Google Maps button.

* test(planner): update route hook tests for calculateRouteWithLegs
2026-05-25 22:27:49 +02:00
Julien G. c130ed41be chore: fix monorepo build pipeline and migrate shared to built package (#1056)
* chore: fix monorepo build pipeline and migrate shared to built package

- Root package.json: add workspace scripts (dev, build, test, test:cov, test:e2e)
  that delegate to actual scripts in shared/server/client workspaces
- shared: add tsup build step (CJS + ESM dual output, .d.ts); consumers now import
  from the built dist instead of raw TS source via path aliases
- server: replace tsc-alias with tsconfig-paths (tsc-alias mangled node_modules
  paths); fix MCP SDK path aliases to point to root node_modules (../node_modules)
- server/scripts/dev.mjs: delay node --watch until tsc -w signals first-pass done,
  eliminating the spurious restart on every dev startup
- client/vite.config.js + vitest.config.ts: remove @trek/shared path alias (no longer
  needed now that shared is a proper package)
- Consolidate package-lock.json at the workspace root; drop per-workspace lock files

* chore: fix test script to reflect root package.json

* chore: add missing lint and prettier script in root package.json

* fix(ci): build shared before tests; fix vitest MCP SDK alias paths

vitest.config.ts aliases pointed at ./node_modules/ (server-local) but
packages are hoisted to the root node_modules/ in the npm workspace —
changed to ../node_modules/.

CI jobs now install and build shared before running server/client tests
so that @trek/shared's dist/ exists when vitest resolves the package.

* fix(docker): update Dockerfile and CI for monorepo workspace structure

Dockerfile:
- Add shared-builder stage that produces @trek/shared dist before
  client and server stages need it
- Each build stage carries root package.json + package-lock.json so npm
  can resolve @trek/shared as a workspace dependency
- Production stage installs via workspace context (npm ci --workspace=server
  --omit=dev) so node_modules/@trek/shared symlinks to shared/dist correctly
- Copy server/tsconfig.json into the image so tsconfig-paths/register can
  find the MCP SDK path aliases at runtime
- CMD cds into /app/server before starting node so tsconfig-paths baseUrl
  resolves and ../node_modules points to /app/node_modules
- Remove mkdir for /app/server (now a real dir); keep symlinks for uploads/data

docker.yml version-bump:
- Replace manual per-workspace cd+npm-version calls with single:
  npm version --workspaces --include-workspace-root --no-git-tag-version
  (mirrors the version:* scripts in root package.json)
- git add now references root package-lock.json; adds shared/package.json

.dockerignore: add shared/dist
package.json: fix version:prerelease preid (alpha → pre)

* fix(tests): use in-memory SQLite per worker in test mode

vitest pool:forks spawns parallel worker processes that all called
initDb() on the same data/travel.db, causing SQLite "database is locked"
and "duplicate column name" races.

When NODE_ENV=test each fork now gets an isolated :memory: DB so migrations
run independently with no file contention.

* chore(ci): add ACT guards to skip DockerHub steps in local act runs

act sets ACT=true automatically. Guards added:
- docker login: if: ${{ !env.ACT }}
- build outputs: type=docker (local load) when ACT, push-by-digest when CI
- digest export/upload: if: ${{ !env.ACT }}
- merge job: if: ${{ !env.ACT }}
- release-helm job (docker.yml): if: ${{ !env.ACT }}
- version-bump git push (docker.yml): wrapped in [ -z "$ACT" ] shell guard

Run locally with:
  ./bin/act -j build -W .github/workflows/docker.yml \
    -P ubuntu-latest=catthehacker/ubuntu:act-latest

* fix(ci): move ACT guards to step level; add guards to security.yml

env context is invalid in job-level if conditions — moved all ACT
guards down to individual steps. Also guards docker login + scout
in security.yml so act can run the build-only part of that workflow.

* fix(ci): skip git fetch and tag logic in act (no remote access in local containers)

* Revert "fix(ci): skip git fetch and tag logic in act (no remote access in local containers)"

This reverts commit 67cf290cda.

* Revert "fix(ci): move ACT guards to step level; add guards to security.yml"

This reverts commit f92b95e054.

* Revert "chore(ci): add ACT guards to skip DockerHub steps in local act runs"

This reverts commit 797183de08.

* fix(docker): add musl optional deps so alpine builds find native rollup/sharp binaries

npm prunes libc-constrained optional deps to the host libc (glibc) when
generating the lockfile, leaving no musl entry for Alpine containers.
Declaring the x64/arm64 musl variants as explicit root optionalDependencies
forces them into the lockfile so npm ci on Alpine can install them.

Covers shared-builder (tsup/rollup) and client-builder (vite/rollup + sharp
icon generation) for both linux/amd64 and linux/arm64 CI targets.

* fix(docker): copy client dist into server/public so the server resolves static files correctly

The server runs from /app/server and serves static files relative to that
directory, so the client build output must land at /app/server/public, not /app/public.
2026-05-25 21:44:58 +02:00
Maurice db5c403239 i18n: register Korean + add Ukrainian translation (#1055)
Korean translation by @ppuassi (#977) — now registered. Ukrainian by @JeffyOLOLO (#902) — lifted onto a clean branch. Both at full en.ts key parity (2258 keys).
2026-05-25 18:37:15 +02:00
SkyLostTR bd29fcb0c0 Add Turkish (tr) translation + language registry (#1029)
Turkish translation by @SkyLostTR, at full en.ts key parity, registered in supportedLanguages + TranslationContext.
2026-05-25 18:26:29 +02:00
sss3978 be71cae0d3 feat(i18n): add Japanese (ja) translation (#829)
Japanese translation by @soma3978, at full en.ts key parity, registered in supportedLanguages + TranslationContext.
2026-05-25 18:22:39 +02:00
ppuassi ee2089e81d feat(i18n): add Korean (ko) translation (#977)
Korean translation by @ppuassi, topped up to full en.ts key parity. Language registration follows separately.
2026-05-25 18:22:35 +02:00
gzor 352f94612d fix(packing): multiply item weight by quantity in bag/total weight calcs (#898)
Quantity now counts toward bag and total weights. Generalised to an itemWeight() helper used by every weight sum (bag totals + max, unassigned, grand total; sidebar + bag modal) with unit tests.
2026-05-25 17:59:54 +02:00
Maurice 0257e4e71e feat(weather): migrate /api/weather to the NestJS pilot module (L1) (#1053)
First strangler migration (L1): /api/weather is served by a NestJS module.

- @trek/shared/weather Zod contract; Nest controller byte-identical to the legacy Express route (paths, query params, status codes, { error } bodies, lang default, ApiError/500 passthrough). Service reuses getWeather/getDetailedWeather (+ shared cache; MCP tools unchanged).
- Strangler routes /api/weather to Nest by default; the legacy Express route + its migration-time parity test were decommissioned in this PR.
- Frontend (FE2): weatherApi typed against the @trek/shared WeatherResult contract.
- Harness: reusable Nest-vs-Express parity harness, e2e harness (temp SQLite + seed/cookie helpers, real JwtAuthGuard), src/nest coverage gate raised to >=80%, src/nest test guide.
- Verified end-to-end on a prod mirror (dev1): 401/400/200 via Nest with real Open-Meteo data, Express route gone.
2026-05-25 17:00:58 +02:00
Maurice 0b218d53b2 Phase 0 — NestJS + Zod foundation harness (F1–F8) (#1050)
Co-hosted NestJS app behind the existing Express server via a strangler-fig dispatcher, sharing the same better-sqlite3 connection and JWT httpOnly cookie. Additive and dormant: default routing stays on Express, Nest only serves its own /api/_nest diagnostics until a module opts in.

F1 @trek/shared Zod contract package; F2 Nest bootstrap co-hosted (fall-through, single Dockerfile/port); F3 shared better-sqlite3 provider; F4 JWT cookie auth guard (+ @CurrentUser, admin guard); F5 Zod validation pipe + error-envelope parity; F6 Nest test + coverage gates; F7 per-prefix strangler toggle (env, default Express); F8 CI build/typecheck/test/coverage.

Remaining F4/F6/F8 checklist items (trip-access + permission levels + MFA policy, e2e harness/seed + 80% gate, Nest↔Express parity test, Playwright PR-comment workflow) are tracked on the first consuming module cards (L1/A1/C1).
2026-05-25 14:29:30 +02:00
1254 changed files with 16899 additions and 53253 deletions
+1 -1
View File
@@ -30,8 +30,8 @@ Thumbs.db
sonar-project.properties sonar-project.properties
server/tests/ server/tests/
server/vitest.config.ts server/vitest.config.ts
server/reset-admin.js
**/*.test.ts **/*.test.ts
**/*.spec.ts
wiki/ wiki/
scripts/ scripts/
charts/ charts/
-1
View File
@@ -34,5 +34,4 @@ jobs:
command: cves command: cves
image: trek:scan image: trek:scan
only-severities: critical,high only-severities: critical,high
only-fixed: true
exit-code: true exit-code: true
+1 -3
View File
@@ -65,6 +65,4 @@ coverage
test-data test-data
.run .run
.full-review .full-review
# Wiki offline snapshot is baked in at build, not committed (duplicates wiki/)
server/assets/wiki/
+16 -28
View File
@@ -1,10 +1,3 @@
# ── Stage 0: gosu ────────────────────────────────────────────────────────────
# Rebuild gosu with a current Go toolchain so the runtime image ships no stale
# Go stdlib (Debian's apt gosu is built with an old Go that trips CVE scanners).
# The binary and its runtime behaviour are identical to the apt package.
FROM golang:1.25-alpine AS gosu-build
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@latest
# ── Stage 1: shared ────────────────────────────────────────────────────────── # ── Stage 1: shared ──────────────────────────────────────────────────────────
FROM node:24-alpine AS shared-builder FROM node:24-alpine AS shared-builder
WORKDIR /app WORKDIR /app
@@ -46,18 +39,27 @@ COPY package.json package-lock.json ./
COPY shared/package.json ./shared/ COPY shared/package.json ./shared/
COPY server/package.json ./server/ COPY server/package.json ./server/
# better-sqlite3 native addon requires build tools (purged after compile).
# kitinerary-extractor for booking-confirmation import:
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
# arm64 — apt package (KDE publishes no arm64 static binary)
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \ apt-get install -y --no-install-recommends tzdata dumb-init gosu wget ca-certificates python3 build-essential && \
libkitinerary-bin && \
npm ci --workspace=server --omit=dev && \ npm ci --workspace=server --omit=dev && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \ ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then \
wget -qO /tmp/ki.tgz https://cdn.kde.org/ci-builds/pim/kitinerary/release-26.04/linux/kitinerary-extractor-x86_64-26.04.0.tgz && \
echo "b7058d98990053c7b61847fef0c21e02d59b60e323e2b171ca210b682334e801 /tmp/ki.tgz" | sha256sum -c && \
tar -xz -C /usr/local -f /tmp/ki.tgz bin/kitinerary-extractor share/locale && \
rm /tmp/ki.tgz; \
else \
apt-get install -y --no-install-recommends libkitinerary-bin && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
fi && \
apt-get purge -y python3 build-essential && \ apt-get purge -y python3 build-essential && \
apt-get autoremove -y && \ apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
ENV XDG_CACHE_HOME=/tmp/kf6-cache ENV XDG_CACHE_HOME=/tmp/kf6-cache
# Prevent Qt from probing for a display in headless containers. # Prevent Qt from probing for a display in headless containers.
ENV QT_QPA_PLATFORM=offscreen ENV QT_QPA_PLATFORM=offscreen
@@ -66,19 +68,8 @@ ENV QT_QPA_PLATFORM=offscreen
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
COPY --from=server-builder /app/server/dist ./server/dist COPY --from=server-builder /app/server/dist ./server/dist
# Runtime data assets read from server/assets at runtime: airports.json (flight
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
# only emits dist, so these must be copied explicitly or the features silently
# degrade to empty in the image.
COPY --from=server-builder /app/server/assets ./server/assets
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths. # tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
COPY server/tsconfig.json ./server/ COPY server/tsconfig.json ./server/
# Encryption-key rotation is run on demand via tsx (a prod dep) straight from the
# raw .ts source — it never enters dist, so it must be copied in explicitly or
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
# Admin recovery script (node server/reset-admin.js) for locked-out installs.
COPY server/reset-admin.js ./server/reset-admin.js
COPY --from=shared-builder /app/shared/dist ./shared/dist COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY --from=client-builder /app/client/dist ./server/public COPY --from=client-builder /app/client/dist ./server/public
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
@@ -99,8 +90,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1 CMD wget -qO- http://localhost:3000/api/health || exit 1
ENTRYPOINT ["dumb-init", "--"] ENTRYPOINT ["dumb-init", "--"]
# Preflight: if the app code is missing, a volume was almost certainly mounted
# over /app (it hides the image's node_modules + dist). Fail with actionable
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly. # cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"] CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
-33
View File
@@ -1,33 +0,0 @@
# Third-party data & attributions
TREK bundles and uses third-party data that requires attribution.
## geoBoundaries — country & sub-national boundaries
The Atlas map's administrative boundaries (admin-0 countries and admin-1
provinces/counties), shipped at `server/assets/atlas/admin0.geojson.gz` and
`server/assets/atlas/admin1.geojson.gz` and generated by
`server/scripts/build-atlas-geo.mjs`, are derived from **geoBoundaries**.
> Runfola, D. et al. (2020) geoBoundaries: A global database of political
> administrative boundaries. PLoS ONE 15(4): e0231866.
> https://doi.org/10.1371/journal.pone.0231866
geoBoundaries is licensed under **CC BY 4.0**
(https://creativecommons.org/licenses/by/4.0/). Source: https://www.geoboundaries.org/
The bundled files are simplified (coordinate-quantized) and re-tagged with the
property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
(gbOpen) release.
## OpenStreetMap — geocoding
Atlas reverse-geocodes places via the **Nominatim** service. Geocoding data is
© OpenStreetMap contributors, licensed under the Open Database License (ODbL).
https://www.openstreetmap.org/copyright
## OurAirports — airport reference data
`server/assets/airports.json` is built from **OurAirports**
(https://ourairports.com/data/), released into the public domain.
+12 -27
View File
@@ -51,10 +51,10 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a> <a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a> <a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a> <a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a> <a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a> <a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a> <a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Trip planner · day plan and route" width="49%" /></a> <a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a> <a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
</div> </div>
@@ -79,7 +79,6 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves - **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization - **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key) - **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering - **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
- **Route optimisation** — auto-sort places and export to Google Maps - **Route optimisation** — auto-sort places and export to Google Maps
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback - **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
@@ -91,7 +90,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### 🧳 Travel management #### 🧳 Travel management
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary)) - **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency - **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
- **Packing lists** — categories, templates, user assignment, progress tracking - **Packing lists** — categories, templates, user assignment, progress tracking
- **Bag tracking** — optional weight tracking with iOS-style distribution - **Bag tracking** — optional weight tracking with iOS-style distribution
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each) - **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
@@ -109,7 +108,6 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
- **Invite links** — one-time or reusable links with expiry - **Invite links** — one-time or reusable links with expiry
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider - **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
- **2FA** — TOTP + backup codes - **2FA** — TOTP + backup codes
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
- **Collab suite** — group chat, shared notes, polls, day check-ins - **Collab suite** — group chat, shared notes, polls, day check-ins
</td> </td>
@@ -130,13 +128,13 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### 🧩 Addons (admin-toggleable) #### 🧩 Addons (admin-toggleable)
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking - **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency - **Budget** — expense tracker with splits, pie chart, multi-currency
- **Documents** — file attachments on trips, places, and reservations - **Documents** — file attachments on trips, places, and reservations
- **Collab** — chat, notes, polls, day-by-day attendance - **Collab** — chat, notes, polls, day-by-day attendance
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking - **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI - **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods - **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations - **Naver List Import** — one-click import from shared Naver Maps lists
- **MCP** — expose TREK to AI assistants via OAuth 2.1 - **MCP** — expose TREK to AI assistants via OAuth 2.1
</td> </td>
@@ -158,9 +156,8 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### ⚙️ Admin & customisation #### ⚙️ Admin & customisation
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar - **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR - **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history - **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates - **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
</td> </td>
@@ -194,9 +191,9 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
<div align="center"> <div align="center">
![Node.js](https://img.shields.io/badge/Node.js_22-339933?style=flat-square&logo=node.js&logoColor=white) ![Node.js](https://img.shields.io/badge/Node.js_22-339933?style=flat-square&logo=node.js&logoColor=white)
![NestJS](https://img.shields.io/badge/NestJS_11-E0234E?style=flat-square&logo=nestjs&logoColor=white) ![Express](https://img.shields.io/badge/Express-000000?style=flat-square&logo=express&logoColor=white)
![SQLite](https://img.shields.io/badge/SQLite-003B57?style=flat-square&logo=sqlite&logoColor=white) ![SQLite](https://img.shields.io/badge/SQLite-003B57?style=flat-square&logo=sqlite&logoColor=white)
![React](https://img.shields.io/badge/React_19-61DAFB?style=flat-square&logo=react&logoColor=black) ![React](https://img.shields.io/badge/React_18-61DAFB?style=flat-square&logo=react&logoColor=black)
![Vite](https://img.shields.io/badge/Vite-646CFF?style=flat-square&logo=vite&logoColor=white) ![Vite](https://img.shields.io/badge/Vite-646CFF?style=flat-square&logo=vite&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white)
![Tailwind](https://img.shields.io/badge/Tailwind-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white) ![Tailwind](https://img.shields.io/badge/Tailwind-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white)
@@ -205,7 +202,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
</div> </div>
Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + Passkeys (WebAuthn) + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL. Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
<br /> <br />
@@ -266,7 +263,7 @@ Then:
docker compose up -d docker compose up -d
``` ```
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work. **HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
</details> </details>
@@ -314,9 +311,6 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it. Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
> [!IMPORTANT]
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
<h3>Rotating the Encryption Key</h3> <h3>Rotating the Encryption Key</h3>
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`): If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
@@ -403,14 +397,12 @@ Caddy handles TLS and WebSockets automatically.
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto | | `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` | | `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` | | `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` | | `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` | | `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` | | `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto | | `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` | | `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` | | `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — | | `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
| **OIDC / SSO** | | | | **OIDC / SSO** | | |
@@ -445,13 +437,6 @@ Caddy handles TLS and WebSockets automatically.
<br /> <br />
## Data sources
The Atlas map's country and sub-national (province/county) boundaries come from
[**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
for full third-party attributions.
## License ## License
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence. TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0"?>
<CommunityApplications>
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
<WebPage>https://github.com/mauriceboe/TREK</WebPage>
<Forum>https://github.com/mauriceboe/TREK/issues</Forum>
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
<DonateText>Support TREK development</DonateText>
</CommunityApplications>
+1 -1
View File
@@ -39,7 +39,7 @@ See `values.yaml` for more options.
## Notes ## Notes
- Ingress is off by default. Enable and configure hosts for your domain. - Ingress is off by default. Enable and configure hosts for your domain.
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class. - PVCs require a default StorageClass or specify one as needed.
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed. - `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC. - `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically. - If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 3.1.3 version: 3.0.22
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "3.1.3" appVersion: "3.0.22"
-12
View File
@@ -28,12 +28,6 @@ data:
{{- if .Values.env.COOKIE_SECURE }} {{- if .Values.env.COOKIE_SECURE }}
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }} COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
{{- end }} {{- end }}
{{- if .Values.env.SESSION_DURATION }}
SESSION_DURATION: {{ .Values.env.SESSION_DURATION | quote }}
{{- end }}
{{- if .Values.env.SESSION_DURATION_REMEMBER }}
SESSION_DURATION_REMEMBER: {{ .Values.env.SESSION_DURATION_REMEMBER | quote }}
{{- end }}
{{- if .Values.env.TRUST_PROXY }} {{- if .Values.env.TRUST_PROXY }}
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }} TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
{{- end }} {{- end }}
@@ -70,9 +64,3 @@ data:
{{- if .Values.env.MCP_RATE_LIMIT }} {{- if .Values.env.MCP_RATE_LIMIT }}
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }} MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
{{- end }} {{- end }}
{{- if .Values.env.OVERPASS_URL }}
OVERPASS_URL: {{ .Values.env.OVERPASS_URL | quote }}
{{- end }}
{{- if .Values.env.OVERPASS_TIMEOUT_MS }}
OVERPASS_TIMEOUT_MS: {{ .Values.env.OVERPASS_TIMEOUT_MS | quote }}
{{- end }}
-14
View File
@@ -5,16 +5,9 @@ metadata:
name: {{ include "trek.fullname" . }}-data name: {{ include "trek.fullname" . }}-data
labels: labels:
app: {{ include "trek.name" . }} app: {{ include "trek.name" . }}
{{- with .Values.persistence.data.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec: spec:
accessModes: accessModes:
- ReadWriteOnce - ReadWriteOnce
{{- with .Values.persistence.data.storageClassName }}
storageClassName: {{ . | quote }}
{{- end }}
resources: resources:
requests: requests:
storage: {{ .Values.persistence.data.size }} storage: {{ .Values.persistence.data.size }}
@@ -25,16 +18,9 @@ metadata:
name: {{ include "trek.fullname" . }}-uploads name: {{ include "trek.fullname" . }}-uploads
labels: labels:
app: {{ include "trek.name" . }} app: {{ include "trek.name" . }}
{{- with .Values.persistence.uploads.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec: spec:
accessModes: accessModes:
- ReadWriteOnce - ReadWriteOnce
{{- with .Values.persistence.uploads.storageClassName }}
storageClassName: {{ . | quote }}
{{- end }}
resources: resources:
requests: requests:
storage: {{ .Values.persistence.uploads.size }} storage: {{ .Values.persistence.uploads.size }}
-15
View File
@@ -34,10 +34,6 @@ env:
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP. # When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
# COOKIE_SECURE: "true" # COOKIE_SECURE: "true"
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production. # Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
# SESSION_DURATION: "24h"
# How long a login session stays valid when "Remember me" is unchecked (the default): trek_session JWT exp + a browser-session cookie. Accepts 1h, 12h, 7d, 30d, 90d. Defaults to 24h.
# SESSION_DURATION_REMEMBER: "30d"
# Session length when "Remember me" is ticked: a longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Defaults to 30d.
# TRUST_PROXY: "1" # TRUST_PROXY: "1"
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work. # Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
# ALLOW_INTERNAL_NETWORK: "false" # ALLOW_INTERNAL_NETWORK: "false"
@@ -67,12 +63,6 @@ env:
# Max MCP API requests per user per minute. Defaults to 300. # Max MCP API requests per user per minute. Defaults to 300.
# MCP_MAX_SESSION_PER_USER: "20" # MCP_MAX_SESSION_PER_USER: "20"
# Max concurrent MCP sessions per user. Defaults to 20. # Max concurrent MCP sessions per user. Defaults to 20.
# OVERPASS_URL: ""
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
# from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored.
# OVERPASS_TIMEOUT_MS: "12000"
# Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000.
# Secret environment variables stored in a Kubernetes Secret. # Secret environment variables stored in a Kubernetes Secret.
@@ -104,13 +94,8 @@ persistence:
enabled: true enabled: true
data: data:
size: 1Gi size: 1Gi
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
storageClassName: ""
annotations: {}
uploads: uploads:
size: 1Gi size: 1Gi
storageClassName: ""
annotations: {}
resources: resources:
requests: requests:
+1 -5
View File
@@ -5,10 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>TREK</title> <title>TREK</title>
<!-- Pre-paint appearance (FOUC fix). External classic script so it runs
before first paint AND complies with the prod CSP (script-src 'self'). -->
<script src="/theme-boot.js"></script>
<!-- PWA / iOS --> <!-- PWA / iOS -->
<meta name="theme-color" content="#09090b" /> <meta name="theme-color" content="#09090b" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
@@ -17,7 +13,7 @@
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" /> <link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" /> <link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
<!-- Fonts --> <!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
+6 -11
View File
@@ -1,6 +1,6 @@
{ {
"name": "@trek/client", "name": "@trek/client",
"version": "3.1.3", "version": "3.0.22",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -17,8 +17,6 @@
"lint": "eslint .", "lint": "eslint .",
"lint:check": "eslint .", "lint:check": "eslint .",
"lint:pages": "node scripts/check-page-pattern.mjs", "lint:pages": "node scripts/check-page-pattern.mjs",
"theme:lint": "node scripts/theme-lint.mjs",
"theme:lint:strict": "node scripts/theme-lint.mjs --strict",
"e2e": "playwright test", "e2e": "playwright test",
"e2e:report": "playwright show-report", "e2e:report": "playwright show-report",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"", "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
@@ -28,7 +26,6 @@
"@fontsource/geist-sans": "^5.2.5", "@fontsource/geist-sans": "^5.2.5",
"@fontsource/poppins": "^5.2.7", "@fontsource/poppins": "^5.2.7",
"@react-pdf/renderer": "^4.5.1", "@react-pdf/renderer": "^4.5.1",
"@simplewebauthn/browser": "^13.1.2",
"@trek/shared": "*", "@trek/shared": "*",
"axios": "^1.6.7", "axios": "^1.6.7",
"dexie": "^4.4.2", "dexie": "^4.4.2",
@@ -36,7 +33,6 @@
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0", "mapbox-gl": "^3.22.0",
"maplibre-gl": "^5.24.0",
"marked": "^18.0.0", "marked": "^18.0.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
@@ -61,12 +57,11 @@
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@trivago/prettier-plugin-sort-imports": "^6.0.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/leaflet": "^1.9.8", "@types/leaflet": "^1.9.8",
"@types/node": "^25.9.3",
"@types/react": "^19.2.15", "@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^4.1.9", "@vitest/coverage-v8": "^3.2.4",
"autoprefixer": "^10.4.18", "autoprefixer": "^10.4.18",
"eslint": "^10.2.1", "eslint": "^10.2.1",
"eslint-config-flat-gitignore": "^2.3.0", "eslint-config-flat-gitignore": "^2.3.0",
@@ -84,8 +79,8 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"typescript-eslint": "^8.58.2", "typescript-eslint": "^8.58.2",
"vite": "8.1.0", "vite": "^5.1.4",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^0.21.0",
"vitest": "^4.1.9" "vitest": "^3.2.4"
} }
} }
-58
View File
@@ -1,58 +0,0 @@
/*
* Pre-paint appearance boot — kills the flash of default/wrong theme (FOUC).
*
* Loaded as an external, render-blocking CLASSIC script in <head> (NOT a module)
* so it runs before first paint AND complies with the production CSP
* (script-src 'self'; inline scripts are blocked). It reads the compact snapshot
* written by client/src/theme/applyAppearance.ts and applies it verbatim. Keep
* this in sync with that module's snapshot shape + apply logic.
*
* It must never throw — any failure silently falls back to the default look.
*/
(function () {
try {
var raw = localStorage.getItem('trek_appearance');
if (!raw) return;
var s = JSON.parse(raw);
if (!s || s.v !== 1) return;
var root = document.documentElement;
var path = location.pathname;
var isShared = path.indexOf('/shared/') === 0 || path.indexOf('/public/') === 0;
var dark;
if (isShared) dark = false;
else if (s.darkMode === 'auto') dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
else dark = s.darkMode === true || s.darkMode === 'dark';
root.classList.toggle('dark', dark);
var scheme = isShared ? 'default' : s.scheme;
if (scheme && scheme !== 'default') root.setAttribute('data-scheme', scheme);
if (!isShared && s.noTransparency) root.setAttribute('data-no-transparency', '');
if (s.density === 'compact') root.setAttribute('data-density', 'compact');
if (s.reduceMotion) root.setAttribute('data-reduce-motion', '');
if (!isShared && scheme === 'custom' && s.accent) {
root.style.setProperty('--accent-custom-light', s.accent.light);
root.style.setProperty('--accent-custom-dark', s.accent.dark);
if (s.accentText) {
root.style.setProperty('--accent-custom-text-light', s.accentText.light);
root.style.setProperty('--accent-custom-text-dark', s.accentText.dark);
}
}
var ts = s.typeScale || {};
var fs = typeof s.fontScale === 'number' ? s.fontScale : 1;
setScale('--fs-scale-title', fs * (ts.title || 1));
setScale('--fs-scale-subtitle', fs * (ts.subtitle || 1));
setScale('--fs-scale-body', fs * (ts.body || 1));
setScale('--fs-scale-caption', fs * (ts.caption || 1));
if (fs !== 1) root.style.fontSize = fs * 100 + '%';
function setScale(name, v) {
if (typeof v === 'number' && v !== 1) root.style.setProperty(name, String(v));
}
} catch (e) {
/* never block boot */
}
})();
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env node
/*
* theme:lint — guards the appearance token system.
*
* Flags styling that bypasses the design tokens and therefore won't follow a
* user's chosen scheme / transparency / text-size:
* - inline color literals (color: '#111', background: 'rgba(...)', boxShadow: '...rgba...')
* - inline numeric fontSize (fontSize: 13)
* - arbitrary-value Tailwind color classes (bg-[#..], text-[rgba(..)])
*
* ALLOWED (never flagged): var(--token) inline styles, bg-[var(--..)] classes,
* and genuinely dynamic values (data-driven colors, computed sizes/positions).
*
* Mirrors the i18n:parity gate. Default mode reports a baseline and exits 0;
* `--strict` exits non-zero when any violations remain (for once the backlog is
* burned down, or wired to changed files only). Add `theme-lint-disable` in a
* line comment to suppress an intentional exception (map/PDF/brand colors).
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
let SRC = new URL('../src', import.meta.url).pathname;
if (process.platform === 'win32' && SRC.startsWith('/')) SRC = SRC.slice(1);
// Surfaces where CSS variables genuinely cannot reach (injected map HTML, WebGL
// paint, standalone PDF documents) — colors there must stay literal.
const EXEMPT = [
/Mapbox/i, /placePopup/i, /marker/i, /popup/i, /TripPDF/, /JourneyBookPDF/,
/MapViewGL/, /MapView\./, /JourneyMapGL/, /reservationsMapbox/, /useAtlas/,
/ReservationOverlay/, /\.test\./, /\.spec\./,
];
const ARB_CLASS = /\b(?:bg|text|border|ring|fill|stroke|from|via|to|shadow|outline|decoration|divide|caret)-\[\s*(?:#|rgba?\(|hsla?\(|oklch\()/;
const INLINE_COLOR = /(?:color|background|backgroundColor|borderColor|border|borderTop|borderBottom|borderLeft|borderRight|boxShadow|fill|stroke|outline|textDecorationColor)\s*:\s*['"`]?\s*(?:#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(|oklch\()/;
const INLINE_FONTSIZE = /fontSize\s*:\s*['"`]?\d/;
function walk(dir, files = []) {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) walk(p, files);
else if (/\.(ts|tsx)$/.test(name)) files.push(p);
}
return files;
}
const strict = process.argv.includes('--strict');
const offenders = [];
let total = 0;
for (const f of walk(SRC)) {
if (EXEMPT.some((re) => re.test(f))) continue;
let count = 0;
for (const line of readFileSync(f, 'utf8').split('\n')) {
if (line.includes('theme-lint-disable')) continue;
if (ARB_CLASS.test(line) || INLINE_COLOR.test(line) || INLINE_FONTSIZE.test(line)) count++;
}
if (count) {
offenders.push([relative(SRC, f).replace(/\\/g, '/'), count]);
total += count;
}
}
offenders.sort((a, b) => b[1] - a[1]);
console.log(`theme:lint — ${total} hardcoded-style hits across ${offenders.length} files (map/PDF excluded).`);
for (const [f, c] of offenders.slice(0, 20)) console.log(` ${String(c).padStart(4)} ${f}`);
if (offenders.length > 20) console.log(` … and ${offenders.length - 20} more files.`);
console.log('\nNew/changed code must use tokens (bg-surface / text-content / bg-accent / var(--..)) and the');
console.log('text-title/subtitle/body/caption tiers — never inline #hex, never bg-[#..]. See src/theme/README.md.');
if (strict && total > 0) {
console.error(`\n✖ theme:lint:strict — ${total} violations remain.`);
process.exit(1);
}
+20 -31
View File
@@ -2,7 +2,6 @@ import React, { useEffect, ReactNode } from 'react'
import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from './store/authStore' import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore' import { useSettingsStore } from './store/settingsStore'
import { applyAppearance } from './theme/applyAppearance'
import { useAddonStore } from './store/addonStore' import { useAddonStore } from './store/addonStore'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import ForgotPasswordPage from './pages/ForgotPasswordPage' import ForgotPasswordPage from './pages/ForgotPasswordPage'
@@ -13,7 +12,6 @@ import FilesPage from './pages/FilesPage'
import AdminPage from './pages/AdminPage' import AdminPage from './pages/AdminPage'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
import VacayPage from './pages/VacayPage' import VacayPage from './pages/VacayPage'
import HelpPage from './pages/HelpPage'
import AtlasPage from './pages/AtlasPage' import AtlasPage from './pages/AtlasPage'
import JourneyPage from './pages/JourneyPage' import JourneyPage from './pages/JourneyPage'
import JourneyDetailPage from './pages/JourneyDetailPage' import JourneyDetailPage from './pages/JourneyDetailPage'
@@ -22,7 +20,6 @@ import SharedTripPage from './pages/SharedTripPage'
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx' import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
import OAuthAuthorizePage from './pages/OAuthAuthorizePage' import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
import { ToastContainer } from './components/shared/Toast' import { ToastContainer } from './components/shared/Toast'
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget'
import BottomNav from './components/Layout/BottomNav' import BottomNav from './components/Layout/BottomNav'
import { TranslationProvider, useTranslation } from './i18n' import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client' import { authApi } from './api/client'
@@ -177,21 +174,30 @@ export default function App() {
const isSharedPage = location.pathname.startsWith('/shared/') const isSharedPage = location.pathname.startsWith('/shared/')
useEffect(() => { useEffect(() => {
const run = () => // Shared page always forces light mode
applyAppearance({ if (isSharedPage) {
darkMode: settings.dark_mode, document.documentElement.classList.remove('dark')
appearance: settings.appearance, const meta = document.querySelector('meta[name="theme-color"]')
isSharedPage, if (meta) meta.setAttribute('content', '#ffffff')
}) return
run() }
// Re-resolve on OS theme change while in auto mode.
if (!isSharedPage && settings.dark_mode === 'auto') { const mode = settings.dark_mode
const applyDark = (isDark: boolean) => {
document.documentElement.classList.toggle('dark', isDark)
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff')
}
if (mode === 'auto') {
const mq = window.matchMedia('(prefers-color-scheme: dark)') const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => run() applyDark(mq.matches)
const handler = (e: MediaQueryListEvent) => applyDark(e.matches)
mq.addEventListener('change', handler) mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler) return () => mq.removeEventListener('change', handler)
} }
}, [settings.dark_mode, settings.appearance, isSharedPage]) applyDark(mode === true || mode === 'dark')
}, [settings.dark_mode, isSharedPage])
const isAuthPage = location.pathname.startsWith('/login') const isAuthPage = location.pathname.startsWith('/login')
|| location.pathname.startsWith('/register') || location.pathname.startsWith('/register')
@@ -202,7 +208,6 @@ export default function App() {
<TranslationProvider> <TranslationProvider>
{!isAuthPage && <SystemNoticeHost />} {!isAuthPage && <SystemNoticeHost />}
<ToastContainer /> <ToastContainer />
{!isAuthPage && <BackgroundTasksWidget />}
<OfflineBanner /> <OfflineBanner />
<Routes> <Routes>
<Route path="/" element={<RootRedirect />} /> <Route path="/" element={<RootRedirect />} />
@@ -222,22 +227,6 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/help"
element={
<ProtectedRoute>
<HelpPage />
</ProtectedRoute>
}
/>
<Route
path="/help/:slug"
element={
<ProtectedRoute>
<HelpPage />
</ProtectedRoute>
}
/>
<Route <Route
path="/trips/:id" path="/trips/:id"
element={ element={
+17 -125
View File
@@ -18,7 +18,7 @@ import {
type TripAddMemberRequest, type AssignmentReorderRequest, type TripAddMemberRequest, type AssignmentReorderRequest,
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest, type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest, type DayCreateRequest, type DayUpdateRequest,
type PlaceCreateRequest, type PlaceUpdateRequest, type PlaceCreateRequest, type PlaceUpdateRequest,
type ReservationCreateRequest, type ReservationUpdateRequest, type ReservationCreateRequest, type ReservationUpdateRequest,
type AccommodationCreateRequest, type AccommodationUpdateRequest, type AccommodationCreateRequest, type AccommodationUpdateRequest,
@@ -41,10 +41,9 @@ import {
type BookingImportPreviewItem, type BookingImportPreviewItem,
type BookingImportPreviewResponse, type BookingImportPreviewResponse,
type BookingImportConfirmResponse, type BookingImportConfirmResponse,
type BookingImportMode,
} from '@trek/shared' } from '@trek/shared'
import { getSocketId } from './websocket' import { getSocketId } from './websocket'
import { probeNow } from '../sync/connectivity' import { isReachable, probeNow } from '../sync/connectivity'
/** /**
* Validate a response payload against its @trek/shared Zod schema — but only in * Validate a response payload against its @trek/shared Zod schema — but only in
@@ -101,7 +100,6 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
ja: '試行回数が多すぎます。時間をおいて再度お試しください。', ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.', ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
uk: 'Занадто багато спроб. Спробуйте пізніше.', uk: 'Занадто багато спроб. Спробуйте пізніше.',
sv: 'För många försök. Prova igen senare.',
} }
function translateRateLimit(): string { function translateRateLimit(): string {
@@ -176,17 +174,13 @@ apiClient.interceptors.response.use(
// distinguish a proxy auth challenge from a genuine outage. If the server // distinguish a proxy auth challenge from a genuine outage. If the server
// is reachable, a top-level reload lets the edge proxy run its auth flow. // is reachable, a top-level reload lets the edge proxy run its auth flow.
if (!error.response && navigator.onLine) { if (!error.response && navigator.onLine) {
// Only an actual edge-proxy auth wall warrants tearing down the SW to await probeNow()
// reauth: a reachable proxy (CF Access / Pangolin) that intercepts /api // Both the original request and the health probe failed while the device
// with a cross-origin redirect or an HTML login page. A genuine offline // has a network interface. This matches the proxy-auth-challenge pattern
// boot ALSO lands here — navigator.onLine reflects a network interface, // (CF Access / Pangolin intercept all requests and CORS-block XHR).
// not reachability, and is routinely true on mobile while offline. So // Guard with sessionStorage to prevent reload loops (server genuinely
// gate strictly on a positive proxy signal; on plain offline do nothing // down would also land here, but only reloads once).
// and let the request reject so the cached shell + IndexedDB serve the if (!isReachable()) {
// app. Unregistering the SW here reloaded into a dead network and broke
// PWA offline mode (#1346).
const state = await probeNow()
if (state === 'proxy-wall') {
const { pathname } = window.location const { pathname } = window.location
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) { if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
sessionStorage.setItem('proxy_reauth_attempted', '1') sessionStorage.setItem('proxy_reauth_attempted', '1')
@@ -267,24 +261,6 @@ export const authApi = {
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data), create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data), delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
}, },
passkey: {
registerOptions: (password: string) => apiClient.post('/auth/passkey/register/options', { password }).then(r => r.data),
registerVerify: (attestationResponse: unknown, name?: string) => apiClient.post('/auth/passkey/register/verify', { attestationResponse, name }).then(r => r.data),
loginOptions: () => apiClient.post('/auth/passkey/login/options', {}).then(r => r.data),
loginVerify: (assertionResponse: unknown) => apiClient.post('/auth/passkey/login/verify', { assertionResponse }).then(r => r.data as { token: string; user: Record<string, unknown> }),
list: () => apiClient.get('/auth/passkey/credentials').then(r => r.data as { credentials: PasskeyCredential[] }),
rename: (id: number, name: string) => apiClient.patch(`/auth/passkey/credentials/${id}`, { name }).then(r => r.data),
delete: (id: number, password: string) => apiClient.delete(`/auth/passkey/credentials/${id}`, { data: { password } }).then(r => r.data),
},
}
export interface PasskeyCredential {
id: number
name: string | null
device_type: string | null
backed_up: boolean
created_at: string
last_used_at: string | null
} }
export const oauthApi = { export const oauthApi = {
@@ -333,7 +309,6 @@ export const tripsApi = {
update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data), update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data), delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
searchCoverImages: (query: string) => apiClient.get('/trips/cover-images/search', { params: { query } }).then(r => r.data),
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data), archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data), unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data), getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
@@ -348,7 +323,6 @@ export const daysApi = {
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data), create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data), update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data), delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
} }
export const placesApi = { export const placesApi = {
@@ -373,10 +347,10 @@ export const placesApi = {
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths)) 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) 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, enrich?: boolean) => importGoogleList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data), apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data),
importNaverList: (tripId: number | string, url: string, enrich?: boolean) => importNaverList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data), apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) => bulkDelete: (tripId: number | string, ids: number[]) =>
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data), apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
} }
@@ -402,7 +376,6 @@ export const packingApi = {
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data), reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data), getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data), setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
listTemplates: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/templates`).then(r => r.data),
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data), applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data), saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data), setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data),
@@ -441,48 +414,12 @@ export const adminApi = {
createUser: (data: Record<string, unknown>) => apiClient.post('/admin/users', data).then(r => r.data), createUser: (data: Record<string, unknown>) => apiClient.post('/admin/users', data).then(r => r.data),
updateUser: (id: number, data: Record<string, unknown>) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data), updateUser: (id: number, data: Record<string, unknown>) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data),
deleteUser: (id: number) => apiClient.delete(`/admin/users/${id}`).then(r => r.data), deleteUser: (id: number) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
resetUserPasskeys: (id: number) => apiClient.delete(`/admin/users/${id}/passkeys`).then(r => r.data),
stats: () => apiClient.get('/admin/stats').then(r => r.data), stats: () => apiClient.get('/admin/stats').then(r => r.data),
saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data), saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data),
getOidc: () => apiClient.get('/admin/oidc').then(r => r.data), getOidc: () => apiClient.get('/admin/oidc').then(r => r.data),
updateOidc: (data: Record<string, unknown>) => apiClient.put('/admin/oidc', data).then(r => r.data), updateOidc: (data: Record<string, unknown>) => apiClient.put('/admin/oidc', data).then(r => r.data),
addons: () => apiClient.get('/admin/addons').then(r => r.data), addons: () => apiClient.get('/admin/addons').then(r => r.data),
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data), updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
// Local LLM (Ollama) management for the AI-parsing addon.
llmLocalModels: (baseUrl: string): Promise<{ models: { name: string; size: number }[] }> =>
apiClient.get('/admin/llm/local/models', { params: { baseUrl } }).then(r => r.data),
/** Pull a model, streaming Ollama's NDJSON progress to `onProgress`. */
llmLocalPull: async (
baseUrl: string,
model: string,
onProgress: (p: { status?: string; total?: number; completed?: number; error?: string }) => void,
): Promise<void> => {
const res = await fetch('/api/admin/llm/local/pull', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseUrl, model }),
})
if (!res.ok || !res.body) {
let msg = `Pull failed (${res.status})`
try { msg = (await res.json())?.error ?? msg } catch { /* non-json */ }
throw new Error(msg)
}
const reader = res.body.getReader()
const dec = new TextDecoder()
let buf = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += dec.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.trim()) continue
try { onProgress(JSON.parse(line)) } catch { /* skip partial */ }
}
}
},
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data), checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
getBagTracking: () => apiClient.get('/admin/bag-tracking').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), updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
@@ -529,20 +466,6 @@ export const addonsApi = {
enabled: () => apiClient.get('/addons').then(r => r.data), enabled: () => apiClient.get('/addons').then(r => r.data),
} }
export const airtrailApi = {
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean; writeEnabled?: boolean }) =>
apiClient.put('/integrations/airtrail/settings', data).then(r => r.data),
status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data),
test: (data: { url?: string; apiKey?: string; allowInsecureTls?: boolean }) =>
apiClient.post('/integrations/airtrail/test', data).then(r => r.data),
sync: (): Promise<{ changed: number }> => apiClient.post('/integrations/airtrail/sync').then(r => r.data),
// flights + import are added with the trip-planner import (P2)
flights: () => apiClient.get('/integrations/airtrail/flights').then(r => r.data),
import: (tripId: number, flightIds: string[]) =>
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, { flightIds }).then(r => r.data),
}
export const journeyApi = { export const journeyApi = {
list: () => apiClient.get('/journeys').then(r => r.data), list: () => apiClient.get('/journeys').then(r => r.data),
create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data), create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data),
@@ -614,11 +537,6 @@ export const mapsApi = {
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')), placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')),
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')), reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')), resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
// OSM-only POI explore: places of a category within the current map viewport bbox.
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
// timeout than the global default instead of aborting at 8s and showing nothing.
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
} }
export const airportsApi = { export const airportsApi = {
@@ -637,7 +555,6 @@ export const budgetApi = {
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data), perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data), settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data), createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data), deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data), reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data), reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
@@ -666,31 +583,17 @@ export const reservationsApi = {
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data), update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data), updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
importBookingPreview: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<BookingImportPreviewResponse> => { importBookingPreview: (tripId: number | string, files: File[]): Promise<BookingImportPreviewResponse> => {
const fd = new FormData() const fd = new FormData()
for (const f of files) fd.append('files', f) for (const f of files) fd.append('files', f)
fd.append('mode', mode) return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
// No client-side timeout: kitinerary + LLM extraction routinely exceeds the
// global 8s default (a cold local model alone can take ~45s).
return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).then(r => r.data)
}, },
importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise<BookingImportConfirmResponse> => importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise<BookingImportConfirmResponse> =>
apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data), apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data),
// Start a background parse: returns a job id at once; progress + result arrive
// over the WebSocket (import:progress / import:done / import:error).
importBookingAsync: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<{ jobId: string }> => {
const fd = new FormData()
for (const f of files) fd.append('files', f)
fd.append('mode', mode)
return apiClient.post(`/trips/${tripId}/reservations/import/booking/async`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).then(r => r.data)
},
// Poll a background job — recovery path when a WebSocket push was missed.
importJobStatus: (tripId: number | string, jobId: string): Promise<{ status: 'running' | 'done' | 'error'; done: number; total: number; result?: BookingImportPreviewResponse; error?: string }> =>
apiClient.get(`/trips/${tripId}/reservations/import/jobs/${jobId}`).then(r => r.data),
} }
export const healthApi = { export const healthApi = {
features: (): Promise<{ bookingImport: boolean; aiParsing: boolean }> => apiClient.get('/health/features').then(r => r.data), features: (): Promise<{ bookingImport: boolean }> => apiClient.get('/health/features').then(r => r.data),
} }
export const weatherApi = { export const weatherApi = {
@@ -703,17 +606,6 @@ export const configApi = {
apiClient.get('/config').then(r => r.data), apiClient.get('/config').then(r => r.data),
} }
export interface HelpNavItem { title: string; slug: string }
export interface HelpNavSection { title: string; pages: HelpNavItem[] }
export interface HelpPageData { slug: string; title: string; markdown: string }
export const helpApi = {
index: (): Promise<{ sections: HelpNavSection[] }> =>
apiClient.get('/help/index').then(r => r.data),
page: (slug: string): Promise<HelpPageData> =>
apiClient.get(`/help/page/${encodeURIComponent(slug)}`).then(r => r.data),
}
export const settingsApi = { export const settingsApi = {
get: () => apiClient.get('/settings').then(r => r.data), get: () => apiClient.get('/settings').then(r => r.data),
set: (key: string, value: unknown) => { set: (key: string, value: unknown) => {
@@ -820,4 +712,4 @@ export const inAppNotificationsApi = {
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data), apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
} }
export default apiClient export default apiClient
-6
View File
@@ -20,12 +20,6 @@ export function getSocketId(): string | null {
return mySocketId return mySocketId
} }
/** Trip ids the app currently has open (joined). Used to re-hydrate the active
* trip's store after the network comes back via the `online` event. */
export function getActiveTrips(): string[] {
return Array.from(activeTrips)
}
export function setRefetchCallback(fn: RefetchCallback | null): void { export function setRefetchCallback(fn: RefetchCallback | null): void {
refetchCallback = fn refetchCallback = fn
} }
+3 -228
View File
@@ -4,11 +4,10 @@ import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud } from 'lucide-react' import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
const ICON_MAP = { const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
} }
function ImmichIcon({ size = 14 }: { size?: number }) { function ImmichIcon({ size = 14 }: { size?: number }) {
@@ -299,12 +298,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
</span> </span>
</div> </div>
{integrationAddons.map(addon => ( {integrationAddons.map(addon => (
<div key={addon.id}> <AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'llm_parsing' && addon.enabled && (
<LlmParsingConfig addon={addon} />
)}
</div>
))} ))}
</div> </div>
)} )}
@@ -315,225 +309,6 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
) )
} }
const MASKED = '••••••••'
const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
/** Curated models the local extractor is tuned for, pullable via Ollama. The router drives
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
{ id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false },
]
/**
* Instance-wide AI-parsing config. When set, applies to the whole instance and
* overrides per-user config (see server llmConfig.ts). The API key is masked on
* read; an unchanged mask is treated as a no-op by the server. For the local
* provider, it also lists installed Ollama models and can pull NuExtract models.
*/
function LlmParsingConfig({ addon }: { addon: Addon }) {
const toast = useToast()
const cfg = (addon.config ?? {}) as Record<string, unknown>
const [provider, setProvider] = useState<string>((cfg.provider as string) ?? 'local')
const [model, setModel] = useState<string>((cfg.model as string) ?? '')
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '')
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '')
const [saving, setSaving] = useState(false)
// Local-provider model management.
const [installed, setInstalled] = useState<string[]>([])
const [modelsErr, setModelsErr] = useState('')
const [loadingModels, setLoadingModels] = useState(false)
const [pulling, setPulling] = useState<string | null>(null)
const [pullPct, setPullPct] = useState(0)
const [pullStatus, setPullStatus] = useState('')
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL
const isInstalled = (id: string) => installed.some(n => n === id || n.startsWith(id + ':') || n.startsWith(id))
const loadModels = async () => {
if (provider !== 'local') return
setLoadingModels(true)
setModelsErr('')
try {
const res = await adminApi.llmLocalModels(effectiveUrl)
setInstalled(res.models.map(m => m.name))
} catch (e: unknown) {
setModelsErr(e instanceof Error ? e.message : 'Could not reach the local LLM server')
setInstalled([])
} finally {
setLoadingModels(false)
}
}
// Load installed models when the local provider is active.
useEffect(() => {
if (provider === 'local') loadModels()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider])
const pull = async (id: string) => {
if (pulling) return
setPulling(id)
setPullPct(0)
setPullStatus('starting…')
try {
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
if (p.error) throw new Error(p.error)
if (p.status) setPullStatus(p.status)
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100))
})
toast.success('Model pulled')
setModel(id)
await loadModels()
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Pull failed')
} finally {
setPulling(null)
setPullPct(0)
setPullStatus('')
}
}
const save = async () => {
setSaving(true)
try {
// Send the masked sentinel unchanged so the server keeps the stored key.
await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } })
toast.success('Saved')
} catch {
toast.error('Failed to save')
} finally {
setSaving(false)
}
}
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-3 py-2 text-sm text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
const labelCls = 'mb-1.5 block text-xs font-medium text-content-secondary'
const sectionCls = 'text-[11px] font-semibold uppercase tracking-wide text-content-faint'
const providerOptions = [
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
]
return (
<div className="border-b border-edge-secondary bg-surface-secondary py-5 pr-6 pl-[70px]">
<div className="max-w-2xl space-y-6">
<p className="text-xs text-content-faint">
Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider.
</p>
{/* Connection */}
<section className="space-y-3">
<div className={sectionCls}>Connection</div>
<div>
<span className={labelCls}>Provider</span>
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
</div>
{provider !== 'anthropic' && (
<label className="block">
<span className={labelCls}>Base URL</span>
<input type="url" autoComplete="off" className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
</label>
)}
<label className="block">
<span className={labelCls}>API key</span>
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
</label>
{provider === 'anthropic' && (
<p className="text-xs text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text scanned PDFs need Anthropic.</p>
)}
</section>
{/* Model */}
<section className="space-y-3">
<div className={sectionCls}>Model</div>
<label className="block">
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
</label>
{/* Local model management (Ollama) */}
{provider === 'local' && (
<div className="space-y-3 rounded-lg border border-edge-secondary bg-surface p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-content-secondary">Installed on the server</span>
<button onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
{loadingModels ? 'Loading…' : 'Refresh'}
</button>
</div>
{modelsErr && <p className="text-xs text-rose-600">{modelsErr}</p>}
{!modelsErr && installed.length === 0 && !loadingModels && (
<p className="text-xs text-content-faint">No models installed yet pull one below.</p>
)}
{installed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{installed.map(name => (
<button
key={name}
title={name}
onClick={() => setModel(name)}
className={`max-w-full truncate rounded-full border px-2.5 py-1 text-xs transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
>
{name}
</button>
))}
</div>
)}
<div className="border-t border-edge-secondary pt-3">
<div className="mb-2 text-xs font-medium text-content-secondary">Pull a recommended model</div>
<div className="space-y-1">
{RECOMMENDED_MODELS.map(m => {
const installedHere = isInstalled(m.id)
const isPulling = pulling === m.id
const active = model === m.id
return (
<div key={m.id} className={`flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors ${active ? 'border-edge-secondary bg-surface-secondary' : 'border-transparent'}`}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-content">{m.label}</span>
{m.recommended && (
<span className="rounded-md bg-[rgba(16,185,129,0.15)] px-1.5 py-px text-[10px] font-semibold text-emerald-600">Recommended</span>
)}
</div>
<div className="text-xs text-content-faint">{m.note}</div>
{isPulling && (
<div className="mt-1.5">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
</div>
<div className="mt-0.5 text-[10px] text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
</div>
)}
</div>
{installedHere ? (
<button onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
{active ? 'Selected' : 'Use'}
</button>
) : (
<button onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
{isPulling ? 'Pulling…' : 'Pull'}
</button>
)}
</div>
)
})}
</div>
</div>
</div>
)}
</section>
<button onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
)
}
interface AddonRowProps { interface AddonRowProps {
addon: Addon addon: Addon
onToggle: (addon: Addon) => void onToggle: (addon: Addon) => void
+6 -6
View File
@@ -473,10 +473,10 @@ export default function BackupPanel() {
<AlertTriangle size={20} className="text-white" /> <AlertTriangle size={20} className="text-white" />
</div> </div>
<div> <div>
<h3 className="text-white" style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}> <h3 className="text-white" style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>
{t('backup.restoreConfirmTitle')} {t('backup.restoreConfirmTitle')}
</h3> </h3>
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}> <p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 12 }}>
{restoreConfirm.filename} {restoreConfirm.filename}
</p> </p>
</div> </div>
@@ -484,11 +484,11 @@ export default function BackupPanel() {
{/* Body */} {/* Body */}
<div style={{ padding: '20px 24px' }}> <div style={{ padding: '20px 24px' }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, margin: 0 }}> <p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{t('backup.restoreWarning')} {t('backup.restoreWarning')}
</p> </p>
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.5 }} <div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800" className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
> >
{t('backup.restoreTip')} {t('backup.restoreTip')}
@@ -500,14 +500,14 @@ export default function BackupPanel() {
<button <button
onClick={() => setRestoreConfirm(null)} onClick={() => setRestoreConfirm(null)}
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700" className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }} style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
> >
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button <button
onClick={executeRestore} onClick={executeRestore}
className="bg-[#dc2626] text-white" className="bg-[#dc2626] text-white"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }} style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'} onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'} onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
> >
@@ -6,17 +6,7 @@ import { useToast } from '../shared/Toast'
import Section from '../Settings/Section' import Section from '../Settings/Section'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView' import { MapView } from '../Map/MapView'
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants' import type { Place } from '../../types'
import type { DistanceUnit, Place } from '../../types'
import {
MAPBOX_DEFAULT_STYLE,
defaultStyleForProvider,
getStylePresets,
isOpenFreeMapStyle,
normalizeStyleForProvider,
styleSettingKey,
type GlMapProvider,
} from '../Map/glProviders'
const MAP_PRESETS = [ const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }, { name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
@@ -28,30 +18,10 @@ const MAP_PRESETS = [
type Defaults = { type Defaults = {
temperature_unit?: string temperature_unit?: string
distance_unit?: DistanceUnit
dark_mode?: string | boolean dark_mode?: string | boolean
time_format?: string time_format?: string
default_currency?: string
blur_booking_codes?: boolean blur_booking_codes?: boolean
map_tile_url?: string map_tile_url?: string
map_provider?: string
mapbox_access_token?: string
mapbox_style?: string
maplibre_style?: string
mapbox_3d_enabled?: boolean
mapbox_quality_mode?: boolean
}
type MapProvider = 'leaflet' | GlMapProvider
function normalizeProvider(value: unknown): MapProvider {
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
}
function styleForProvider(provider: MapProvider, style?: string | null): string {
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
return normalizeStyleForProvider(provider, style)
} }
function OptionRow({ function OptionRow({
@@ -89,7 +59,7 @@ function OptionButton({
style={{ style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer', padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500, fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)', border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: active ? 'var(--bg-hover)' : 'var(--bg-card)', background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)', color: 'var(--text-primary)',
@@ -107,16 +77,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const [defaults, setDefaults] = useState<Defaults>({}) const [defaults, setDefaults] = useState<Defaults>({})
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [mapTileUrl, setMapTileUrl] = useState('') const [mapTileUrl, setMapTileUrl] = useState('')
const [mapboxToken, setMapboxToken] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
useEffect(() => { useEffect(() => {
adminApi.getDefaultUserSettings().then((data: Defaults) => { adminApi.getDefaultUserSettings().then((data: Defaults) => {
const provider = normalizeProvider(data.map_provider)
setDefaults(data) setDefaults(data)
setMapTileUrl(data.map_tile_url || '') setMapTileUrl(data.map_tile_url || '')
setMapboxToken(data.mapbox_access_token || '')
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
setLoaded(true) setLoaded(true)
}).catch(() => setLoaded(true)) }).catch(() => setLoaded(true))
}, []) }, [])
@@ -136,11 +101,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const updated = await adminApi.updateDefaultUserSettings({ [key]: null }) const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
setDefaults(updated) setDefaults(updated)
if (key === 'map_tile_url') setMapTileUrl('') if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'mapbox_style' || key === 'maplibre_style') {
const provider = normalizeProvider(defaults.map_provider)
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
}
toast.success(t('admin.defaultSettings.reset')) toast.success(t('admin.defaultSettings.reset'))
} catch (err: unknown) { } catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error')) toast.error(err instanceof Error ? err.message : t('common.error'))
@@ -186,24 +146,10 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
}], []) }], [])
if (!loaded) { if (!loaded) {
return <p className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontStyle: 'italic', padding: 16 }}>Loading</p> return <p className="text-content-faint" style={{ fontSize: 12, fontStyle: 'italic', padding: 16 }}>Loading</p>
} }
const darkMode = defaults.dark_mode const darkMode = defaults.dark_mode
const mapProvider = normalizeProvider(defaults.map_provider)
const glStylePresets = mapProvider === 'leaflet' ? [] : getStylePresets(mapProvider)
const styleKey: keyof Defaults = mapProvider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
const saveMapProvider = (nextProvider: MapProvider) => {
const patch: Partial<Defaults> = { map_provider: nextProvider }
if (nextProvider !== 'leaflet') {
// Load + save the new provider's own style slot so the other provider's style is kept.
const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style
const nextStyle = styleForProvider(nextProvider, slot)
setMapboxStyle(nextStyle)
patch[styleSettingKey(nextProvider)] = nextStyle
}
save(patch)
}
return ( return (
<Section title={t('admin.defaultSettings.title')} icon={Settings2}> <Section title={t('admin.defaultSettings.title')} icon={Settings2}>
@@ -244,22 +190,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
))} ))}
</OptionRow> </OptionRow>
{/* Distance */}
<OptionRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
{([
{ value: 'metric', label: 'km Metric' },
{ value: 'imperial', label: 'mi Imperial' },
] as const).map(opt => (
<OptionButton
key={opt.value}
active={defaults.distance_unit === opt.value}
onClick={() => save({ distance_unit: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Time Format */} {/* Time Format */}
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}> <OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
{([ {([
@@ -276,23 +206,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
))} ))}
</OptionRow> </OptionRow>
{/* Default Currency */}
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('settings.currency')} <ResetButton field="default_currency" />
</label>
<CustomSelect
value={defaults.default_currency || ''}
onChange={(value: string) => { if (value) save({ default_currency: value }) }}
placeholder={t('settings.currency')}
searchable
options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
size="sm"
style={{ maxWidth: 240 }}
/>
<p className="text-xs mt-1 text-content-faint">{t('settings.currencyHint')}</p>
</div>
{/* Blur Booking Codes */} {/* Blur Booking Codes */}
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}> <OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
{([ {([
@@ -354,105 +267,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
})} })}
</div> </div>
</div> </div>
{/* ── Map provider / instance-wide Mapbox ───────────────────────── */}
<div style={{ borderTop: '1px solid var(--border-primary)', paddingTop: 20, marginTop: 4 }}>
<OptionRow
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
hint={t('admin.defaultSettings.mapProviderHint')}
>
{([
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
] as const).map(opt => (
<OptionButton
key={opt.value}
active={mapProvider === opt.value}
onClick={() => saveMapProvider(opt.value)}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{mapProvider !== 'leaflet' && (
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
{mapProvider === 'mapbox-gl' && (
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxToken')}
<ResetButton field="mapbox_access_token" />
</label>
<input
type="text"
value={mapboxToken}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxToken(e.target.value)}
onBlur={() => save({ mapbox_access_token: mapboxToken })}
placeholder="pk.eyJ…"
spellCheck={false}
autoComplete="off"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
</div>
)}
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxStyle')}
<ResetButton field={styleKey} />
</label>
<CustomSelect
value={mapboxStyle}
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ [styleKey]: value }) } }}
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
options={glStylePresets.map(p => ({ value: p.url, label: p.name }))}
size="sm"
style={{ marginBottom: 8 }}
/>
<input
type="text"
value={mapboxStyle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
onBlur={() => {
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle)
setMapboxStyle(nextStyle)
save({ [styleKey]: nextStyle })
}}
placeholder={defaultStyleForProvider(mapProvider)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
{mapProvider === 'mapbox-gl' && (
<>
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
{([
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
] as const).map(opt => (
<OptionButton key={String(opt.value)} active={(defaults.mapbox_3d_enabled ?? true) === opt.value} onClick={() => save({ mapbox_3d_enabled: opt.value })}>
{opt.label}
</OptionButton>
))}
</OptionRow>
<OptionRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
{([
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
] as const).map(opt => (
<OptionButton key={String(opt.value)} active={(defaults.mapbox_quality_mode ?? false) === opt.value} onClick={() => save({ mapbox_quality_mode: opt.value })}>
{opt.label}
</OptionButton>
))}
</OptionRow>
</>
)}
</div>
)}
</div>
</Section> </Section>
) )
} }
@@ -1,163 +0,0 @@
import ReactDOM from 'react-dom'
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { addListener, removeListener } from '../../api/websocket'
import { reservationsApi } from '../../api/client'
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
/**
* Global, route-independent widget (bottom-right) that tracks background booking
* imports. Mounted once at the app root so it survives navigation. It listens to the
* user's WebSocket for import:progress / import:done / import:error and reflects each
* job; a finished job offers a "review" action that takes the user to the trip, where
* the per-item review flow opens. Polls running jobs as a backstop for missed pushes.
*/
export default function BackgroundTasksWidget() {
const { t } = useTranslation()
const navigate = useNavigate()
const tasks = useBackgroundTasksStore((s) => s.tasks)
const setProgress = useBackgroundTasksStore((s) => s.setProgress)
const setDone = useBackgroundTasksStore((s) => s.setDone)
const setError = useBackgroundTasksStore((s) => s.setError)
const requestReview = useBackgroundTasksStore((s) => s.requestReview)
const dismiss = useBackgroundTasksStore((s) => s.dismiss)
// On (re)load, reconcile tasks restored from localStorage with the server: a parse
// that was still running when the page reloaded must keep its widget, so re-fetch each
// job's real status (and its parsed items) once. A job the server has since dropped
// (404, expired) is removed so no stale card lingers.
const didRehydrate = useRef(false)
useEffect(() => {
if (didRehydrate.current) return
didRehydrate.current = true
const restored = useBackgroundTasksStore.getState().tasks
for (const task of restored) {
reservationsApi
.importJobStatus(task.tripId, task.id)
.then((s) => {
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
else setProgress(task.id, task.tripId, s.done, s.total)
})
.catch((err: { response?: { status?: number } }) => {
if (err?.response?.status === 404) dismiss(task.id)
})
}
// run once on mount against whatever was rehydrated from storage
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Server pushes import:* to the user on whatever page they're on.
useEffect(() => {
const handler = (e: Record<string, unknown>) => {
const type = typeof e.type === 'string' ? e.type : ''
if (!type.startsWith('import:')) return
const id = String(e.jobId ?? '')
const tripId = String(e.tripId ?? '')
if (!id) return
if (type === 'import:progress') setProgress(id, tripId, Number(e.done ?? 0), Number(e.total ?? 1))
else if (type === 'import:done') {
const result = e.result as { items?: unknown[]; warnings?: string[] } | undefined
setDone(id, tripId, (result?.items ?? []) as never, result?.warnings ?? [])
} else if (type === 'import:error') setError(id, tripId, String(e.message ?? 'error'))
}
addListener(handler)
return () => removeListener(handler)
}, [setProgress, setDone, setError])
// Backstop: poll jobs whose state we still need — running ones (in case a WebSocket push
// was missed) and a restored 'done' task whose items haven't been re-fetched yet (so a
// failed one-shot rehydrate self-heals instead of getting stuck on "preview empty").
useEffect(() => {
const pending = tasks.filter((task) => task.status === 'running' || (task.status === 'done' && task.items === undefined))
if (pending.length === 0) return
const iv = setInterval(() => {
for (const task of pending) {
reservationsApi
.importJobStatus(task.tripId, task.id)
.then((s) => {
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
else setProgress(task.id, task.tripId, s.done, s.total)
})
.catch(() => {})
}
}, 5000)
return () => clearInterval(iv)
}, [tasks, setProgress, setDone, setError])
if (tasks.length === 0) return null
const review = (task: BackgroundImportTask) => {
requestReview(task.id)
navigate(`/trips/${task.tripId}`)
}
return ReactDOM.createPortal(
<div
style={{ position: 'fixed', right: 16, bottom: 16, zIndex: 50000, display: 'flex', flexDirection: 'column', gap: 8, width: 380, maxWidth: 'calc(100vw - 32px)', fontFamily: 'var(--font-system)' }}
>
{tasks.map((task) => (
<div
key={task.id}
className="bg-surface-card"
style={{ borderRadius: 12, border: '1px solid var(--border-primary)', boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '11px 13px', backdropFilter: 'blur(8px)', display: 'flex', gap: 10, alignItems: 'flex-start' }}
>
<div style={{ flexShrink: 0, marginTop: 1 }}>
{(task.status === 'running' || (task.status === 'done' && task.items === undefined)) && <Loader2 size={16} className="animate-spin" color="var(--accent)" />}
{task.status === 'done' && task.items !== undefined && <CheckCircle2 size={16} color="#10b981" />}
{task.status === 'error' && <AlertCircle size={16} color="#ef4444" />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.label}
</div>
{task.status === 'running' && (
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>
{t('reservations.import.parsing')}
{task.total > 1 ? ` · ${task.done}/${task.total}` : ''}
</div>
)}
{task.status === 'done' && (
task.items === undefined ? (
// Restored from a reload; items are being re-fetched (see the poll backstop).
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.parsing')}</div>
) : task.items.length > 0 ? (
<button
onClick={() => review(task)}
className="bg-accent text-accent-text"
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.import')}
</button>
) : (
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.previewEmpty')}</div>
)
)}
{task.status === 'error' && (
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b91c1c', marginTop: 1, whiteSpace: 'pre-wrap' }}>{task.error}</div>
)}
</div>
{task.status !== 'running' && (
<button
onClick={() => dismiss(task.id)}
className="bg-transparent text-content-faint"
style={{ flexShrink: 0, border: 'none', cursor: 'pointer', padding: 2, borderRadius: 6, display: 'flex', alignItems: 'center' }}
aria-label={t('common.close')}
>
<X size={13} />
</button>
)}
</div>
))}
</div>,
document.body
)
}
+7 -7
View File
@@ -38,14 +38,14 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<div style={{ width: 64, height: 64, borderRadius: 16, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}> <div style={{ width: 64, height: 64, borderRadius: 16, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
<Calculator size={28} color="#6b7280" /> <Calculator size={28} color="#6b7280" />
</div> </div>
<h2 style={{ fontSize: 'calc(20px * var(--fs-scale-title, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2> <h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
<p style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p> <p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
{canEdit && ( {canEdit && (
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}> <div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)} <input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddCategory()} onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
placeholder={t('budget.emptyPlaceholder')} placeholder={t('budget.emptyPlaceholder')}
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} /> style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()} <button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}> style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
<Plus size={16} /> <Plus size={16} />
@@ -65,7 +65,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
padding: '14px 16px 14px 22px', padding: '14px 16px 14px 22px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap',
}}> }}>
<h2 style={{ margin: 0, fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}> <h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
{t('budget.title')} {t('budget.title')}
</h2> </h2>
<div className="flex flex-wrap max-md:!w-full max-md:!mt-2" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}> <div className="flex flex-wrap max-md:!w-full max-md:!mt-2" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
@@ -85,14 +85,14 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
onChange={e => setNewCategoryName(e.target.value)} onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }} onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
placeholder={t('budget.categoryName')} placeholder={t('budget.categoryName')}
style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }} 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()} <button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
title={t('budget.addCategory')} title={t('budget.addCategory')}
style={{ style={{
appearance: 'none', border: 'none', cursor: newCategoryName.trim() ? 'pointer' : 'default', fontFamily: 'inherit', appearance: 'none', border: 'none', cursor: newCategoryName.trim() ? 'pointer' : 'default', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6, display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0, background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
opacity: newCategoryName.trim() ? 1 : 0.4, opacity: newCategoryName.trim() ? 1 : 0.4,
transition: 'opacity 0.15s ease', transition: 'opacity 0.15s ease',
@@ -105,7 +105,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
style={{ style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6, display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0, background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
transition: 'opacity 0.15s ease', transition: 'opacity 0.15s ease',
}} }}
@@ -23,7 +23,7 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
setTimeout(() => nameRef.current?.focus(), 50) setTimeout(() => nameRef.current?.focus(), 50)
} }
const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' } const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' }
return ( return (
<tr className="bg-surface-secondary"> <tr className="bg-surface-secondary">
@@ -44,9 +44,9 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
<input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} <input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} /> placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
</td> </td>
<td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'center' }}>-</td> <td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
<td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'center' }}>-</td> <td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
<td className="hidden lg:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'center' }}>-</td> <td className="hidden lg:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}> <td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
<div style={{ maxWidth: 90, margin: '0 auto' }}> <div style={{ maxWidth: 90, margin: '0 auto' }}>
<CustomDatePicker value={expenseDate} onChange={setExpenseDate} placeholder="-" compact /> <CustomDatePicker value={expenseDate} onChange={setExpenseDate} placeholder="-" compact />
@@ -103,11 +103,11 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
onChange={e => setEditingCat({ ...editingCat, value: e.target.value })} onChange={e => setEditingCat({ ...editingCat, value: e.target.value })}
onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }} onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }}
onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }} onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }}
style={{ fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }} style={{ fontWeight: 600, fontSize: 13, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }}
/> />
) : ( ) : (
<> <>
<span style={{ fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{cat}</span> <span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
{canEdit && ( {canEdit && (
<button onClick={() => setEditingCat({ name: cat, value: cat })} <button onClick={() => setEditingCat({ name: cat, value: cat })}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
@@ -119,7 +119,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
)} )}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span> <span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
{canEdit && ( {canEdit && (
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')} <button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }} style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
@@ -233,7 +233,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
<CustomDatePicker value={item.expense_date || ''} onChange={v => handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless /> <CustomDatePicker value={item.expense_date || ''} onChange={v => handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless />
</div> </div>
) : ( ) : (
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span> <span style={{ fontSize: 11, color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span>
)} )}
</td> </td>
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td> <td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td>
@@ -50,7 +50,7 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
return <input ref={inputRef} type="text" inputMode={type === 'number' ? 'decimal' : 'text'} value={editValue} return <input ref={inputRef} type="text" inputMode={type === 'number' ? 'decimal' : 'text'} value={editValue}
onChange={e => setEditValue(e.target.value)} onBlur={save} onPaste={handlePaste} onChange={e => setEditValue(e.target.value)} onBlur={save} onPaste={handlePaste}
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }}
style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }} style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }}
placeholder={placeholder} /> placeholder={placeholder} />
} }
@@ -62,7 +62,7 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
<div onClick={() => { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip} <div onClick={() => { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center', style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s', justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', ...style }} color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }} onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}> onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
{display || placeholder || '-'} {display || placeholder || '-'}
@@ -56,13 +56,13 @@ export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }:
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
display: 'flex', alignItems: 'center', gap: 5, display: 'flex', alignItems: 'center', gap: 5,
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)', background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8, fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
}}> }}>
{label} {label}
{paid && ( {paid && (
<span style={{ <span style={{
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, padding: '1px 5px', borderRadius: 4, fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
background: 'rgba(34,197,94,0.15)', color: '#16a34a', background: 'rgba(34,197,94,0.15)', color: '#16a34a',
textTransform: 'uppercase', letterSpacing: '0.03em', textTransform: 'uppercase', letterSpacing: '0.03em',
}}>Paid</span> }}>Paid</span>
@@ -151,14 +151,14 @@ export default function BudgetMemberChips({ members = [], tripMembers = [], onSe
<button key={tm.id} onClick={() => toggleMember(tm.id)} style={{ <button key={tm.id} onClick={() => toggleMember(tm.id)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px', display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer', borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-primary)', textAlign: 'left', fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', textAlign: 'left',
}} }}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }} onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }} onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }}
> >
<div style={{ <div style={{
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)', width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
}}> }}>
{tm.avatar_url {tm.avatar_url
@@ -51,10 +51,10 @@ export default function PerPersonInline({ tripId, budgetItems, currency, locale,
<div key={p.user_id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '6px 0' }}> <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} /> <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={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 500, letterSpacing: '-0.01em', color: theme.text }}>{p.username}</div> <div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text }}>{p.username}</div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: theme.faint, marginTop: 1 }}>{percent}%</div> <div style={{ fontSize: 11, color: theme.faint, marginTop: 1 }}>{percent}%</div>
</div> </div>
<div style={{ fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 600, color: theme.text, letterSpacing: '-0.01em' }}>{fmt(p.total_assigned)}</div> <div style={{ fontSize: 13.5, fontWeight: 600, color: theme.text, letterSpacing: '-0.01em' }}>{fmt(p.total_assigned)}</div>
</div> </div>
) )
})} })}
@@ -46,7 +46,7 @@ export default function PieChart({ segments, size = 200, totalLabel }: PieChartP
boxShadow: 'inset 0 0 12px rgba(0,0,0,0.04)', boxShadow: 'inset 0 0 12px rgba(0,0,0,0.04)',
}}> }}>
<Wallet size={18} color="var(--text-faint)" style={{ marginBottom: 2 }} /> <Wallet size={18} color="var(--text-faint)" style={{ marginBottom: 2 }} />
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span> <span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span>
</div> </div>
</div> </div>
) )
@@ -47,7 +47,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
<Wallet size={20} strokeWidth={2} /> <Wallet size={20} strokeWidth={2} />
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</div> <div style={{ fontSize: 11, color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</div>
</div> </div>
</div> </div>
@@ -58,13 +58,13 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, ''] const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, '']
return ( return (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}> <div style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}>
<span style={{ fontSize: 'calc(38px * var(--fs-scale-title, 1))', fontWeight: 700 }}>{integerPart}</span> <span style={{ fontSize: 38, fontWeight: 700 }}>{integerPart}</span>
{decimalPart && <span style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>} {decimalPart && <span style={{ fontSize: 22, fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>}
<span style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 500, color: theme.sub, marginLeft: 2 }}>{SYMBOLS[currency] || currency}</span> <span style={{ fontSize: 22, fontWeight: 500, color: theme.sub, marginLeft: 2 }}>{SYMBOLS[currency] || currency}</span>
</div> </div>
) )
})()} })()}
<div style={{ color: theme.faint, fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 8, fontWeight: 500, letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ color: theme.faint, fontSize: 12, marginTop: 8, fontWeight: 500, letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 6 }}>
<span>{currency}</span> <span>{currency}</span>
</div> </div>
@@ -78,7 +78,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
<button onClick={() => setSettlementOpen(v => !v)} style={{ <button onClick={() => setSettlementOpen(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%', display: 'flex', alignItems: 'center', gap: 6, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
color: theme.sub, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, letterSpacing: 0.5, color: theme.sub, fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
}}> }}>
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />} {settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{t('budget.settlement')} {t('budget.settlement')}
@@ -95,7 +95,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
marginTop: 6, width: 220, padding: '10px 12px', borderRadius: 10, zIndex: 100, marginTop: 6, width: 220, padding: '10px 12px', borderRadius: 10, zIndex: 100,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left', fontSize: 11, fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
}}> }}>
{t('budget.settlementInfo')} {t('budget.settlementInfo')}
</div> </div>
@@ -117,7 +117,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
> >
<RingAvatar userId={flow.from.user_id} username={flow.from.username} avatarUrl={flow.from.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} /> <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 }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5 }}>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}> <span style={{ fontSize: 13, fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}>
{fmt(flow.amount, currency)} {fmt(flow.amount, currency)}
</span> </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={{ 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' }}>
@@ -130,7 +130,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && ( {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
<div style={{ marginTop: 8, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}> <div style={{ marginTop: 8, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}> <div style={{ fontSize: 10, fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}>
{t('budget.netBalances')} {t('budget.netBalances')}
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
@@ -140,13 +140,13 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
return ( return (
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 0' }}> <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} /> <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: 'calc(13px * var(--fs-scale-body, 1))', color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ flex: 1, fontSize: 13, color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{b.username} {b.username}
</span> </span>
<span style={{ <span style={{
display: 'inline-flex', alignItems: 'center', gap: 4, display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '4px 10px', borderRadius: 8, padding: '4px 10px', borderRadius: 8,
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, letterSpacing: '-0.01em', fontSize: 12, fontWeight: 700, letterSpacing: '-0.01em',
background: positive ? 'rgba(16,185,129,0.13)' : 'rgba(239,68,68,0.13)', background: positive ? 'rgba(16,185,129,0.13)' : 'rgba(239,68,68,0.13)',
color: positive ? '#10b981' : '#ef4444', color: positive ? '#10b981' : '#ef4444',
}}> }}>
@@ -192,7 +192,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
<PieChartIcon size={18} strokeWidth={2} /> <PieChartIcon size={18} strokeWidth={2} />
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div> <div style={{ fontSize: 11, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div>
</div> </div>
</div> </div>
@@ -226,12 +226,12 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
})} })}
</svg> </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={{ 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: 'calc(10.5px * var(--fs-scale-caption, 1))', color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{t('budget.total')}</div> <div style={{ fontSize: 10.5, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{t('budget.total')}</div>
<div style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}> <div style={{ fontSize: 22, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
<span>{totalInt}</span> <span>{totalInt}</span>
{totalDec && <span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>} {totalDec && <span style={{ fontSize: 13, fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
</div> </div>
<div style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div> <div style={{ fontSize: 10.5, color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div>
</div> </div>
</div> </div>
@@ -256,13 +256,13 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
boxShadow: `0 0 12px ${seg.color}80`, boxShadow: `0 0 12px ${seg.color}80`,
}} /> }} />
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 500, letterSpacing: '-0.01em', color: theme.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{seg.name}</div> <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: 'calc(11.5px * var(--fs-scale-caption, 1))', color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div> <div style={{ fontSize: 11.5, color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div>
</div> </div>
<span style={{ <span style={{
flexShrink: 0, flexShrink: 0,
padding: '4px 9px', borderRadius: 7, padding: '4px 9px', borderRadius: 7,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, letterSpacing: '-0.01em', fontSize: 11, fontWeight: 700, letterSpacing: '-0.01em',
background: `${seg.color}26`, background: `${seg.color}26`,
border: `1px solid ${seg.color}40`, border: `1px solid ${seg.color}40`,
color: chipColor, color: chipColor,
@@ -1,197 +0,0 @@
// FE-COMP-COSTS: settlements surfaced inline in the Costs ledger (issue #1241)
import { render, screen, waitFor } from '../../../tests/helpers/render'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { useAuthStore } from '../../store/authStore'
import { useTripStore } from '../../store/tripStore'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories'
import CostsPanel from './CostsPanel'
const tripMembers = [
{ id: 1, username: 'alice', avatar_url: null },
{ id: 2, username: 'bob', avatar_url: null },
]
beforeEach(() => {
resetAllStores()
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) })
})
describe('CostsPanel — settlements in the ledger', () => {
it('renders a settle-up payment as a ledger row with an undo action', async () => {
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () =>
HttpResponse.json({
balances: [],
flows: [],
settlements: [
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
],
})
),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
// The expense and the settlement (payment) both appear in the unified ledger.
await screen.findByText('Dinner')
await screen.findByText('Payment')
// The payment row exposes an inline undo (no need to open a separate History modal).
expect(screen.getByTitle('Undo')).toBeInTheDocument()
})
it('records a manual payment via the Add payment button', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget/settlements', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ settlement: { id: 1, ...posted } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
await user.type(await screen.findByPlaceholderText('0.00'), '25')
// The footer submit is the second "Add payment" control once the modal is open.
const addButtons = screen.getAllByRole('button', { name: 'Add payment' })
const submit = addButtons[addButtons.length - 1]
await user.click(submit)
await waitFor(() => expect(posted).toMatchObject({ amount: 25 }))
})
it('hides payment rows while a text search is active', async () => {
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () =>
HttpResponse.json({
balances: [],
flows: [],
settlements: [
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
],
})
),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Payment')
await user.type(screen.getByPlaceholderText('Search expenses…'), 'Dinner')
// Payment rows have no name, so a search hides them while the matching expense stays.
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
expect(screen.getByText('Dinner')).toBeInTheDocument()
})
it('auto-splits the total across participants and rebalances a pinned amount on save', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Dinner' }), id: 5 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[]
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
await waitFor(() => expect(nums()[1].value).toBe('50'))
expect(nums()[2].value).toBe('50')
// Pin the first participant to 30 → the other non-pinned field rebalances to 70.
await user.clear(nums()[1]); await user.type(nums()[1], '30')
await waitFor(() => expect(nums()[2].value).toBe('70'))
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1]) // footer submit
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(100)
expect(posted!.payers).toEqual(expect.arrayContaining([
expect.objectContaining({ user_id: 1, amount: 30 }),
expect.objectContaining({ user_id: 2, amount: 70 }),
]))
})
it('accepts a comma as the decimal separator in the total amount (#1256)', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'AirTags' }), id: 6 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'AirTags')
await user.type(screen.getAllByPlaceholderText('0.00')[0], '39,99') // comma → normalized to 39.99
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1]) // footer submit
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(39.99)
})
it('marks an expense with no payer as Unfinished', async () => {
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Hotel')
expect(screen.getByText('Unfinished')).toBeInTheDocument()
})
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Hotel' }), id: 9 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel')
await user.type(screen.getAllByPlaceholderText('0.00')[0], '120') // total only, paid on-site later
// Deselect everyone — the cost is recorded without a split (the bug: this was blocked).
// The participant toggles are buttons; the same names also appear as plain text in
// the Balances sidebar, so target the buttons specifically.
await user.click(screen.getByRole('button', { name: /alice/i }))
await user.click(screen.getByRole('button', { name: /bob/i }))
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
const submit = addBtns[addBtns.length - 1] // footer submit
expect(submit).not.toBeDisabled()
await user.click(submit)
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(120)
expect(posted!.member_ids).toEqual([])
expect(posted!.payers).toEqual([])
})
})
+148 -328
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useCallback } from 'react' import { useState, useEffect, useMemo, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2 } from 'lucide-react' import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, Check, RotateCcw, History, Pencil, Trash2 } from 'lucide-react'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
@@ -39,12 +39,6 @@ interface SettlementData {
settlements: Settlement[] settlements: Settlement[]
} }
// One row in the unified Costs ledger — either an expense or a settle-up payment,
// carrying the date used to group it by day.
type LedgerEntry =
| { kind: 'expense'; date: string; e: BudgetItem }
| { kind: 'payment'; date: string; s: Settlement }
const round2 = (n: number) => Math.round(n * 100) / 100 const round2 = (n: number) => Math.round(n * 100) / 100
const FIELD_H = 40 // shared height for the amount / currency / day row in the modal const FIELD_H = 40 // shared height for the amount / currency / day row in the modal
@@ -68,10 +62,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const [settlement, setSettlement] = useState<SettlementData | null>(null) const [settlement, setSettlement] = useState<SettlementData | null>(null)
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all') const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [histOpen, setHistOpen] = useState(false)
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<BudgetItem | null>(null) const [editing, setEditing] = useState<BudgetItem | null>(null)
const [editingSettlement, setEditingSettlement] = useState<Settlement | null>(null)
const [addingPayment, setAddingPayment] = useState(false)
const people = tripMembers const people = tripMembers
const personById = useCallback((id: number) => people.find(p => p.id === id), [people]) const personById = useCallback((id: number) => people.find(p => p.id === id), [people])
@@ -129,37 +122,21 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
return list return list
}, [budgetItems, filter, search, me]) }, [budgetItems, filter, search, me])
// Settlements ("payments") shown inline in the ledger. They have no name, so a
// text search hides them; they're excluded from the "owed" expense filter and,
// under "mine", only show transfers I'm part of.
const filteredSettlements = useMemo(() => {
if (search.trim()) return []
if (filter === 'owed') return []
let list = settlement?.settlements || []
if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me)
return list
}, [settlement, filter, search, me])
const dayGroups = useMemo(() => { const dayGroups = useMemo(() => {
const entries: LedgerEntry[] = [ const groups: { day: string; items: BudgetItem[] }[] = []
...filtered.map(e => ({ kind: 'expense' as const, date: e.expense_date || '', e })), const labelOf = (e: BudgetItem) => {
...filteredSettlements.map(s => ({ kind: 'payment' as const, date: (s.created_at || '').slice(0, 10), s })), if (!e.expense_date) return t('costs.noDate')
] try { return new Date(e.expense_date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return e.expense_date }
const labelOf = (date: string) => {
if (!date) return t('costs.noDate')
try { return new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return date }
} }
// Newest day first; within a day, expenses before payments (insertion order). const sorted = filtered.slice().sort((a, b) => (b.expense_date || '').localeCompare(a.expense_date || ''))
const sorted = entries.slice().sort((a, b) => (b.date || '').localeCompare(a.date || '')) for (const e of sorted) {
const groups: { day: string; entries: LedgerEntry[] }[] = [] const day = labelOf(e)
for (const en of sorted) {
const day = labelOf(en.date)
let g = groups.find(x => x.day === day) let g = groups.find(x => x.day === day)
if (!g) { g = { day, entries: [] }; groups.push(g) } if (!g) { g = { day, items: [] }; groups.push(g) }
g.entries.push(en) g.items.push(e)
} }
return groups return groups
}, [filtered, filteredSettlements, locale, t]) }, [filtered, locale, t])
// ── settle actions ────────────────────────────────────────────────────── // ── settle actions ──────────────────────────────────────────────────────
const settleFlow = async (fromId: number, toId: number, amount: number) => { const settleFlow = async (fromId: number, toId: number, amount: number) => {
@@ -223,17 +200,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 24, marginBottom: 28, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 24, marginBottom: 28, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{dateMeta && ( {dateMeta && (
<span className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 999, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, whiteSpace: 'nowrap' }}> <span className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 999, fontSize: 13, fontWeight: 500, whiteSpace: 'nowrap' }}>
{dateMeta.range} · <b className="text-content">{t('costs.daysCount', { count: dateMeta.days })}</b> {dateMeta.range} · <b className="text-content">{t('costs.daysCount', { count: dateMeta.days })}</b>
</span> </span>
)} )}
<span className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 14px 8px 10px', borderRadius: 999, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}> <span className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 14px 8px 10px', borderRadius: 999, fontSize: 13, fontWeight: 500 }}>
<span style={{ display: 'inline-flex' }}> <span style={{ display: 'inline-flex' }}>
{people.slice(0, 4).map((p, i) => { {people.slice(0, 4).map((p, i) => {
const common = { width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--bg-card)', marginLeft: i ? -8 : 0, flexShrink: 0 } as const const common = { width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--bg-card)', marginLeft: i ? -8 : 0, flexShrink: 0 } as const
return p.avatar_url return p.avatar_url
? <img key={p.id} src={p.avatar_url} alt="" style={{ ...common, objectFit: 'cover', display: 'block' }} /> ? <img key={p.id} src={p.avatar_url} alt="" style={{ ...common, objectFit: 'cover', display: 'block' }} />
: <span key={p.id} style={{ ...common, background: colorFor(p.id), color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span> : <span key={p.id} style={{ ...common, background: colorFor(p.id), color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>
})} })}
</span> </span>
<b className="text-content">{t('costs.travelers', { count: people.length })}</b> <b className="text-content">{t('costs.travelers', { count: people.length })}</b>
@@ -243,12 +220,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', gap: 10 }}> <div style={{ display: 'flex', gap: 10 }}>
<button onClick={settleAll} disabled={!(settlement?.flows || []).length} <button onClick={settleAll} disabled={!(settlement?.flows || []).length}
className="bg-surface-card border border-edge text-content disabled:opacity-40" className="bg-surface-card border border-edge text-content disabled:opacity-40"
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 16px', borderRadius: 12, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}> style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 16px', borderRadius: 12, fontSize: 14, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
<Check size={16} /> {t('costs.settleUp')} <Check size={16} /> {t('costs.settleUp')}
</button> </button>
<button onClick={() => { setEditing(null); setModalOpen(true) }} <button onClick={() => { setEditing(null); setModalOpen(true) }}
className="bg-[var(--text-primary)] text-[var(--bg-primary)]" className="bg-[var(--text-primary)] text-[var(--bg-primary)]"
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 18px', borderRadius: 12, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}> style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 18px', borderRadius: 12, fontSize: 14, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={16} /> {t('costs.addExpense')} <Plus size={16} /> {t('costs.addExpense')}
</button> </button>
</div> </div>
@@ -277,20 +254,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{/* expenses */} {/* expenses */}
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, gap: 12, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, gap: 12, flexWrap: 'wrap' }}>
<h3 className="text-content" style={{ fontSize: 'calc(24px * var(--fs-scale-title, 1))', fontWeight: 600, letterSpacing: '-0.025em', margin: 0 }}> <h3 className="text-content" style={{ fontSize: 24, fontWeight: 600, letterSpacing: '-0.025em', margin: 0 }}>
{t('costs.expenses')} {t('costs.expenses')}
</h3> </h3>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 6, borderRadius: 10, padding: '0 10px', height: 34 }}> <div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 6, borderRadius: 10, padding: '0 10px', height: 34 }}>
<Search size={15} className="text-content-faint" /> <Search size={15} className="text-content-faint" />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')} <input value={search} onChange={e => setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')}
className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', width: 150, fontFamily: 'inherit' }} /> className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 13, width: 150, fontFamily: 'inherit' }} />
</div> </div>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}> <div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
{(['all', 'mine', 'owed'] as const).map(f => ( {(['all', 'mine', 'owed'] as const).map(f => (
<button key={f} onClick={() => setFilter(f)} <button key={f} onClick={() => setFilter(f)}
className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '6px 11px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 7, fontWeight: 500, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}> style={{ padding: '6px 11px', fontSize: 12, borderRadius: 7, fontWeight: 500, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.filter.' + f)} {t('costs.filter.' + f)}
</button> </button>
))} ))}
@@ -303,16 +280,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{search ? t('costs.noMatch') : t('costs.emptyText')} {search ? t('costs.noMatch') : t('costs.emptyText')}
</div> </div>
) : dayGroups.map(g => { ) : dayGroups.map(g => {
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0) const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
return ( return (
<div key={g.day} style={{ marginBottom: 22 }}> <div key={g.day} style={{ marginBottom: 22 }}>
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}> <div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}>
{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span> {g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 12 }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{g.entries.map(en => en.kind === 'expense' {g.items.map(e => <ExpenseRow key={e.id} e={e} />)}
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}
</div> </div>
</div> </div>
) )
@@ -325,13 +300,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}> <div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div> <div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
{canEdit && ( <button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)}
<button onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-secondary border border-edge disabled:opacity-40"
className="text-content-muted bg-surface-secondary border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}> <History size={13} /> {t('costs.history')}{(settlement?.settlements || []).length ? ` (${settlement!.settlements.length})` : ''}
<Plus size={13} /> {t('costs.addPayment')} </button>
</button>
)}
</div> </div>
<SettleFlows /> <SettleFlows />
</div> </div>
@@ -357,11 +330,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} /> onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} />
)} )}
{(editingSettlement || addingPayment) && ( <Modal isOpen={histOpen} onClose={() => setHistOpen(false)} title={t('costs.settleHistory')} size="md">
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement} <SettleHistory settlements={settlement?.settlements || []} fmt={fmt} Avatar={Avatar} name={personName} onUndo={undoSettlement} canEdit={canEdit} />
onClose={() => { setEditingSettlement(null); setAddingPayment(false) }} </Modal>
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
)}
<style>{` <style>{`
.costs-root { .costs-root {
@@ -407,8 +378,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
if (flows.length === 0) return ( if (flows.length === 0) return (
<div style={{ textAlign: 'center', padding: '14px 8px' }}> <div style={{ textAlign: 'center', padding: '14px 8px' }}>
<div style={{ width: 46, height: 46, borderRadius: '50%', margin: '0 auto 10px', display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><Check size={22} /></div> <div style={{ width: 46, height: 46, borderRadius: '50%', margin: '0 auto 10px', display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><Check size={22} /></div>
<div className="text-content" style={{ fontSize: 'calc(14.5px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('costs.everyoneSquare')}</div> <div className="text-content" style={{ fontSize: 14.5, fontWeight: 600 }}>{t('costs.everyoneSquare')}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 2 }}>{t('costs.nothingOutstanding')}</div> <div className="text-content-faint" style={{ fontSize: 12, marginTop: 2 }}>{t('costs.nothingOutstanding')}</div>
</div> </div>
) )
return ( return (
@@ -419,8 +390,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<Avatar id={f.from.user_id} size={32} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={f.to.user_id} size={32} /> <Avatar id={f.from.user_id} size={32} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={f.to.user_id} size={32} />
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700 }}>{fmt(f.amount)}</span> <span className="text-content" style={{ fontSize: 14, fontWeight: 700 }}>{fmt(f.amount)}</span>
{canEdit && <button onClick={() => settleFlow(f.from.user_id, f.to.user_id, f.amount)} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '7px 12px', borderRadius: 9, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>{t('costs.settle')}</button>} {canEdit && <button onClick={() => settleFlow(f.from.user_id, f.to.user_id, f.amount)} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '7px 12px', borderRadius: 9, fontSize: 12, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>{t('costs.settle')}</button>}
</div> </div>
</div> </div>
))} ))}
@@ -434,14 +405,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, paddingTop: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16, paddingTop: 8 }}>
{/* Total card */} {/* Total card */}
<section style={{ background: 'linear-gradient(135deg,#1f2937,#111827)', color: '#fff', borderRadius: 22, padding: '20px 20px 16px', boxShadow: '0 8px 24px -8px rgba(0,0,0,0.28)' }}> <section style={{ background: 'linear-gradient(135deg,#1f2937,#111827)', color: '#fff', borderRadius: 22, padding: '20px 20px 16px', boxShadow: '0 8px 24px -8px rgba(0,0,0,0.28)' }}>
<div style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', textTransform: 'uppercase', letterSpacing: '0.12em', color: 'rgba(255,255,255,0.6)', fontWeight: 600 }}>{t('costs.totalSpend')}</div> <div style={{ fontSize: 11.5, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'rgba(255,255,255,0.6)', fontWeight: 600 }}>{t('costs.totalSpend')}</div>
<div style={{ fontSize: 'calc(44px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.04em', lineHeight: 1, marginTop: 8, display: 'flex', alignItems: 'baseline' }}>{bigMoney(totals.totalSpend, 24, 'rgba(255,255,255,0.6)')}</div> <div style={{ fontSize: 44, fontWeight: 700, letterSpacing: '-0.04em', lineHeight: 1, marginTop: 8, display: 'flex', alignItems: 'baseline' }}>{bigMoney(totals.totalSpend, 24, 'rgba(255,255,255,0.6)')}</div>
<div style={{ display: 'flex', gap: 18, marginTop: 12, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'rgba(255,255,255,0.6)', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 18, marginTop: 12, fontSize: 12, color: 'rgba(255,255,255,0.6)', flexWrap: 'wrap' }}>
<span>{t('costs.yourShare')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myShare)}</b></span> <span>{t('costs.yourShare')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myShare)}</b></span>
<span>{t('costs.youPaid')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myPaid)}</b></span> <span>{t('costs.youPaid')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myPaid)}</b></span>
</div> </div>
{canEdit && ( {canEdit && (
<button onClick={() => { setEditing(null); setModalOpen(true) }} style={{ marginTop: 16, width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, background: 'rgba(255,255,255,0.14)', border: '1px solid rgba(255,255,255,0.16)', color: '#fff', padding: 13, borderRadius: 14, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}> <button onClick={() => { setEditing(null); setModalOpen(true) }} style={{ marginTop: 16, width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, background: 'rgba(255,255,255,0.14)', border: '1px solid rgba(255,255,255,0.16)', color: '#fff', padding: 13, borderRadius: 14, fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={17} /> {t('costs.addExpense')} <Plus size={17} /> {t('costs.addExpense')}
</button> </button>
)} )}
@@ -451,51 +422,47 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}> <div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', marginBottom: 10, background: '#dc262622', color: '#dc2626' }}><ArrowDown size={17} /></div> <div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', marginBottom: 10, background: '#dc262622', color: '#dc2626' }}><ArrowDown size={17} /></div>
<div className="text-content" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('costs.youOwe')}</div> <div className="text-content" style={{ fontSize: 12.5, fontWeight: 600 }}>{t('costs.youOwe')}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}>{t('costs.youOweSub')}</div> <div className="text-content-faint" style={{ fontSize: 10.5 }}>{t('costs.youOweSub')}</div>
<div style={{ fontSize: 'calc(27px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, marginTop: 12, display: 'flex', alignItems: 'baseline', color: '#dc2626' }}>{bigMoney(totals.owe, 16, 'var(--c-ink3)')}</div> <div style={{ fontSize: 27, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, marginTop: 12, display: 'flex', alignItems: 'baseline', color: '#dc2626' }}>{bigMoney(totals.owe, 16, 'var(--c-ink3)')}</div>
</div> </div>
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}> <div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', marginBottom: 10, background: '#16a34a22', color: '#16a34a' }}><ArrowUp size={17} /></div> <div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', marginBottom: 10, background: '#16a34a22', color: '#16a34a' }}><ArrowUp size={17} /></div>
<div className="text-content" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('costs.youreOwed')}</div> <div className="text-content" style={{ fontSize: 12.5, fontWeight: 600 }}>{t('costs.youreOwed')}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}>{t('costs.youreOwedSub')}</div> <div className="text-content-faint" style={{ fontSize: 10.5 }}>{t('costs.youreOwedSub')}</div>
<div style={{ fontSize: 'calc(27px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, marginTop: 12, display: 'flex', alignItems: 'baseline', color: '#16a34a' }}>{bigMoney(totals.owed, 16, 'var(--c-ink3)')}</div> <div style={{ fontSize: 27, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, marginTop: 12, display: 'flex', alignItems: 'baseline', color: '#16a34a' }}>{bigMoney(totals.owed, 16, 'var(--c-ink3)')}</div>
</div> </div>
</div> </div>
{/* Settle up */} {/* Settle up */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}> <div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
<div className="text-content" style={{ fontSize: 'calc(19px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div> <div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 12, fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
{canEdit && ( <button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)} className="text-content-muted bg-surface-card border border-edge disabled:opacity-40" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><History size={13} /> {t('costs.history')}</button>
<button onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button>
)}
</div> </div>
<SettleFlows /> <SettleFlows />
</div> </div>
{/* Expenses */} {/* Expenses */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="text-content" style={{ fontSize: 'calc(19px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.02em' }}>{t('costs.expenses')}</div> <div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>{t('costs.expenses')}</div>
<div className="bg-surface-card border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 8, borderRadius: 12, padding: '0 12px', height: 42 }}> <div className="bg-surface-card border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 8, borderRadius: 12, padding: '0 12px', height: 42 }}>
<Search size={16} className="text-content-faint" /> <Search size={16} className="text-content-faint" />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')} className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 'calc(14px * var(--fs-scale-body, 1))', width: '100%', fontFamily: 'inherit' }} /> <input value={search} onChange={e => setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')} className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 14, width: '100%', fontFamily: 'inherit' }} />
</div> </div>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 11, padding: 3, gap: 2 }}> <div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 11, padding: 3, gap: 2 }}>
{(['all', 'mine', 'owed'] as const).map(f => ( {(['all', 'mine', 'owed'] as const).map(f => (
<button key={f} onClick={() => setFilter(f)} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} style={{ flex: 1, padding: '8px 6px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>{t('costs.filter.' + f)}</button> <button key={f} onClick={() => setFilter(f)} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} style={{ flex: 1, padding: '8px 6px', fontSize: 12.5, fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>{t('costs.filter.' + f)}</button>
))} ))}
</div> </div>
{dayGroups.length === 0 {dayGroups.length === 0
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div> ? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 13 }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
: dayGroups.map(g => { : dayGroups.map(g => {
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0) const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
return ( return (
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div> <div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 11.5 }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.entries.map(en => en.kind === 'expense' <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}</div>
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}</div>
</div> </div>
) )
})} })}
@@ -523,31 +490,15 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const cur = curOf(e) const cur = curOf(e)
const payers = (e.payers || []).filter(p => p.amount > 0) const payers = (e.payers || []).filter(p => p.amount > 0)
const net = round2(myPaidOf(e) - myShareOf(e)) const net = round2(myPaidOf(e) - myShareOf(e))
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
// total but stays out of settlements until who-paid is filled in.
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
return ( return (
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}> <div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
<span style={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}> <span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></span>
<Icon size={21} />
{isMobile && isUnfinished && (
<span title={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
)}
</span>
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}> <div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{e.name}</div>
<span className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{e.name}</span>
{isUnfinished && !isMobile && (
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 800 }}>!</span>
{t('costs.unfinished')}
</span>
)}
</div>
{payers.length > 0 && ( {payers.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
{payers.map(p => ( {payers.map(p => (
<span key={p.user_id} className="bg-surface-secondary border border-edge" title={personName(p.user_id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}> <span key={p.user_id} className="bg-surface-secondary border border-edge" title={personName(p.user_id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 11.5 }}>
<Avatar id={p.user_id} size={18} /> <Avatar id={p.user_id} size={18} />
<span className="text-content" style={{ fontWeight: 700 }}>{fmt(convert(p.amount, cur))}</span> <span className="text-content" style={{ fontWeight: 700 }}>{fmt(convert(p.amount, cur))}</span>
</span> </span>
@@ -555,16 +506,16 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div> </div>
)} )}
{!isMobile && ( {!isMobile && (
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> <div className="text-content-faint" style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{t(c.labelKey)}{cur !== base ? ` · ${fmt(e.total_price, cur)}${fmt(baseTotal(e))}` : ''} {t(c.labelKey)}{cur !== base ? ` · ${fmt(e.total_price, cur)}${fmt(baseTotal(e))}` : ''}
</div> </div>
)} )}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}> <div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<div className="text-content" style={{ fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{fmt(baseTotal(e))}</div> <div className="text-content" style={{ fontSize: 18, fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
{!isUnfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && ( {(e.members || []).length > 0 && Math.abs(net) > 0.01 && (
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}> <div style={{ fontSize: 12, marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })} {net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
</div> </div>
)} )}
@@ -580,32 +531,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
) )
} }
// A settle-up payment as a ledger row — visually distinct from an expense, with
// inline edit + undo (reuses deleteSettlement) so it isn't buried in a modal.
function SettlementRow({ s }: { s: Settlement }) {
return (
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><ArrowLeftRight size={21} /></span>
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, marginBottom: 6 }}>{t('costs.payment')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }} title={`${personName(s.from_user_id)}${personName(s.to_user_id)}`}>
<Avatar id={s.from_user_id} size={20} /><ArrowRight size={13} className="text-content-faint" /><Avatar id={s.to_user_id} size={20} />
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{personName(s.from_user_id)} {personName(s.to_user_id)}</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
<div className="text-content" style={{ fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(s.amount)}</div>
{canEdit && (
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
<button title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="bg-surface-secondary border border-edge" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer', color: '#dc2626' }}><RotateCcw size={13} /></button>
</div>
)}
</div>
</div>
)
}
function BalancesList({ balances }: { balances: SettlementData['balances'] }) { function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 }) const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance))) const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
@@ -618,14 +543,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div key={r.user_id} style={{ display: 'grid', gridTemplateColumns: '28px 1fr auto', gap: 10, alignItems: 'center' }}> <div key={r.user_id} style={{ display: 'grid', gridTemplateColumns: '28px 1fr auto', gap: 10, alignItems: 'center' }}>
<Avatar id={r.user_id} size={28} /> <Avatar id={r.user_id} size={28} />
<div> <div>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{personName(r.user_id)}</div> <div className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{personName(r.user_id)}</div>
<div className="bg-surface-secondary" style={{ height: 5, borderRadius: 3, marginTop: 5, position: 'relative', overflow: 'hidden' }}> <div className="bg-surface-secondary" style={{ height: 5, borderRadius: 3, marginTop: 5, position: 'relative', overflow: 'hidden' }}>
<span style={{ position: 'absolute', left: '50%', top: -1, bottom: -1, width: 1, background: 'var(--border-primary)' }} /> <span style={{ position: 'absolute', left: '50%', top: -1, bottom: -1, width: 1, background: 'var(--border-primary)' }} />
{pos && <span style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: pct / 2 + '%', background: '#16a34a', borderRadius: 3 }} />} {pos && <span style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: pct / 2 + '%', background: '#16a34a', borderRadius: 3 }} />}
{neg && <span style={{ position: 'absolute', right: '50%', top: 0, bottom: 0, width: pct / 2 + '%', background: '#dc2626', borderRadius: 3 }} />} {neg && <span style={{ position: 'absolute', right: '50%', top: 0, bottom: 0, width: pct / 2 + '%', background: '#dc2626', borderRadius: 3 }} />}
</div> </div>
</div> </div>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, textAlign: 'right', color: pos ? '#16a34a' : neg ? '#dc2626' : 'var(--text-faint)' }}> <div style={{ fontSize: 13, fontWeight: 600, textAlign: 'right', color: pos ? '#16a34a' : neg ? '#dc2626' : 'var(--text-faint)' }}>
{pos ? '+' + fmt(r.balance) : neg ? '' + fmt(-r.balance) : fmt(0)} {pos ? '+' + fmt(r.balance) : neg ? '' + fmt(-r.balance) : fmt(0)}
</div> </div>
</div> </div>
@@ -637,21 +562,19 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
function CategoryBreakdown() { function CategoryBreakdown() {
const tot: Record<string, number> = {} const tot: Record<string, number> = {}
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) } let grand = 0
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e); grand += baseTotal(e) }
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0)) const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>{t('costs.noCategories')}</div> if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
// Bars are scaled relative to the most expensive category (the top row fills the
// bar), not to the trip grand total — makes the relative ranking readable.
const maxCat = Math.max(0, ...rows.map(c => tot[c.key] || 0))
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{rows.map(c => { {rows.map(c => {
const v = tot[c.key]; const pct = maxCat ? v / maxCat * 100 : 0 const v = tot[c.key]; const pct = grand ? v / grand * 100 : 0
return ( return (
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}> <div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} /> <span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{t(c.labelKey)}</span> <span className="text-content" style={{ fontSize: 13, fontWeight: 500 }}>{t(c.labelKey)}</span>
<span className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{fmt0(v)}</span> <span className="text-content-muted" style={{ fontSize: 13, fontWeight: 600 }}>{fmt0(v)}</span>
<div className="bg-surface-secondary" style={{ gridColumn: '1 / -1', height: 5, borderRadius: 3, overflow: 'hidden', marginTop: -2 }}> <div className="bg-surface-secondary" style={{ gridColumn: '1 / -1', height: 5, borderRadius: 3, overflow: 'hidden', marginTop: -2 }}>
<span style={{ display: 'block', height: '100%', width: pct + '%', background: c.color, borderRadius: 3 }} /> <span style={{ display: 'block', height: '100%', width: pct + '%', background: c.color, borderRadius: 3 }} />
</div> </div>
@@ -682,16 +605,16 @@ function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }:
<div style={{ display: 'flex', alignItems: 'center', gap: 11 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 11 }}>
<span style={{ width: 36, height: 36, borderRadius: 11, display: 'grid', placeItems: 'center', background: total ? 'rgba(255,255,255,0.12)' : (accent + '22'), color: total ? '#fff' : accent }}>{icon}</span> <span style={{ width: 36, height: 36, borderRadius: 11, display: 'grid', placeItems: 'center', background: total ? 'rgba(255,255,255,0.12)' : (accent + '22'), color: total ? '#fff' : accent }}>{icon}</span>
<div> <div>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }} className={total ? '' : 'text-content'}>{label}</div> <div style={{ fontSize: 13, fontWeight: 600 }} className={total ? '' : 'text-content'}>{label}</div>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', opacity: total ? 0.6 : 1 }} className={total ? '' : 'text-content-faint'}>{sub}</div> <div style={{ fontSize: 12, opacity: total ? 0.6 : 1 }} className={total ? '' : 'text-content-faint'}>{sub}</div>
</div> </div>
</div> </div>
<div style={{ fontSize: 'calc(46px * var(--fs-scale-title, 1))', fontWeight: 600, letterSpacing: '-0.035em', lineHeight: 1, marginTop: 20, display: 'flex', alignItems: 'baseline', color: total ? '#fff' : accent }}> <div style={{ fontSize: 46, fontWeight: 600, letterSpacing: '-0.035em', lineHeight: 1, marginTop: 20, display: 'flex', alignItems: 'baseline', color: total ? '#fff' : accent }}>
{parts {parts
? parts.map((p, i) => <span key={i} style={big(p) ? undefined : { fontSize: 'calc(26px * var(--fs-scale-title, 1))', fontWeight: 500, color: muted }}>{p.value}</span>) ? parts.map((p, i) => <span key={i} style={big(p) ? undefined : { fontSize: 26, fontWeight: 500, color: muted }}>{p.value}</span>)
: <span>{formatMoney(amount, currency, locale)}</span>} : <span>{formatMoney(amount, currency, locale)}</span>}
</div> </div>
<div style={{ marginTop: 16, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', opacity: total ? 0.85 : 1 }}>{foot}</div> <div style={{ marginTop: 16, fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', opacity: total ? 0.85 : 1 }}>{foot}</div>
</div> </div>
) )
} }
@@ -702,7 +625,7 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span className="text-content-faint">{lead}</span> <span className="text-content-faint">{lead}</span>
{uniq.map(id => ( {uniq.map(id => (
<span key={id} className="bg-surface-secondary border border-edge text-content" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}> <span key={id} className="bg-surface-secondary border border-edge text-content" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 12, fontWeight: 600 }}>
<Avatar id={id} size={18} />{name(id)} <Avatar id={id} size={18} />{name(id)}
</span> </span>
))} ))}
@@ -710,75 +633,37 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
) )
} }
// Add or edit a settle-up payment (from / to / amount). Reachable inline from the function SettleHistory({ settlements, fmt, Avatar, name, onUndo, canEdit }: {
// ledger row and from a manual "Add payment" button, so recording "I sent money to settlements: Settlement[]; fmt: (v: number) => string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string; onUndo: (id: number) => void; canEdit: boolean
// X" works the same whether or not there's an outstanding expense behind it.
function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; onClose: () => void; onSaved: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const toast = useToast() if (settlements.length === 0) return <div className="text-content-faint" style={{ textAlign: 'center', padding: 30, fontSize: 13 }}>{t('costs.noSettlements')}</div>
const otherDefault = people.find(p => p.id !== me)?.id ?? me const total = settlements.reduce((a, s) => a + s.amount, 0)
const [fromId, setFromId] = useState<string>(String(editing?.from_user_id ?? me))
const [toId, setToId] = useState<string>(String(editing?.to_user_id ?? otherDefault))
const [amount, setAmount] = useState<string>(editing ? String(editing.amount) : '')
const [saving, setSaving] = useState(false)
const amt = parseFloat(amount) || 0
const valid = amt > 0 && fromId !== toId
const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
const save = async () => {
if (!valid) return
setSaving(true)
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt }
try {
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
else await budgetApi.createSettlement(tripId, data)
onSaved()
} catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
}
const inputCls = 'w-full bg-surface-input border border-edge text-content'
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
return ( return (
<Modal isOpen onClose={onClose} title={editing ? t('costs.editPayment') : t('costs.addPayment')} size="md" <div>
footer={ <div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 14px', borderRadius: 12, marginBottom: 14, background: 'rgba(22,163,74,0.1)', color: '#16a34a', fontWeight: 600, fontSize: 13 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}> <span>{t('costs.paymentsSettled', { count: settlements.length })}</span><span>{fmt(total)}</span>
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addPayment')}</button>
</div>
}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label className={labelCls}>{t('costs.from')}</label>
<CustomSelect value={fromId} onChange={v => setFromId(String(v))} options={opts} style={{ width: '100%' }} />
</div>
<div>
<label className={labelCls}>{t('costs.to')}</label>
<CustomSelect value={toId} onChange={v => setToId(String(v))} options={opts} style={{ width: '100%' }} />
</div>
<div>
<label className={labelCls}>{t('costs.amount')}</label>
<input type="text" inputMode="decimal" placeholder="0.00" value={amount}
onChange={e => setAmount(e.target.value.replace(',', '.'))} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', outline: 'none', fontWeight: 600 }} />
</div>
</div> </div>
</Modal> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{settlements.map(s => (
<div key={s.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '12px 14px', borderRadius: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }} title={`${name(s.from_user_id)}${name(s.to_user_id)}`}>
<Avatar id={s.from_user_id} size={30} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={s.to_user_id} size={30} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span className="text-content" style={{ fontSize: 14, fontWeight: 600 }}>{fmt(s.amount)}</span>
{canEdit && <button onClick={() => onUndo(s.id)} className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><RotateCcw size={12} /> {t('costs.undo')}</button>}
</div>
</div>
))}
</div>
</div>
) )
} }
// ── Add / edit expense modal ─────────────────────────────────────────────── // ── Add / edit expense modal ───────────────────────────────────────────────
export interface ExpensePrefill { function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
name?: string tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; onClose: () => void; onSaved: () => void
category?: string
amount?: number
reservationId?: number
}
export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClose, onSaved }: {
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; prefill?: ExpensePrefill; onClose: () => void; onSaved: () => void
}) { }) {
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const toast = useToast() const toast = useToast()
@@ -786,99 +671,34 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const { convert } = useExchangeRates(base) const { convert } = useExchangeRates(base)
const sym = (c: string) => SYMBOLS[c] || (c + ' ') const sym = (c: string) => SYMBOLS[c] || (c + ' ')
const [name, setName] = useState(editing?.name || prefill?.name || '') const [name, setName] = useState(editing?.name || '')
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food')) const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : 'food')
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase()) const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10)) const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
// One participant list: a person is "in" the split and may have paid an amount. const [payers, setPayers] = useState<Record<number, string>>(() => {
// Entering the total auto-distributes it equally across the non-pinned participants;
// touching an amount pins it and the rest rebalance so the paid amounts always sum
// back to the total. Leaving every amount blank = an unfinished expense (counts
// toward the trip total only, never settlements, until who-paid is filled in).
const [total, setTotal] = useState<string>(() => {
if (editing) return editing.total_price ? String(editing.total_price) : ''
if (prefill?.amount != null) return String(prefill.amount)
return ''
})
const [participants, setParticipants] = useState<Set<number>>(() =>
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
const [paid, setPaid] = useState<Record<number, string>>(() => {
const m: Record<number, string> = {} const m: Record<number, string> = {}
for (const p of editing?.payers || []) if (p.amount > 0) m[p.user_id] = String(p.amount) for (const p of editing?.payers || []) m[p.user_id] = String(p.amount)
return m return m
}) })
// Amounts the user pinned by typing — kept out of the auto-rebalance. Existing const [split, setSplit] = useState<Set<number>>(() =>
// payer amounts load as pinned so opening an expense never reshuffles them. editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
const [dirty, setDirty] = useState<Set<number>>(() =>
new Set((editing?.payers || []).filter(p => p.amount > 0).map(p => p.user_id)))
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const totalNum = parseFloat(total) || 0 const payersTotal = Object.values(payers).reduce((a, v) => a + (parseFloat(v) || 0), 0)
const paidSum = round2([...participants].reduce((a, id) => a + (parseFloat(paid[id]) || 0), 0)) const each = split.size > 0 ? payersTotal / split.size : 0
const paidEntered = paidSum > 0 const valid = name.trim().length > 0 && split.size > 0 && payersTotal > 0
const balanced = Math.abs(paidSum - totalNum) < 0.01
const each = participants.size > 0 ? totalNum / participants.size : 0
// No participants = a recorded total with nobody to split with (e.g. a booking
// paid on-site later). It saves as an "unfinished" expense (#1286); selecting
// people only adds the who-owes-whom split on top.
const valid = name.trim().length > 0 && totalNum > 0 && (!paidEntered || balanced)
// Spread `amount` across `n` people in whole cents so the parts sum back exactly.
const splitCents = (amount: number, n: number): number[] => {
if (n <= 0) return []
const cents = Math.max(0, Math.round(amount * 100))
const base = Math.floor(cents / n), rem = cents - base * n
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
}
// Recompute the non-pinned participants so every paid amount sums to the total.
const rebalance = (paidMap: Record<number, string>, dirtySet: Set<number>, parts: Set<number>, totalVal: number): Record<number, string> => {
const ids = [...parts]
const free = ids.filter(id => !dirtySet.has(id))
if (free.length === 0) return paidMap
const pinnedSum = ids.filter(id => dirtySet.has(id)).reduce((a, id) => a + (parseFloat(paidMap[id]) || 0), 0)
const shares = splitCents(totalVal - pinnedSum, free.length)
const next = { ...paidMap }
free.forEach((id, i) => { next[id] = shares[i] ? String(shares[i]) : '' })
return next
}
const onTotalChange = (v: string) => {
v = v.replace(',', '.')
setTotal(v)
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
}
const onPaidChange = (id: number, v: string) => {
v = v.replace(',', '.')
const nextDirty = new Set(dirty); nextDirty.add(id)
setDirty(nextDirty)
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
}
const toggleParticipant = (id: number) => {
const nextParts = new Set(participants), nextDirty = new Set(dirty), nextPaid = { ...paid }
if (nextParts.has(id)) { nextParts.delete(id); nextDirty.delete(id); delete nextPaid[id] }
else nextParts.add(id)
setParticipants(nextParts); setDirty(nextDirty)
setPaid(rebalance(nextPaid, nextDirty, nextParts, totalNum))
}
const save = async () => { const save = async () => {
if (!valid) return if (!valid) return
setSaving(true) setSaving(true)
const payerList = [...participants] const payerList = Object.entries(payers).map(([uid, v]) => ({ user_id: Number(uid), amount: parseFloat(v) || 0 })).filter(p => p.amount > 0)
.map(id => ({ user_id: id, amount: parseFloat(paid[id]) || 0 }))
.filter(p => p.amount > 0)
const data = { const data = {
name: name.trim(), category: cat, name: name.trim(), category: cat,
// Store the actual currency the amounts were entered in; conversion to the // Store the actual currency the amounts were entered in; conversion to the
// viewer's display currency happens live (real rates), no manual rate. // viewer's display currency happens live (real rates), no manual rate.
currency, currency,
payers: payerList, member_ids: [...participants], payers: payerList, member_ids: [...split],
expense_date: day || null, expense_date: day || null,
// Always record the entered total: the server keeps it as-is for an unfinished
// expense (no payers) and otherwise re-derives it from the payer sum (== total).
total_price: totalNum,
// Link a freshly-created expense to its booking (create-from-booking flow).
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
} }
try { try {
if (editing) await updateBudgetItem(tripId, editing.id, data) if (editing) await updateBudgetItem(tripId, editing.id, data)
@@ -894,23 +714,21 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<Modal isOpen onClose={onClose} title={editing ? t('costs.editExpense') : t('costs.addExpense')} size="2xl" <Modal isOpen onClose={onClose} title={editing ? t('costs.editExpense') : t('costs.addExpense')} size="2xl"
footer={ footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button> <button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addExpense')}</button> <button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addExpense')}</button>
</div> </div>
}> }>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div> <div>
<label className={labelCls}>{t('costs.whatFor')}</label> <label className={labelCls}>{t('costs.whatFor')}</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder={t('costs.namePlaceholder')} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', outline: 'none' }} /> <input value={name} onChange={e => setName(e.target.value)} placeholder={t('costs.namePlaceholder')} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none' }} />
</div> </div>
<div> <div>
<label className={labelCls}>{t('costs.totalAmount')}</label> <label className={labelCls}>{t('costs.totalAmount')}</label>
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}> <div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
<span className="text-content-faint" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))' }}>{sym(currency)}</span> <span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
<input type="text" inputMode="decimal" placeholder="0.00" value={total} <span className="text-content" style={{ flex: 1, fontSize: 15, fontWeight: 600, paddingLeft: 6 }}>{payersTotal.toFixed(2)}</span>
onChange={e => onTotalChange(e.target.value)}
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, paddingLeft: 6, width: '100%' }} />
</div> </div>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
@@ -926,11 +744,11 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div> </div>
</div> </div>
{currency !== base && totalNum > 0 && ( {currency !== base && payersTotal > 0 && (
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}> <div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{formatMoney(totalNum, currency, locale)}</span> <span>{formatMoney(payersTotal, currency, locale)}</span>
<span className="text-content-faint"></span> <span className="text-content-faint"></span>
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(totalNum, currency), base, locale)}</span> <span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(payersTotal, currency), base, locale)}</span>
<span className="text-content-faint">· {t('costs.liveRate')}</span> <span className="text-content-faint">· {t('costs.liveRate')}</span>
</div> </div>
)} )}
@@ -943,7 +761,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
return ( return (
<button key={c.key} onClick={() => setCat(c.key)} <button key={c.key} onClick={() => setCat(c.key)}
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-muted border border-edge'} className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-muted border border-edge'}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 11px 6px 7px', borderRadius: 999, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}> style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 11px 6px 7px', borderRadius: 999, fontSize: 12.5, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
<span style={{ width: 20, height: 20, borderRadius: 6, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={12} /></span> <span style={{ width: 20, height: 20, borderRadius: 6, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={12} /></span>
{t(c.labelKey)} {t(c.labelKey)}
</button> </button>
@@ -955,37 +773,39 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<div> <div>
<label className={labelCls}>{t('costs.whoPaid')}</label> <label className={labelCls}>{t('costs.whoPaid')}</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{people.map((p, idx) => { {people.map(p => (
const on = participants.has(p.id) <div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10 }}>
return ( <span className="text-content" style={{ fontSize: 14, fontWeight: 500 }}>{p.id === me ? t('costs.you') : p.username}</span>
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10, opacity: on ? 1 : 0.5 }}> <div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<button onClick={() => toggleParticipant(p.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}> <span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
{p.avatar_url <input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={payers[p.id] || ''}
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} /> onChange={e => setPayers(prev => ({ ...prev, [p.id]: e.target.value }))}
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>} className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span>
</button>
{on ? (
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{sym(currency)}</span>
<input type="text" inputMode="decimal" placeholder="0.00" value={paid[p.id] || ''}
onChange={e => onPaidChange(p.id, e.target.value)}
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
</div>
) : (
<button onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'right' }}>{t('costs.tapToInclude')}</button>
)}
</div> </div>
</div>
))}
</div>
</div>
<div>
<label className={labelCls}>{t('costs.splitBetween')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
{people.map(p => {
const on = split.has(p.id)
return (
<button key={p.id} onClick={() => setSplit(prev => { const n = new Set(prev); n.has(p.id) ? n.delete(p.id) : n.add(p.id); return n })}
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-faint border border-edge'}
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '6px 13px 6px 7px', borderRadius: 999, fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
{p.avatar_url
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', opacity: on ? 1 : 0.45 }} />
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[people.findIndex(x => x.id === p.id) % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
{p.id === me ? t('costs.you') : p.username}
</button>
) )
})} })}
</div> </div>
<div style={{ marginTop: 10, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}> <div className="text-content-faint" style={{ marginTop: 10, fontSize: 12.5 }}>
<span className="text-content-faint"> {split.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: split.size, amount: sym(currency) + each.toFixed(2) })}
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
</span>
{paidEntered
? <span style={{ fontWeight: 600, color: balanced ? '#16a34a' : '#dc2626' }}>{sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}</span>
: (totalNum > 0 && <span style={{ color: '#d97706', fontWeight: 600 }}>{t('costs.unfinishedHint')}</span>)}
</div> </div>
</div> </div>
</div> </div>
@@ -32,32 +32,8 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k]) export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
/** /** Map any stored category (incl. legacy free-text values) to a known meta. */
* Legacy / English free-text categories (and reservation type labels) mapped to
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
* which never matched the lowercase keys and fell through to `other`.
*/
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport',
transport: 'transport', transportation: 'transport',
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
grocery: 'groceries', groceries: 'groceries',
activity: 'activities', activities: 'activities',
sightseeing: 'sightseeing', sights: 'sightseeing',
shop: 'shopping', shopping: 'shopping',
fee: 'fees', fees: 'fees',
health: 'health', medical: 'health',
tip: 'tips', tips: 'tips',
other: 'other', misc: 'other',
}
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
export function catMeta(cat: string | null | undefined): CostCategoryMeta { export function catMeta(cat: string | null | undefined): CostCategoryMeta {
if (!cat) return COST_CAT_META.other if (cat && cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory] return COST_CAT_META.other
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
} }
@@ -647,7 +647,7 @@ describe('CollabChat', () => {
let foundBigEmoji = false; let foundBigEmoji = false;
while (el) { while (el) {
const styleAttr = el.getAttribute('style'); const styleAttr = el.getAttribute('style');
if (styleAttr && styleAttr.includes('font-size: calc(40px')) { if (styleAttr && styleAttr.includes('font-size: 40px')) {
foundBigEmoji = true; foundBigEmoji = true;
break; break;
} }
+2 -2
View File
@@ -33,7 +33,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
<div style={{ <div style={{
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8,
padding: '6px 10px', borderRadius: 10, background: 'var(--bg-secondary)', padding: '6px 10px', borderRadius: 10, background: 'var(--bg-secondary)',
borderLeft: '3px solid #007AFF', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', borderLeft: '3px solid #007AFF', fontSize: 12, color: 'var(--text-muted)',
}}> }}>
<Reply size={12} style={{ flexShrink: 0, opacity: 0.5 }} /> <Reply size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
@@ -67,7 +67,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
disabled={!canEdit} disabled={!canEdit}
style={{ style={{
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20, flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
padding: '8px 14px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', lineHeight: 1.4, fontFamily: 'inherit', padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
maxHeight: 100, overflowY: 'hidden', maxHeight: 100, overflowY: 'hidden',
opacity: canEdit ? 1 : 0.5, opacity: canEdit ? 1 : 0.5,
@@ -49,7 +49,7 @@ export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: Emoj
<button key={c} onClick={() => setCat(c)} style={{ <button key={c} onClick={() => setCat(c)} style={{
flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer', flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer',
background: cat === c ? 'var(--bg-hover)' : 'transparent', background: cat === c ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-primary)', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: 'inherit', color: 'var(--text-primary)', fontSize: 10, fontWeight: 600, fontFamily: 'inherit',
}}> }}>
{c} {c}
</button> </button>
@@ -45,17 +45,17 @@ export function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
)} )}
<div style={{ padding: '8px 10px' }}> <div style={{ padding: '8px 10px' }}>
{domain && ( {domain && (
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}> <div style={{ fontSize: 10, fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}>
{data.site_name || domain} {data.site_name || domain}
</div> </div>
)} )}
{data.title && ( {data.title && (
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: own ? '#fff' : 'var(--text-primary)', lineHeight: 1.3, marginBottom: 2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}> <div style={{ fontSize: 12, fontWeight: 600, color: own ? '#fff' : 'var(--text-primary)', lineHeight: 1.3, marginBottom: 2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{data.title} {data.title}
</div> </div>
)} )}
{data.description && ( {data.description && (
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: own ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}> <div style={{ fontSize: 11, color: own ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{data.description} {data.description}
</div> </div>
)} )}
@@ -14,8 +14,8 @@ export function ChatMessages(props: any) {
{messages.length === 0 ? ( {messages.length === 0 ? (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32, textAlign: 'center' }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32, textAlign: 'center' }}>
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} /> <MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
<span style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('collab.chat.empty')}</span> <span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', opacity: 0.6, fontFamily: 'var(--font-subtext)' }}>{t('collab.chat.emptyDesc') || ''}</span> <span style={{ fontSize: 12, opacity: 0.6, fontFamily: 'var(--font-subtext)' }}>{t('collab.chat.emptyDesc') || ''}</span>
</div> </div>
) : ( ) : (
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{ <div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
@@ -25,7 +25,7 @@ export function ChatMessages(props: any) {
{hasMore && ( {hasMore && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}>
<button onClick={handleLoadMore} disabled={loadingMore} style={{ <button onClick={handleLoadMore} disabled={loadingMore} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600,
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)', color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit', borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
}}> }}>
@@ -51,13 +51,13 @@ export function ChatMessages(props: any) {
<React.Fragment key={msg.id}> <React.Fragment key={msg.id}>
{showDate && ( {showDate && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}> <span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
{formatDateSeparator(msg.created_at, t)} {formatDateSeparator(msg.created_at, t)}
</span> </span>
</div> </div>
)} )}
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontStyle: 'italic' }}> <span style={{ fontSize: 11, color: 'var(--text-faint)', fontStyle: 'italic' }}>
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)} {msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)}
</span> </span>
</div> </div>
@@ -76,7 +76,7 @@ export function ChatMessages(props: any) {
{showDate && ( {showDate && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
<span style={{ <span style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99,
letterSpacing: 0.3, textTransform: 'uppercase', letterSpacing: 0.3, textTransform: 'uppercase',
}}> }}>
@@ -103,7 +103,7 @@ export function ChatMessages(props: any) {
<div style={{ <div style={{
width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)', width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', fontSize: 11, fontWeight: 700, color: 'var(--text-muted)',
}}> }}>
{(msg.username || '?')[0].toUpperCase()} {(msg.username || '?')[0].toUpperCase()}
</div> </div>
@@ -115,7 +115,7 @@ export function ChatMessages(props: any) {
<div style={{ display: 'flex', flexDirection: 'column', alignItems: own ? 'flex-end' : 'flex-start', maxWidth: '78%', minWidth: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: own ? 'flex-end' : 'flex-start', maxWidth: '78%', minWidth: 0 }}>
{/* Username for others at group start */} {/* Username for others at group start */}
{!own && isNewGroup && ( {!own && isNewGroup && (
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}> <span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
{msg.username} {msg.username}
</span> </span>
)} )}
@@ -138,7 +138,7 @@ export function ChatMessages(props: any) {
}} }}
> >
{bigEmoji ? ( {bigEmoji ? (
<div style={{ fontSize: 'calc(40px * var(--fs-scale-title, 1))', lineHeight: 1.2, padding: '2px 0' }}> <div style={{ fontSize: 40, lineHeight: 1.2, padding: '2px 0' }}>
{msg.text} {msg.text}
</div> </div>
) : ( ) : (
@@ -146,16 +146,16 @@ export function ChatMessages(props: any) {
background: own ? '#007AFF' : 'var(--bg-secondary)', background: own ? '#007AFF' : 'var(--bg-secondary)',
color: own ? '#fff' : 'var(--text-primary)', color: own ? '#fff' : 'var(--text-primary)',
borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px', borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
fontSize: 'calc(14px * var(--fs-scale-body, 1))', lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap', fontSize: 14, lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
}}> }}>
{/* Inline reply quote */} {/* Inline reply quote */}
{hasReply && ( {hasReply && (
<div style={{ <div style={{
padding: '5px 10px', marginBottom: 4, borderRadius: 12, padding: '5px 10px', marginBottom: 4, borderRadius: 12,
background: own ? 'rgba(255,255,255,0.15)' : 'var(--bg-tertiary)', background: own ? 'rgba(255,255,255,0.15)' : 'var(--bg-tertiary)',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.3, fontSize: 12, lineHeight: 1.3,
}}> }}>
<div style={{ fontWeight: 600, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', opacity: 0.7, marginBottom: 1 }}> <div style={{ fontWeight: 600, fontSize: 11, opacity: 0.7, marginBottom: 1 }}>
{msg.reply_username || ''} {msg.reply_username || ''}
</div> </div>
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
@@ -233,7 +233,7 @@ export function ChatMessages(props: any) {
{/* Timestamp — only on last message of group */} {/* Timestamp — only on last message of group */}
{isLastInGroup && ( {isLastInGroup && (
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}> <span style={{ fontSize: 9, color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
{formatTime(msg.created_at, is12h)} {formatTime(msg.created_at, is12h)}
</span> </span>
)} )}
@@ -34,14 +34,14 @@ export function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadg
}} }}
> >
<TwemojiImg emoji={reaction.emoji} size={16} /> <TwemojiImg emoji={reaction.emoji} size={16} />
{reaction.count > 1 && <span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>} {reaction.count > 1 && <span style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>}
</button> </button>
{hover && names && ReactDOM.createPortal( {hover && names && ReactDOM.createPortal(
<div style={{ <div style={{
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)', position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)', background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8, fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
}}> }}>
{names} {names}
@@ -175,7 +175,7 @@ describe('CollabNotes', () => {
expect(document.body).toBeInTheDocument(); expect(document.body).toBeInTheDocument();
}); });
it('FE-COMP-NOTES-013: deleting a note asks for confirmation, then calls DELETE API and removes it', async () => { it('FE-COMP-NOTES-013: delete note calls DELETE API and removes it from grid', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use( server.use(
http.get('/api/trips/1/collab/notes', () => http.get('/api/trips/1/collab/notes', () =>
@@ -193,11 +193,8 @@ describe('CollabNotes', () => {
); );
render(<CollabNotes {...defaultProps} />); render(<CollabNotes {...defaultProps} />);
await screen.findByText('Remove Me'); await screen.findByText('Remove Me');
await user.click(screen.getByTitle('Delete')); const deleteBtn = screen.getByTitle('Delete');
// Deleting now asks for confirmation first — the note stays until confirmed. await user.click(deleteBtn);
expect(screen.getByText('Delete note?')).toBeInTheDocument();
expect(screen.getByText('Remove Me')).toBeInTheDocument();
await user.click(document.querySelector('button.bg-red-600') as HTMLElement);
await waitFor(() => expect(screen.queryByText('Remove Me')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('Remove Me')).not.toBeInTheDocument());
}); });
+15 -28
View File
@@ -10,7 +10,6 @@ import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket' import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import ConfirmDialog from '../shared/ConfirmDialog'
import type { User } from '../../types' import type { User } from '../../types'
import type { CollabNote } from './CollabNotes.types' import type { CollabNote } from './CollabNotes.types'
import { FONT, NOTE_COLORS } from './CollabNotes.constants' import { FONT, NOTE_COLORS } from './CollabNotes.constants'
@@ -45,7 +44,6 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
const [previewFile, setPreviewFile] = useState(null) const [previewFile, setPreviewFile] = useState(null)
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
const [activeCategory, setActiveCategory] = useState(null) const [activeCategory, setActiveCategory] = useState(null)
const [pendingDeleteNoteId, setPendingDeleteNoteId] = useState<number | null>(null)
// Empty categories (no notes yet) stored in localStorage // Empty categories (no notes yet) stored in localStorage
const [emptyCategories, setEmptyCategories] = useState(() => { const [emptyCategories, setEmptyCategories] = useState(() => {
@@ -233,7 +231,6 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
activeCategory, setActiveCategory, categoryColors, getCategoryColor, activeCategory, setActiveCategory, categoryColors, getCategoryColor,
handleCreateNote, handleUpdateNote, saveCategoryColors, handleEditSubmit, handleCreateNote, handleUpdateNote, saveCategoryColors, handleEditSubmit,
handleDeleteNoteFile, handleDeleteNote, categories, sortedNotes, handleDeleteNoteFile, handleDeleteNote, categories, sortedNotes,
pendingDeleteNoteId, setPendingDeleteNoteId,
} }
} }
@@ -243,7 +240,7 @@ function CollabNotesLoading({ t }: NotesState) {
return ( return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}> <div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-faint)' }}> <div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0, fontFamily: FONT }}> <h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0, fontFamily: FONT }}>
{t('collab.notes.title')} {t('collab.notes.title')}
</h3> </h3>
</div> </div>
@@ -263,7 +260,7 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
return ( return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
<h3 style={{ <h3 style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', margin: 0, fontFamily: FONT, fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', margin: 0, fontFamily: FONT,
letterSpacing: 0.3, textTransform: 'uppercase', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase', display: 'flex', alignItems: 'center', gap: 7,
}}> }}>
<StickyNote size={14} color="var(--text-faint)" /> <StickyNote size={14} color="var(--text-faint)" />
@@ -277,7 +274,7 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
<Settings size={14} /> <Settings size={14} />
</button>} </button>}
{canEdit && <button onClick={() => setShowNewModal(true)} {canEdit && <button onClick={() => setShowNewModal(true)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}> style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
<Plus size={12} /> <Plus size={12} />
{t('collab.notes.new')} {t('collab.notes.new')}
</button>} </button>}
@@ -292,7 +289,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
<button <button
onClick={() => setActiveCategory(null)} onClick={() => setActiveCategory(null)}
style={{ style={{
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT, flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 10, fontWeight: 600, fontFamily: FONT,
border: activeCategory === null ? '1px solid var(--accent)' : '1px solid var(--border-faint)', border: activeCategory === null ? '1px solid var(--accent)' : '1px solid var(--border-faint)',
background: activeCategory === null ? 'var(--accent)' : 'transparent', background: activeCategory === null ? 'var(--accent)' : 'transparent',
color: activeCategory === null ? 'var(--accent-text)' : 'var(--text-secondary)', color: activeCategory === null ? 'var(--accent-text)' : 'var(--text-secondary)',
@@ -306,7 +303,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
key={cat} key={cat}
onClick={() => setActiveCategory(prev => prev === cat ? null : cat)} onClick={() => setActiveCategory(prev => prev === cat ? null : cat)}
style={{ style={{
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT, flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 10, fontWeight: 600, fontFamily: FONT,
border: activeCategory === cat ? '1px solid var(--accent)' : '1px solid var(--border-faint)', border: activeCategory === cat ? '1px solid var(--accent)' : '1px solid var(--border-faint)',
background: activeCategory === cat ? 'var(--accent)' : 'transparent', background: activeCategory === cat ? 'var(--accent)' : 'transparent',
color: activeCategory === cat ? 'var(--accent-text)' : 'var(--text-secondary)', color: activeCategory === cat ? 'var(--accent-text)' : 'var(--text-secondary)',
@@ -322,7 +319,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
function CollabNotesGrid(S: NotesState) { function CollabNotesGrid(S: NotesState) {
const { const {
sortedNotes, currentUser, canEdit, handleUpdateNote, setPendingDeleteNoteId, sortedNotes, currentUser, canEdit, handleUpdateNote, handleDeleteNote,
setEditingNote, setViewingNote, setPreviewFile, getCategoryColor, tripId, t, setEditingNote, setViewingNote, setPreviewFile, getCategoryColor, tripId, t,
} = S } = S
return ( return (
@@ -334,10 +331,10 @@ function CollabNotesGrid(S: NotesState) {
padding: '48px 20px', textAlign: 'center', height: '100%', padding: '48px 20px', textAlign: 'center', height: '100%',
}}> }}>
<Pencil size={36} color="var(--text-faint)" style={{ marginBottom: 12 }} /> <Pencil size={36} color="var(--text-faint)" style={{ marginBottom: 12 }} />
<div style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4, fontFamily: FONT }}> <div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4, fontFamily: FONT }}>
{t('collab.notes.empty')} {t('collab.notes.empty')}
</div> </div>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontFamily: FONT }}> <div style={{ fontSize: 12, color: 'var(--text-faint)', fontFamily: FONT }}>
{t('collab.notes.emptyDesc') || 'Create a note to get started'} {t('collab.notes.emptyDesc') || 'Create a note to get started'}
</div> </div>
</div> </div>
@@ -355,7 +352,7 @@ function CollabNotesGrid(S: NotesState) {
currentUser={currentUser} currentUser={currentUser}
canEdit={canEdit} canEdit={canEdit}
onUpdate={handleUpdateNote} onUpdate={handleUpdateNote}
onDelete={setPendingDeleteNoteId} onDelete={handleDeleteNote}
onEdit={setEditingNote} onEdit={setEditingNote}
onView={setViewingNote} onView={setViewingNote}
onPreviewFile={setPreviewFile} onPreviewFile={setPreviewFile}
@@ -397,10 +394,10 @@ function ViewNoteModal(S: NotesState) {
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}> }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{viewingNote.title}</div> <div style={{ fontSize: 17, fontWeight: 600, color: 'var(--text-primary)' }}>{viewingNote.title}</div>
{viewingNote.category && ( {viewingNote.category && (
<span style={{ <span style={{
display: 'inline-block', marginTop: 4, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, display: 'inline-block', marginTop: 4, fontSize: 10, fontWeight: 600,
color: getCategoryColor(viewingNote.category), color: getCategoryColor(viewingNote.category),
background: `${getCategoryColor(viewingNote.category)}18`, background: `${getCategoryColor(viewingNote.category)}18`,
padding: '2px 8px', borderRadius: 6, padding: '2px 8px', borderRadius: 6,
@@ -422,11 +419,11 @@ function ViewNoteModal(S: NotesState) {
</button> </button>
</div> </div>
</div> </div>
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 'calc(14px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', lineHeight: 1.7 }}> <div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown> <Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
{(viewingNote.attachments || []).length > 0 && ( {(viewingNote.attachments || []).length > 0 && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}> <div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{(viewingNote.attachments || []).map(a => { {(viewingNote.attachments || []).map(a => {
const isImage = a.mime_type?.startsWith('image/') const isImage = a.mime_type?.startsWith('image/')
@@ -449,10 +446,10 @@ function ViewNoteModal(S: NotesState) {
}} }}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }} onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}> onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span> <span style={{ fontSize: 10, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div> </div>
)} )}
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span> <span style={{ fontSize: 9, color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
</div> </div>
) )
})} })}
@@ -473,7 +470,6 @@ export default function CollabNotes(props: CollabNotesProps) {
viewingNote, showNewModal, editingNote, previewFile, showSettings, viewingNote, showNewModal, editingNote, previewFile, showSettings,
setShowNewModal, setEditingNote, setPreviewFile, setShowSettings, setShowNewModal, setEditingNote, setPreviewFile, setShowSettings,
handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, handleUpdateNote, handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, handleUpdateNote,
handleDeleteNote, pendingDeleteNoteId, setPendingDeleteNoteId,
} = S } = S
if (loading) return <CollabNotesLoading {...S} /> if (loading) return <CollabNotesLoading {...S} />
@@ -531,15 +527,6 @@ export default function CollabNotes(props: CollabNotesProps) {
t={t} t={t}
/> />
)} )}
{/* Confirm: delete a collab note — guards against accidental deletion */}
<ConfirmDialog
isOpen={pendingDeleteNoteId !== null}
onClose={() => setPendingDeleteNoteId(null)}
onConfirm={() => { if (pendingDeleteNoteId !== null) handleDeleteNote(pendingDeleteNoteId) }}
title={t('collab.notes.confirmDeleteTitle')}
message={t('collab.notes.confirmDeleteBody')}
/>
</div> </div>
) )
} }
@@ -16,7 +16,7 @@ interface NoteCardProps {
currentUser: User currentUser: User
canEdit: boolean canEdit: boolean
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void> onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
onDelete: (noteId: number) => void onDelete: (noteId: number) => Promise<void>
onEdit: (note: CollabNote) => void onEdit: (note: CollabNote) => void
onView: (note: CollabNote) => void onView: (note: CollabNote) => void
onPreviewFile: (file: NoteFile) => void onPreviewFile: (file: NoteFile) => void
@@ -63,11 +63,11 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
}}> }}>
{!!note.pinned && <Pin size={9} color={color} style={{ flexShrink: 0 }} />} {!!note.pinned && <Pin size={9} color={color} style={{ flexShrink: 0 }} />}
<span style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden', flex: 1, minWidth: 0 }}> <span style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden', flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{note.title} {note.title}
</span> </span>
{note.category && ( {note.category && (
<span style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, color, background: `${color}18`, padding: '2px 6px', borderRadius: 99, flexShrink: 0, letterSpacing: '0.02em', textTransform: 'uppercase' }}> <span style={{ fontSize: 8, fontWeight: 600, color, background: `${color}18`, padding: '2px 6px', borderRadius: 99, flexShrink: 0, letterSpacing: '0.02em', textTransform: 'uppercase' }}>
{note.category} {note.category}
</span> </span>
)} )}
@@ -115,7 +115,7 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
marginBottom: 6, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.12s', marginBottom: 6, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.12s',
whiteSpace: 'nowrap', zIndex: 10, whiteSpace: 'nowrap', zIndex: 10,
background: 'var(--bg-card)', color: 'var(--text-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8, fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)',
}}> }}>
{author.username} {author.username}
@@ -137,7 +137,7 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{note.content && ( {note.content && (
<div className="collab-note-md" style={{ <div className="collab-note-md" style={{
fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)', lineHeight: 1.5, margin: 0, fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, margin: 0,
maxHeight: '4.5em', overflow: 'hidden', maxHeight: '4.5em', overflow: 'hidden',
wordBreak: 'break-word', fontFamily: FONT, wordBreak: 'break-word', fontFamily: FONT,
}}> }}>
@@ -151,14 +151,14 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
{/* Website */} {/* Website */}
{note.website && ( {note.website && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<span style={{ fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>Link</span> <span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>Link</span>
<WebsiteThumbnail url={note.website} tripId={tripId} color={color} /> <WebsiteThumbnail url={note.website} tripId={tripId} color={color} />
</div> </div>
)} )}
{/* Files */} {/* Files */}
{(note.attachments || []).length > 0 && ( {(note.attachments || []).length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<span style={{ fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>{t('files.title')}</span> <span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>{t('files.title')}</span>
<div style={{ display: 'flex', gap: 4 }}> <div style={{ display: 'flex', gap: 4 }}>
{(note.attachments || []).slice(0, note.website ? 1 : 2).map(a => { {(note.attachments || []).slice(0, note.website ? 1 : 2).map(a => {
const isImage = a.mime_type?.startsWith('image/') const isImage = a.mime_type?.startsWith('image/')
@@ -179,12 +179,12 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
}} }}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }} onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}> onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span> <span style={{ fontSize: 9, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div> </div>
) )
})} })}
{(note.attachments?.length || 0) > (note.website ? 1 : 2) && ( {(note.attachments?.length || 0) > (note.website ? 1 : 2) && (
<span style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', textAlign: 'center' }}>+{(note.attachments?.length || 0) - (note.website ? 1 : 2)}</span> <span style={{ fontSize: 8, color: 'var(--text-faint)', textAlign: 'center' }}>+{(note.attachments?.length || 0) - (note.website ? 1 : 2)}</span>
)} )}
</div> </div>
</div> </div>
@@ -71,7 +71,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
}} onClick={e => e.stopPropagation()}> }} onClick={e => e.stopPropagation()}>
{/* Header */} {/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}> <h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>
{t('collab.notes.categorySettings') || 'Category Settings'} {t('collab.notes.categorySettings') || 'Category Settings'}
</h3> </h3>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}> <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}>
@@ -82,7 +82,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
{/* Categories list */} {/* Categories list */}
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{allCats.length === 0 && ( {allCats.length === 0 && (
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', textAlign: 'center', padding: 16 }}> <p style={{ fontSize: 12, color: 'var(--text-faint)', textAlign: 'center', padding: 16 }}>
{t('collab.notes.noCategoriesYet') || 'No categories yet'} {t('collab.notes.noCategoriesYet') || 'No categories yet'}
</p> </p>
)} )}
@@ -119,7 +119,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
placeholder={t('collab.notes.newCategory')} placeholder={t('collab.notes.newCategory')}
style={{ style={{
flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
}} /> }} />
<button onClick={handleAddCategory} disabled={!newCatName.trim()} style={{ <button onClick={handleAddCategory} disabled={!newCatName.trim()} style={{
background: newCatName.trim() ? 'var(--accent)' : 'var(--border-primary)', color: 'var(--accent-text)', background: newCatName.trim() ? 'var(--accent)' : 'var(--border-primary)', color: 'var(--accent-text)',
@@ -133,7 +133,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
{/* Save */} {/* Save */}
<button onClick={handleSave} style={{ <button onClick={handleSave} style={{
width: '100%', borderRadius: 99, padding: '9px 14px', background: 'var(--accent)', color: 'var(--accent-text)', width: '100%', borderRadius: 99, padding: '9px 14px', background: 'var(--accent)', color: 'var(--accent-text)',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', marginTop: 8, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', marginTop: 8,
}}> }}>
{t('collab.notes.save')} {t('collab.notes.save')}
</button> </button>
@@ -21,12 +21,12 @@ export function EditableCatName({ name, onRename }: EditableCatNameProps) {
if (editing) { if (editing) {
return <input ref={inputRef} value={value} onChange={e => setValue(e.target.value)} return <input ref={inputRef} value={value} onChange={e => setValue(e.target.value)}
onBlur={save} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setValue(name); setEditing(false) } }} onBlur={save} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setValue(name); setEditing(false) } }}
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '2px 8px', background: 'var(--bg-input)', fontFamily: 'inherit', outline: 'none' }} /> style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '2px 8px', background: 'var(--bg-input)', fontFamily: 'inherit', outline: 'none' }} />
} }
return ( return (
<span onClick={() => { setValue(name); setEditing(true) }} <span onClick={() => { setValue(name); setEditing(true) }}
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }} style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }}
title="Click to rename"> title="Click to rename">
{name} {name}
</span> </span>
@@ -37,7 +37,7 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
: <Loader2 size={32} className="animate-spin text-[rgba(255,255,255,0.5)]" /> : <Loader2 size={32} className="animate-spin text-[rgba(255,255,255,0.5)]" />
} }
<div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}> <div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span> <span style={{ fontSize: 11, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button> <button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button> <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
@@ -48,21 +48,21 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
/* Document viewer — card with header */ /* Document viewer — card with header */
<div style={{ width: '100%', maxWidth: 950, height: '94vh', display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }} onClick={e => e.stopPropagation()}> <div style={{ width: '100%', maxWidth: 950, height: '94vh', display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span> <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}> <div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button> <button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button> <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
</div> </div>
</div> </div>
{(isPdf || isTxt) ? ( {(isPdf || isTxt) ? (
<object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}> <object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}> <p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download</button> <button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download</button>
</p> </p>
</object> </object>
) : ( ) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}> <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download {file.original_name}</button> <button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download {file.original_name}</button>
</div> </div>
)} )}
</div> </div>
@@ -118,7 +118,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
borderBottom: '1px solid var(--border-faint)', borderBottom: '1px solid var(--border-faint)',
}}> }}>
<h3 style={{ <h3 style={{
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontSize: 14,
fontWeight: 700, fontWeight: 700,
color: 'var(--text-primary)', color: 'var(--text-primary)',
margin: 0, margin: 0,
@@ -153,7 +153,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
{/* Title */} {/* Title */}
<div> <div>
<div style={{ <div style={{
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontSize: 9,
fontWeight: 600, fontWeight: 600,
color: 'var(--text-faint)', color: 'var(--text-faint)',
textTransform: 'uppercase', textTransform: 'uppercase',
@@ -173,7 +173,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
border: '1px solid var(--border-primary)', border: '1px solid var(--border-primary)',
borderRadius: 10, borderRadius: 10,
padding: '8px 12px', padding: '8px 12px',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontSize: 13,
background: 'var(--bg-input)', background: 'var(--bg-input)',
color: 'var(--text-primary)', color: 'var(--text-primary)',
fontFamily: 'inherit', fontFamily: 'inherit',
@@ -186,7 +186,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
{/* Content */} {/* Content */}
<div> <div>
<div style={{ <div style={{
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontSize: 9,
fontWeight: 600, fontWeight: 600,
color: 'var(--text-faint)', color: 'var(--text-faint)',
textTransform: 'uppercase', textTransform: 'uppercase',
@@ -205,7 +205,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
border: '1px solid var(--border-primary)', border: '1px solid var(--border-primary)',
borderRadius: 10, borderRadius: 10,
padding: '8px 12px', padding: '8px 12px',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontSize: 13,
background: 'var(--bg-input)', background: 'var(--bg-input)',
color: 'var(--text-primary)', color: 'var(--text-primary)',
fontFamily: 'inherit', fontFamily: 'inherit',
@@ -220,7 +220,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
{/* Category pills */} {/* Category pills */}
<div> <div>
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6, fontFamily: FONT }}> <div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6, fontFamily: FONT }}>
{t('collab.notes.category')} {t('collab.notes.category')}
</div> </div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
@@ -229,7 +229,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
const active = category === cat const active = category === cat
return ( return (
<button key={cat} type="button" onClick={() => setCategory(cat)} <button key={cat} type="button" onClick={() => setCategory(cat)}
style={{ padding: '4px 12px', borderRadius: 99, border: active ? `1.5px solid ${c}` : '1px solid var(--border-faint)', background: active ? `${c}18` : 'transparent', color: active ? c : 'var(--text-muted)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: FONT }}> style={{ padding: '4px 12px', borderRadius: 99, border: active ? `1.5px solid ${c}` : '1px solid var(--border-faint)', background: active ? `${c}18` : 'transparent', color: active ? c : 'var(--text-muted)', fontSize: 11, fontWeight: 600, cursor: 'pointer', fontFamily: FONT }}>
{cat} {cat}
</button> </button>
) )
@@ -239,17 +239,17 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
{/* Website */} {/* Website */}
<div> <div>
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}> <div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
{t('collab.notes.website')} {t('collab.notes.website')}
</div> </div>
<input value={website} onChange={e => setWebsite(e.target.value)} <input value={website} onChange={e => setWebsite(e.target.value)}
placeholder={t('collab.notes.websitePlaceholder')} placeholder={t('collab.notes.websitePlaceholder')}
style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} /> style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
</div> </div>
{/* File attachments */} {/* File attachments */}
{canUploadFiles && <div> {canUploadFiles && <div>
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}> <div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
{t('collab.notes.attachFiles')} {t('collab.notes.attachFiles')}
</div> </div>
<input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { const files = e.target.files; if (files?.length) setPendingFiles(prev => [...prev, ...Array.from(files)]); e.target.value = '' }} /> <input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { const files = e.target.files; if (files?.length) setPendingFiles(prev => [...prev, ...Array.from(files)]); e.target.value = '' }} />
@@ -258,7 +258,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
{existingAttachments.map(a => { {existingAttachments.map(a => {
const isImage = a.mime_type?.startsWith('image/') const isImage = a.mime_type?.startsWith('image/')
return ( return (
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)' }}> <div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
{isImage && <AuthedImg src={a.url} style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 3 }} />} {isImage && <AuthedImg src={a.url} style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 3 }} />}
{(a.original_name || '').length > 20 ? a.original_name.slice(0, 17) + '...' : a.original_name} {(a.original_name || '').length > 20 ? a.original_name.slice(0, 17) + '...' : a.original_name}
<button type="button" onClick={() => handleDeleteAttachment(a.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: 0, display: 'flex' }}> <button type="button" onClick={() => handleDeleteAttachment(a.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: 0, display: 'flex' }}>
@@ -269,7 +269,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
})} })}
{/* New pending files */} {/* New pending files */}
{pendingFiles.map((f, i) => ( {pendingFiles.map((f, i) => (
<div key={`new-${i}`} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)' }}> <div key={`new-${i}`} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
{f.name.length > 20 ? f.name.slice(0, 17) + '...' : f.name} {f.name.length > 20 ? f.name.slice(0, 17) + '...' : f.name}
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 0, display: 'flex' }}> <button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 0, display: 'flex' }}>
<X size={10} /> <X size={10} />
@@ -277,7 +277,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
</div> </div>
))} ))}
<button type="button" onClick={() => fileRef.current?.click()} <button type="button" onClick={() => fileRef.current?.click()}
style={{ padding: '4px 10px', borderRadius: 8, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}> style={{ padding: '4px 10px', borderRadius: 8, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 11, fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<Plus size={11} /> {t('files.attach') || 'Add'} <Plus size={11} /> {t('files.attach') || 'Add'}
</button> </button>
</div> </div>
@@ -293,7 +293,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
padding: '7px 14px', padding: '7px 14px',
background: canSubmit ? 'var(--accent)' : 'var(--border-primary)', background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)', color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontSize: 12,
fontWeight: 600, fontWeight: 600,
fontFamily: FONT, fontFamily: FONT,
border: 'none', border: 'none',
@@ -37,7 +37,7 @@ export function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps)
) : ( ) : (
<> <>
<ExternalLink size={14} color="var(--text-muted)" /> <ExternalLink size={14} color="var(--text-muted)" />
<span style={{ fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', maxWidth: 42, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}> <span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-muted)', maxWidth: 42, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}>
{domain} {domain}
</span> </span>
</> </>
+1 -1
View File
@@ -175,7 +175,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer', padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
background: active ? 'var(--accent)' : 'transparent', background: active ? 'var(--accent)' : 'transparent',
color: active ? 'var(--accent-text)' : 'var(--text-muted)', color: active ? 'var(--accent-text)' : 'var(--text-muted)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: 'inherit', fontSize: 11, fontWeight: 600, fontFamily: 'inherit',
transition: 'all 0.15s', transition: 'all 0.15s',
}}> }}>
{tab.label} {tab.label}
+22 -22
View File
@@ -88,30 +88,30 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={onClose}> <div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={onClose}>
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onClick={e => e.stopPropagation()} onSubmit={handleSubmit}> <form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onClick={e => e.stopPropagation()} onSubmit={handleSubmit}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.polls.new')}</h3> <h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.polls.new')}</h3>
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button> <button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
</div> </div>
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Question */} {/* Question */}
<div> <div>
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div> <div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div>
<input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder') || 'Ask a question...'} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} /> <input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder') || 'Ask a question...'} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
</div> </div>
{/* Options */} {/* Options */}
<div> <div>
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.options')}</div> <div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.options')}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{options.map((opt, i) => ( {options.map((opt, i) => (
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}> <div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`} <input value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`}
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} /> style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
{options.length > 2 && ( {options.length > 2 && (
<button type="button" onClick={() => removeOption(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={14} /></button> <button type="button" onClick={() => removeOption(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={14} /></button>
)} )}
</div> </div>
))} ))}
<button type="button" onClick={addOption} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 12px', borderRadius: 10, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontFamily: FONT }}> <button type="button" onClick={addOption} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 12px', borderRadius: 10, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 12, fontFamily: FONT }}>
<Plus size={12} /> {t('collab.polls.addOption')} <Plus size={12} /> {t('collab.polls.addOption')}
</button> </button>
</div> </div>
@@ -126,13 +126,13 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
}}> }}>
<div style={{ width: 16, height: 16, borderRadius: '50%', background: '#fff', transition: 'transform 0.2s', transform: multiChoice ? 'translateX(16px)' : 'translateX(0)' }} /> <div style={{ width: 16, height: 16, borderRadius: '50%', background: '#fff', transition: 'transform 0.2s', transform: multiChoice ? 'translateX(16px)' : 'translateX(0)' }} />
</div> </div>
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', fontFamily: FONT }}>{t('collab.polls.multiChoice')}</span> <span style={{ fontSize: 12, color: 'var(--text-muted)', fontFamily: FONT }}>{t('collab.polls.multiChoice')}</span>
</label> </label>
{/* Submit */} {/* Submit */}
<button type="submit" disabled={!canSubmit} style={{ <button type="submit" disabled={!canSubmit} style={{
width: '100%', borderRadius: 99, padding: '9px 14px', background: canSubmit ? 'var(--accent)' : 'var(--border-primary)', width: '100%', borderRadius: 99, padding: '9px 14px', background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: canSubmit ? 'pointer' : 'default', fontFamily: FONT, color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)', fontSize: 13, fontWeight: 600, border: 'none', cursor: canSubmit ? 'pointer' : 'default', fontFamily: FONT,
}}> }}>
{submitting ? '...' : t('collab.polls.create')} {submitting ? '...' : t('collab.polls.create')}
</button> </button>
@@ -168,7 +168,7 @@ function VoterChip({ voter, offset }: VoterChipProps) {
style={{ style={{
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)', width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', fontSize: 7, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden',
border: '1.5px solid var(--bg-card)', marginLeft: offset ? -5 : 0, flexShrink: 0, border: '1.5px solid var(--bg-card)', marginLeft: offset ? -5 : 0, flexShrink: 0,
}}> }}>
{voter.avatar_url ? <img src={voter.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (voter.username || '?')[0].toUpperCase()} {voter.avatar_url ? <img src={voter.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (voter.username || '?')[0].toUpperCase()}
@@ -178,7 +178,7 @@ function VoterChip({ voter, offset }: VoterChipProps) {
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)', position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
background: 'var(--bg-card)', color: 'var(--text-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8, fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)',
}}> }}>
{voter.username} {voter.username}
@@ -217,26 +217,26 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
background: isClosed ? 'var(--bg-secondary)' : 'transparent', background: isClosed ? 'var(--bg-secondary)' : 'transparent',
}}> }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}> <div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}>
{poll.question} {poll.question}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
{isClosed && ( {isClosed && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
<Lock size={8} /> {t('collab.polls.closed')} <Lock size={8} /> {t('collab.polls.closed')}
</span> </span>
)} )}
{remaining && !isClosed && ( {remaining && !isClosed && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: '#f59e0b', background: '#f59e0b18', padding: '2px 7px', borderRadius: 99 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, fontWeight: 600, color: '#f59e0b', background: '#f59e0b18', padding: '2px 7px', borderRadius: 99 }}>
<Clock size={8} /> {remaining} <Clock size={8} /> {remaining}
</span> </span>
)} )}
{poll.multi_choice && ( {poll.multi_choice && (
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}> <span style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
{t('collab.polls.multiChoice')} {t('collab.polls.multiChoice')}
</span> </span>
)} )}
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}> <span style={{ fontSize: 9, color: 'var(--text-faint)' }}>
{total} {total === 1 ? 'vote' : 'votes'} {total} {total === 1 ? 'vote' : 'votes'}
</span> </span>
</div> </div>
@@ -303,7 +303,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
{/* Label */} {/* Label */}
<span style={{ <span style={{
flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: myVote || isWinner ? 600 : 400, flex: 1, fontSize: 13, fontWeight: myVote || isWinner ? 600 : 400,
color: 'var(--text-primary)', position: 'relative', zIndex: 1, color: 'var(--text-primary)', position: 'relative', zIndex: 1,
}}> }}>
{typeof opt === 'string' ? opt : opt.text} {typeof opt === 'string' ? opt : opt.text}
@@ -321,7 +321,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
{/* Percentage */} {/* Percentage */}
{(hasVoted || isClosed) && ( {(hasVoted || isClosed) && (
<span style={{ <span style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: myVote ? '#007AFF' : 'var(--text-muted)', fontSize: 12, fontWeight: 700, color: myVote ? '#007AFF' : 'var(--text-muted)',
position: 'relative', zIndex: 1, minWidth: 32, textAlign: 'right', position: 'relative', zIndex: 1, minWidth: 32, textAlign: 'right',
}}> }}>
{pct}% {pct}%
@@ -443,14 +443,14 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}> <div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
{/* Header */} {/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
<h3 style={{ margin: 0, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase' }}> <h3 style={{ margin: 0, fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase' }}>
<BarChart3 size={14} color="var(--text-faint)" /> <BarChart3 size={14} color="var(--text-faint)" />
{t('collab.polls.title')} {t('collab.polls.title')}
</h3> </h3>
{canEdit && ( {canEdit && (
<button onClick={() => setShowForm(true)} style={{ <button onClick={() => setShowForm(true)} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
fontFamily: FONT, border: 'none', cursor: 'pointer', fontFamily: FONT, border: 'none', cursor: 'pointer',
}}> }}>
<Plus size={12} /> {t('collab.polls.new')} <Plus size={12} /> {t('collab.polls.new')}
@@ -463,8 +463,8 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
{polls.length === 0 ? ( {polls.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '48px 20px', textAlign: 'center', height: '100%' }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '48px 20px', textAlign: 'center', height: '100%' }}>
<BarChart3 size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} /> <BarChart3 size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
<div style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.polls.empty')}</div> <div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.polls.empty')}</div>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}>{t('collab.polls.emptyHint')}</div> <div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.polls.emptyHint')}</div>
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
@@ -474,7 +474,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
{closedPolls.length > 0 && ( {closedPolls.length > 0 && (
<> <>
{activePolls.length > 0 && ( {activePolls.length > 0 && (
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, padding: '8px 0 2px' }}> <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, padding: '8px 0 2px' }}>
{t('collab.polls.closedSection') || 'Closed'} {t('collab.polls.closedSection') || 'Closed'}
</div> </div>
)} )}
@@ -91,7 +91,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 7, flexShrink: 0, padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 7, flexShrink: 0,
}}> }}>
<Sparkles size={14} color="var(--text-faint)" /> <Sparkles size={14} color="var(--text-faint)" />
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.3, textTransform: 'uppercase' }}> <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.3, textTransform: 'uppercase' }}>
{t('collab.whatsNext.title') || "What's Next"} {t('collab.whatsNext.title') || "What's Next"}
</span> </span>
</div> </div>
@@ -101,8 +101,8 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
{upcoming.length === 0 ? ( {upcoming.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: '48px 20px', textAlign: 'center' }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: '48px 20px', textAlign: 'center' }}>
<Calendar size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} /> <Calendar size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
<div style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.whatsNext.empty')}</div> <div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.whatsNext.empty')}</div>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div> <div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div>
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
@@ -114,7 +114,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
<React.Fragment key={item.id}> <React.Fragment key={item.id}>
{showDayHeader && ( {showDayHeader && (
<div style={{ <div style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, color: 'var(--text-faint)', fontSize: 10, fontWeight: 500, color: 'var(--text-faint)',
textTransform: 'uppercase', letterSpacing: 0.5, textTransform: 'uppercase', letterSpacing: 0.5,
padding: idx === 0 ? '0 4px 4px' : '8px 4px 4px', padding: idx === 0 ? '0 4px 4px' : '8px 4px 4px',
}}> }}>
@@ -132,15 +132,15 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
> >
{/* Time column */} {/* Time column */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 44, flexShrink: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 44, flexShrink: 0 }}>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}> <span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
{item.time ? formatTime(item.time, is12h) : 'TBD'} {item.time ? formatTime(item.time, is12h) : 'TBD'}
</span> </span>
{item.endTime && ( {item.endTime && (
<> <>
<span style={{ fontSize: 'calc(7px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontWeight: 600, letterSpacing: 0.3, margin: '2px 0', textTransform: 'uppercase' }}> <span style={{ fontSize: 7, color: 'var(--text-faint)', fontWeight: 600, letterSpacing: 0.3, margin: '2px 0', textTransform: 'uppercase' }}>
{t('collab.whatsNext.until') || 'bis'} {t('collab.whatsNext.until') || 'bis'}
</span> </span>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}> <span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
{formatTime(item.endTime, is12h)} {formatTime(item.endTime, is12h)}
</span> </span>
</> </>
@@ -152,13 +152,13 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
{/* Details */} {/* Details */}
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.name} {item.name}
</div> </div>
{item.address && ( {item.address && (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 2 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 2 }}>
<MapPin size={9} color="var(--text-faint)" style={{ flexShrink: 0 }} /> <MapPin size={9} color="var(--text-faint)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.address} {item.address}
</span> </span>
</div> </div>
@@ -175,7 +175,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
<div style={{ <div style={{
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-secondary)', width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-secondary)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', fontSize: 7, fontWeight: 700, color: 'var(--text-muted)',
overflow: 'hidden', flexShrink: 0, overflow: 'hidden', flexShrink: 0,
}}> }}>
{p.avatar {p.avatar
@@ -183,7 +183,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
: p.username?.[0]?.toUpperCase() : p.username?.[0]?.toUpperCase()
} }
</div> </div>
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, color: 'var(--text-muted)' }}>{p.username}</span> <span style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)' }}>{p.username}</span>
</div> </div>
))} ))}
</div> </div>
@@ -17,8 +17,8 @@ export function AssignModal(S: FileManagerState) {
}} onClick={e => e.stopPropagation()}> }} onClick={e => e.stopPropagation()}>
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}> <div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{t('files.assignTitle')}</div> <div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('files.assignTitle')}</div>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{files.find(f => f.id === assignFileId)?.original_name || ''} {files.find(f => f.id === assignFileId)?.original_name || ''}
</div> </div>
</div> </div>
@@ -27,7 +27,7 @@ export function AssignModal(S: FileManagerState) {
</button> </button>
</div> </div>
<div style={{ padding: '8px 12px 0' }}> <div style={{ padding: '8px 12px 0' }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '0 2px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '0 2px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{t('files.noteLabel') || 'Note'} {t('files.noteLabel') || 'Note'}
</div> </div>
<input <input
@@ -43,7 +43,7 @@ export function AssignModal(S: FileManagerState) {
}} }}
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
style={{ style={{
width: '100%', padding: '7px 10px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', borderRadius: 8, width: '100%', padding: '7px 10px', fontSize: 13, borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
}} }}
@@ -91,7 +91,7 @@ export function AssignModal(S: FileManagerState) {
} }
}} style={{ }} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none', width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400, borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6, display: 'flex', alignItems: 'center', gap: 6,
}} }}
@@ -106,18 +106,18 @@ export function AssignModal(S: FileManagerState) {
const placesSection = places.length > 0 && ( const placesSection = places.length > 0 && (
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{t('files.assignPlace')} {t('files.assignPlace')}
</div> </div>
{dayGroups.map(({ day, dayPlaces }) => ( {dayGroups.map(({ day, dayPlaces }) => (
<div key={day.id}> <div key={day.id}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
<span>{day.title || t('dayplan.dayN', { n: day.day_number })}</span> <span>{day.title || t('dayplan.dayN', { n: day.day_number })}</span>
{(() => { {(() => {
const badge = day.date || (day.title ? t('dayplan.dayN', { n: day.day_number }) : null) const badge = day.date || (day.title ? t('dayplan.dayN', { n: day.day_number }) : null)
return badge ? ( return badge ? (
<span style={{ <span style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 999, background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 999,
}}>{badge}</span> }}>{badge}</span>
) : null ) : null
@@ -128,7 +128,7 @@ export function AssignModal(S: FileManagerState) {
))} ))}
{unassigned.length > 0 && ( {unassigned.length > 0 && (
<div> <div>
{dayGroups.length > 0 && <div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>{t('files.unassigned')}</div>} {dayGroups.length > 0 && <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>{t('files.unassigned')}</div>}
{unassigned.map(placeBtn)} {unassigned.map(placeBtn)}
</div> </div>
)} )}
@@ -166,7 +166,7 @@ export function AssignModal(S: FileManagerState) {
} }
}} style={{ }} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none', width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400, borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6, display: 'flex', alignItems: 'center', gap: 6,
}} }}
@@ -183,7 +183,7 @@ export function AssignModal(S: FileManagerState) {
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{bookingReservations.length > 0 && ( {bookingReservations.length > 0 && (
<> <>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{t('files.assignBooking')} {t('files.assignBooking')}
</div> </div>
{bookingReservations.map(reservationBtn)} {bookingReservations.map(reservationBtn)}
@@ -191,7 +191,7 @@ export function AssignModal(S: FileManagerState) {
)} )}
{transportReservations.length > 0 && ( {transportReservations.length > 0 && (
<> <>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5, marginTop: bookingReservations.length > 0 ? 4 : 0 }}> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5, marginTop: bookingReservations.length > 0 ? 4 : 0 }}>
{t('files.assignTransport')} {t('files.assignTransport')}
</div> </div>
{transportReservations.map(reservationBtn)} {transportReservations.map(reservationBtn)}
@@ -32,7 +32,7 @@ export function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avata
<div style={{ <div style={{
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)', position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
background: 'var(--bg-elevated)', color: 'var(--text-primary)', background: 'var(--bg-elevated)', color: 'var(--text-primary)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '3px 8px', borderRadius: 6, fontSize: 11, fontWeight: 500, padding: '3px 8px', borderRadius: 6,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', whiteSpace: 'nowrap', zIndex: 9999, boxShadow: '0 2px 8px rgba(0,0,0,0.15)', whiteSpace: 'nowrap', zIndex: 9999,
pointerEvents: 'none', pointerEvents: 'none',
}}> }}>
@@ -22,15 +22,15 @@ export function FilesView(S: FileManagerState) {
<input {...getInputProps()} /> <input {...getInputProps()} />
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} /> <Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} />
{uploading ? ( {uploading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-secondary)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
<div style={{ width: 14, height: 14, border: '2px solid var(--text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} /> <div style={{ width: 14, height: 14, border: '2px solid var(--text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
{t('files.uploading')} {t('files.uploading')}
</div> </div>
) : ( ) : (
<> <>
<p style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p> <p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
<p style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p> <p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
<p style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 6, opacity: 0.7 }}> <p style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 6, opacity: 0.7 }}>
{(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB {(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
</p> </p>
</> </>
@@ -48,14 +48,14 @@ export function FilesView(S: FileManagerState) {
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []), ...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
].map(tab => ( ].map(tab => (
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{ <button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
fontFamily: 'inherit', transition: 'all 0.12s', fontFamily: 'inherit', transition: 'all 0.12s',
background: filterType === tab.id ? 'var(--accent)' : 'transparent', background: filterType === tab.id ? 'var(--accent)' : 'transparent',
color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)', color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)',
fontWeight: filterType === tab.id ? 600 : 400, fontWeight: filterType === tab.id ? 600 : 400,
}}>{tab.icon ? <tab.icon size={13} fill={filterType === tab.id ? '#facc15' : 'none'} color={filterType === tab.id ? '#facc15' : 'currentColor'} /> : tab.label}</button> }}>{tab.icon ? <tab.icon size={13} fill={filterType === tab.id ? '#facc15' : 'none'} color={filterType === tab.id ? '#facc15' : 'currentColor'} /> : tab.label}</button>
))} ))}
<span style={{ marginLeft: 'auto', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', alignSelf: 'center' }}> <span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--text-faint)', alignSelf: 'center' }}>
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })} {filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
</span> </span>
</div> </div>
@@ -65,8 +65,8 @@ export function FilesView(S: FileManagerState) {
{filteredFiles.length === 0 ? ( {filteredFiles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}> <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' }} /> <FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p> <p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
<p style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p> <p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
@@ -71,7 +71,7 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
> >
{/* Header */} {/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }} onClick={e => e.stopPropagation()}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }} onClick={e => e.stopPropagation()}>
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}> <span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
{file.original_name} {file.original_name}
<span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span> <span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span>
</span> </span>
@@ -16,18 +16,18 @@ export function PdfPreviewModal(S: FileManagerState) {
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span> <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<button <button
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))} onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'} onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}> onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
<ExternalLink size={13} /> {t('files.openTab')} <ExternalLink size={13} /> {t('files.openTab')}
</button> </button>
<button <button
onClick={() => triggerDownload(previewFile.url, previewFile.original_name)} onClick={() => triggerDownload(previewFile.url, previewFile.original_name)}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'} onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}> onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
<Download size={13} /> {t('files.download') || 'Download'} <Download size={13} /> {t('files.download') || 'Download'}
@@ -49,7 +49,7 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
const isPdf = file.mime_type === 'application/pdf' const isPdf = file.mime_type === 'application/pdf'
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span> <span style={{ fontSize: 9, fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div> </div>
) )
})() })()
@@ -65,19 +65,19 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null} {!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
<span <span
onClick={() => !isTrash && openFile(file)} onClick={() => !isTrash && openFile(file)}
style={{ fontWeight: 500, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }} style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }}
> >
{file.original_name} {file.original_name}
</span> </span>
</div> </div>
{file.description && ( {file.description && (
<p style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', margin: '2px 0 0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p> <p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '2px 0 0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p>
)} )}
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}> <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
{file.file_size && <span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>} {file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span> <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
{linkedPlaces.map(p => ( {linkedPlaces.map(p => (
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} /> <SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
@@ -8,7 +8,7 @@ export function SourceBadge({ icon: Icon, label }: SourceBadgeProps) {
return ( return (
<span style={{ <span style={{
display: 'inline-flex', alignItems: 'center', gap: 4, display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', color: '#4b5563', fontSize: 10.5, color: '#4b5563',
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)', background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
borderRadius: 6, padding: '2px 7px', borderRadius: 6, padding: '2px 7px',
fontWeight: 500, maxWidth: '100%', overflow: 'hidden', fontWeight: 500, maxWidth: '100%', overflow: 'hidden',
@@ -10,7 +10,7 @@ export function FileManagerToolbar(S: FileManagerState) {
padding: '14px 16px 14px 22px', padding: '14px 16px 14px 22px',
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}> }}>
<h2 style={{ margin: 0, fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}> <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')} {showTrash ? (t('files.trash') || 'Trash') : t('files.title')}
</h2> </h2>
@@ -40,7 +40,7 @@ export function FileManagerToolbar(S: FileManagerState) {
style={{ style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6, display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 99, fontSize: 'calc(13px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
background: active ? 'var(--bg-card)' : 'transparent', background: active ? 'var(--bg-card)' : 'transparent',
color: active ? 'var(--text-primary)' : 'var(--text-muted)', color: active ? 'var(--text-primary)' : 'var(--text-muted)',
fontWeight: active ? 500 : 400, fontWeight: active ? 500 : 400,
@@ -51,7 +51,7 @@ export function FileManagerToolbar(S: FileManagerState) {
{TabIcon ? <TabIcon size={13} fill={active ? '#facc15' : 'none'} color={active ? '#facc15' : 'currentColor'} /> : null} {TabIcon ? <TabIcon size={13} fill={active ? '#facc15' : 'none'} color={active ? '#facc15' : 'currentColor'} /> : null}
{'label' in tab && tab.label} {'label' in tab && tab.label}
<span style={{ <span style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontSize: 10, fontWeight: 600,
background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)', background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
color: 'var(--text-faint)', color: 'var(--text-faint)',
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center', padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
@@ -66,7 +66,7 @@ export function FileManagerToolbar(S: FileManagerState) {
<button onClick={toggleTrash} style={{ <button onClick={toggleTrash} style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6, display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)', background: 'var(--accent)', color: 'var(--accent-text)',
flexShrink: 0, marginLeft: 'auto', flexShrink: 0, marginLeft: 'auto',
opacity: showTrash ? 1 : 0.88, opacity: showTrash ? 1 : 0.88,
@@ -10,7 +10,7 @@ export function TrashView(S: FileManagerState) {
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
<button onClick={handleEmptyTrash} style={{ <button onClick={handleEmptyTrash} style={{
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca', padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
background: '#fef2f2', color: '#dc2626', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, background: '#fef2f2', color: '#dc2626', fontSize: 12, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit', cursor: 'pointer', fontFamily: 'inherit',
}}> }}>
{t('files.emptyTrash') || 'Empty Trash'} {t('files.emptyTrash') || 'Empty Trash'}
@@ -24,7 +24,7 @@ export function TrashView(S: FileManagerState) {
) : trashFiles.length === 0 ? ( ) : trashFiles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}> <div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
<Trash2 size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} /> <Trash2 size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.trashEmpty') || 'Trash is empty'}</p> <p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.trashEmpty') || 'Trash is empty'}</p>
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
@@ -48,7 +48,7 @@ export default function JournalBody({ text, dark }: Props) {
<pre style={{ <pre style={{
background: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', background: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
borderRadius: 8, padding: 14, overflowX: 'auto', borderRadius: 8, padding: 14, overflowX: 'auto',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'monospace', margin: '12px 0', fontSize: 13, fontFamily: 'monospace', margin: '12px 0',
}}> }}>
<code>{children}</code> <code>{children}</code>
</pre> </pre>
+2 -2
View File
@@ -300,7 +300,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`, border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B', color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1, cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
}} }}
>+</button> >+</button>
<button <button
@@ -312,7 +312,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`, border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B', color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1, cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
}} }}
></button> ></button>
</div> </div>
@@ -1,11 +1,7 @@
import { forwardRef, lazy, Suspense, useImperativeHandle, useRef } from 'react' import { forwardRef, useImperativeHandle, useRef } from 'react'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import JourneyMap, { type JourneyMapHandle } from './JourneyMap' import JourneyMap, { type JourneyMapHandle } from './JourneyMap'
import type { JourneyMapGLHandle } from './JourneyMapGL' import JourneyMapGL, { type JourneyMapGLHandle } from './JourneyMapGL'
// Lazy-load the GL renderer (and its ~230 KB gzip engine) so Leaflet-only
// installs never download it — it ships only once a GL provider is picked.
const JourneyMapGL = lazy(() => import('./JourneyMapGL'))
// Unified handle — both providers expose the same three methods. // Unified handle — both providers expose the same three methods.
export type JourneyMapAutoHandle = JourneyMapHandle export type JourneyMapAutoHandle = JourneyMapHandle
@@ -41,9 +37,8 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
const glRef = useRef<JourneyMapGLHandle>(null) const glRef = useRef<JourneyMapGLHandle>(null)
// Fall back to Leaflet when the user selected Mapbox GL but hasn't // Fall back to Leaflet when the user selected Mapbox GL but hasn't
// supplied a token yet. MapLibre/OpenFreeMap is tokenless. // supplied a token yet — otherwise the map would just show a stub.
const useGL = provider === 'maplibre-gl' || (provider === 'mapbox-gl' && !!token) const useGL = provider === 'mapbox-gl' && !!token
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' : 'mapbox-gl'
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id), highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
@@ -52,12 +47,8 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
}), [useGL]) }), [useGL])
if (useGL) { if (useGL) {
return ( // eslint-disable-next-line @typescript-eslint/no-explicit-any
<Suspense fallback={null}> return <JourneyMapGL ref={glRef} {...(props as any)} />
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<JourneyMapGL ref={glRef} {...(props as any)} glProvider={glProvider} />
</Suspense>
)
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
return <JourneyMap ref={leafletRef} {...(props as any)} /> return <JourneyMap ref={leafletRef} {...(props as any)} />
+35 -63
View File
@@ -1,11 +1,8 @@
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react' import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
import mapboxgl from 'mapbox-gl' import mapboxgl from 'mapbox-gl'
import maplibregl from 'maplibre-gl'
import 'mapbox-gl/dist/mapbox-gl.css' import 'mapbox-gl/dist/mapbox-gl.css'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup' import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
export interface JourneyMapGLHandle { export interface JourneyMapGLHandle {
highlightMarker: (id: string | null) => void highlightMarker: (id: string | null) => void
@@ -35,7 +32,6 @@ interface Props {
onMarkerClick?: (id: string, type?: string) => void onMarkerClick?: (id: string, type?: string) => void
fullScreen?: boolean fullScreen?: boolean
paddingBottom?: number paddingBottom?: number
glProvider?: GlMapProvider
} }
interface Item { interface Item {
@@ -99,10 +95,8 @@ function ensureJourneyPopupStyle() {
const s = document.createElement('style') const s = document.createElement('style')
s.id = 'trek-journey-popup-style' s.id = 'trek-journey-popup-style'
s.textContent = ` s.textContent = `
.mapboxgl-popup.trek-journey-popup, .mapboxgl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
.maplibregl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; } .mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content {
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-content {
padding: 9px 14px 10px; padding: 9px 14px 10px;
border-radius: 14px; border-radius: 14px;
background: rgba(255, 255, 255, 0.94); background: rgba(255, 255, 255, 0.94);
@@ -114,24 +108,20 @@ function ensureJourneyPopupStyle() {
min-width: 160px; min-width: 160px;
max-width: 280px; max-width: 280px;
} }
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content, .mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content {
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-content {
background: rgba(24, 24, 27, 0.88); background: rgba(24, 24, 27, 0.88);
border-color: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.08);
color: #FAFAFA; color: #FAFAFA;
} }
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip, .mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip {
.maplibregl-popup.trek-journey-popup .maplibregl-popup-tip {
border-top-color: rgba(255, 255, 255, 0.94); border-top-color: rgba(255, 255, 255, 0.94);
border-bottom-color: rgba(255, 255, 255, 0.94); border-bottom-color: rgba(255, 255, 255, 0.94);
} }
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip, .mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip {
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-tip {
border-top-color: rgba(24, 24, 27, 0.88); border-top-color: rgba(24, 24, 27, 0.88);
border-bottom-color: rgba(24, 24, 27, 0.88); border-bottom-color: rgba(24, 24, 27, 0.88);
} }
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button, .mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button { display: none; }
.maplibregl-popup.trek-journey-popup .maplibregl-popup-close-button { display: none; }
.trek-journey-popup-title { .trek-journey-popup-title {
font-size: 13.5px; font-size: 13.5px;
font-weight: 600; font-weight: 600;
@@ -142,8 +132,7 @@ function ensureJourneyPopupStyle() {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title, .mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
.trek-journey-popup-sub { .trek-journey-popup-sub {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -154,8 +143,7 @@ function ensureJourneyPopupStyle() {
line-height: 1.35; line-height: 1.35;
white-space: nowrap; white-space: nowrap;
} }
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub, .mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
.trek-journey-popup-place { .trek-journey-popup-place {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -206,29 +194,20 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H
const EMPTY_TRAIL: { lat: number; lng: number }[] = [] const EMPTY_TRAIL: { lat: number; lng: number }[] = []
const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL( const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL(
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl' }, { entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
ref ref
) { ) {
const stableTrail = trail || EMPTY_TRAIL const stableTrail = trail || EMPTY_TRAIL
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE) const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '') const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false) const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true) const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
const mapLang = useSettingsStore(s => s.settings.language)
const isMapLibre = glProvider === 'maplibre-gl'
const gl = (isMapLibre ? maplibregl : mapboxgl) as any
const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
const enableMapbox3d = !isMapLibre && mapbox3d
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any const mapRef = useRef<mapboxgl.Map | null>(null)
const mapRef = useRef<any | null>(null) const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map())
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<string, any>>(new Map())
const itemsRef = useRef<Item[]>([]) const itemsRef = useRef<Item[]>([])
const highlightedRef = useRef<string | null>(null) const highlightedRef = useRef<string | null>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any const popupRef = useRef<mapboxgl.Popup | null>(null)
const popupRef = useRef<any | null>(null)
const onMarkerClickRef = useRef(onMarkerClick) const onMarkerClickRef = useRef(onMarkerClick)
onMarkerClickRef.current = onMarkerClick onMarkerClickRef.current = onMarkerClick
const darkRef = useRef(dark) const darkRef = useRef(dark)
@@ -268,7 +247,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
const el = popupRef.current.getElement() const el = popupRef.current.getElement()
if (el) el.classList.toggle('trek-dark', !!darkRef.current) if (el) el.classList.toggle('trek-dark', !!darkRef.current)
} else { } else {
popupRef.current = new gl.Popup({ popupRef.current = new mapboxgl.Popup({
closeButton: false, closeButton: false,
closeOnClick: false, closeOnClick: false,
closeOnMove: false, closeOnMove: false,
@@ -281,7 +260,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
.setHTML(html) .setHTML(html)
.addTo(mapRef.current) .addTo(mapRef.current)
} }
}, [gl]) }, [])
const hidePopup = useCallback(() => { const hidePopup = useCallback(() => {
if (popupRef.current) { if (popupRef.current) {
@@ -326,11 +305,11 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
mapRef.current.flyTo({ mapRef.current.flyTo({
center: marker.getLngLat(), center: marker.getLngLat(),
zoom: Math.max(mapRef.current.getZoom(), 14), zoom: Math.max(mapRef.current.getZoom(), 14),
pitch: enableMapbox3d ? 45 : 0, pitch: mapbox3d ? 45 : 0,
duration: 600, duration: 600,
}) })
} catch { /* map not yet ready */ } } catch { /* map not yet ready */ }
}, [highlightMarker, enableMapbox3d]) }, [highlightMarker, mapbox3d])
const invalidateSize = useCallback(() => { const invalidateSize = useCallback(() => {
try { mapRef.current?.resize() } catch { /* map not yet ready */ } try { mapRef.current?.resize() } catch { /* map not yet ready */ }
@@ -341,46 +320,39 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
// Build map once per style/token change. Markers and layers are rebuilt // Build map once per style/token change. Markers and layers are rebuilt
// inside the same effect so they stay in sync with the active style. // inside the same effect so they stay in sync with the active style.
useEffect(() => { useEffect(() => {
if (!containerRef.current || (!isMapLibre && !mapboxToken)) return if (!containerRef.current || !mapboxToken) return
if (!isMapLibre) mapboxgl.accessToken = mapboxToken mapboxgl.accessToken = mapboxToken
const items = buildItems(entries) const items = buildItems(entries)
itemsRef.current = items itemsRef.current = items
const bounds = new gl.LngLatBounds() const bounds = new mapboxgl.LngLatBounds()
items.forEach(i => bounds.extend([i.lng, i.lat])) items.forEach(i => bounds.extend([i.lng, i.lat]))
stableTrail.forEach(p => bounds.extend([p.lng, p.lat])) stableTrail.forEach(p => bounds.extend([p.lng, p.lat]))
const hasPoints = items.length > 0 || stableTrail.length > 0 const hasPoints = items.length > 0 || stableTrail.length > 0
const mapOptions: Record<string, unknown> = { const map = new mapboxgl.Map({
container: containerRef.current, container: containerRef.current,
style: glStyle, style: mapboxStyle,
center: hasPoints ? bounds.getCenter() : [0, 30], center: hasPoints ? bounds.getCenter() : [0, 30],
zoom: hasPoints ? 2 : 1, zoom: hasPoints ? 2 : 1,
pitch: enableMapbox3d && fullScreen ? 45 : 0, pitch: mapbox3d && fullScreen ? 45 : 0,
attributionControl: true, attributionControl: true,
antialias: mapboxQuality, antialias: mapboxQuality,
} projection: mapboxQuality ? 'globe' : 'mercator',
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator' })
const map = new gl.Map(mapOptions as any)
mapRef.current = map mapRef.current = map
map.on('load', () => { map.on('load', () => {
if (enableMapbox3d) { if (mapbox3d) {
if (!isStandardFamily(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map) if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
if (supportsCustom3d(glStyle)) addCustom3dBuildings(map, !!darkRef.current) if (supportsCustom3d(mapboxStyle)) addCustom3dBuildings(map, !!darkRef.current)
} }
// Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0) // Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0)
// stay pinned to their coordinates at every zoom and pitch. // stay pinned to their coordinates at every zoom and pitch.
if (glStyle === MAPBOX_DEFAULT_STYLE) { if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
try { map.setTerrain(null) } catch { /* noop */ } try { map.setTerrain(null) } catch { /* noop */ }
} }
// Pin the basemap label language to the UI language so labels don't fall back to the
// browser/OS locale and stack multiple scripts per place (#1299).
if (!isMapLibre && isStandardFamily(glStyle)) {
try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLang)) } catch { /* style/SDK may not support it */ }
}
// route trail — dashed line connecting entries in time order // route trail — dashed line connecting entries in time order
if (items.length > 1) { if (items.length > 1) {
@@ -411,7 +383,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
// markers // markers
items.forEach((item) => { items.forEach((item) => {
const el = markerHtml(item.dayColor, item.dayLabel, false) const el = markerHtml(item.dayColor, item.dayLabel, false)
const marker = new gl.Marker({ element: el, anchor: 'bottom' }) const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([item.lng, item.lat]) .setLngLat([item.lng, item.lat])
.addTo(map) .addTo(map)
el.addEventListener('click', (ev) => { el.addEventListener('click', (ev) => {
@@ -428,7 +400,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
map.fitBounds(bounds, { map.fitBounds(bounds, {
padding: { top: 50, bottom: pb, left: 50, right: 50 }, padding: { top: 50, bottom: pb, left: 50, right: 50 },
maxZoom: 16, maxZoom: 16,
pitch: enableMapbox3d && fullScreen ? 45 : 0, pitch: mapbox3d && fullScreen ? 45 : 0,
duration: 0, duration: 0,
}) })
} catch { /* empty bounds */ } } catch { /* empty bounds */ }
@@ -446,7 +418,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
try { map.remove() } catch { /* noop */ } try { map.remove() } catch { /* noop */ }
mapRef.current = null mapRef.current = null
} }
}, [entries, stableTrail, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom]) }, [entries, stableTrail, mapboxStyle, mapboxToken, mapbox3d, mapboxQuality, fullScreen, paddingBottom])
// external activeMarkerId → highlight + flyTo // external activeMarkerId → highlight + flyTo
useEffect(() => { useEffect(() => {
@@ -459,15 +431,15 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
mapRef.current.flyTo({ mapRef.current.flyTo({
center: marker.getLngLat(), center: marker.getLngLat(),
zoom: Math.max(mapRef.current.getZoom(), 12), zoom: Math.max(mapRef.current.getZoom(), 12),
pitch: enableMapbox3d && fullScreen ? 45 : 0, pitch: mapbox3d && fullScreen ? 45 : 0,
duration: 500, duration: 500,
}) })
} catch { /* map not ready */ } } catch { /* map not ready */ }
}, 50) }, 50)
return () => clearTimeout(t) return () => clearTimeout(t)
}, [activeMarkerId, highlightMarker, enableMapbox3d, fullScreen]) }, [activeMarkerId, highlightMarker, mapbox3d, fullScreen])
if (!isMapLibre && !mapboxToken) { if (!mapboxToken) {
return ( return (
<div <div
style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }} style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}
@@ -1,6 +1,6 @@
// FE-COMP-MDTOOLBAR-001 to FE-COMP-MDTOOLBAR-006 // FE-COMP-MDTOOLBAR-001 to FE-COMP-MDTOOLBAR-006
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '../../../tests/helpers/render'; import { render, screen, fireEvent } from '../../../tests/helpers/render';
import MarkdownToolbar from './MarkdownToolbar'; import MarkdownToolbar from './MarkdownToolbar';
import React from 'react'; import React from 'react';
@@ -16,10 +16,10 @@ function createTextareaRef(value = '', selectionStart = 0, selectionEnd = 0) {
} }
describe('MarkdownToolbar', () => { describe('MarkdownToolbar', () => {
let onUpdate: Mock<(value: string) => void>; let onUpdate: ReturnType<typeof vi.fn>;
beforeEach(() => { beforeEach(() => {
onUpdate = vi.fn<(value: string) => void>(); onUpdate = vi.fn();
}); });
it('FE-COMP-MDTOOLBAR-001: renders all 8 toolbar buttons', () => { it('FE-COMP-MDTOOLBAR-001: renders all 8 toolbar buttons', () => {
@@ -81,7 +81,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
> >
{/* Top bar */} {/* Top bar */}
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 20px' }}> <div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 20px' }}>
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}> <span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 13, fontWeight: 500 }}>
{idx + 1} / {photos.length} {idx + 1} / {photos.length}
</span> </span>
<button onClick={onClose} style={{ <button onClick={onClose} style={{
@@ -137,7 +137,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
{photo.caption && ( {photo.caption && (
<div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 5, maxWidth: '70%', textAlign: 'center' }}> <div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 5, maxWidth: '70%', textAlign: 'center' }}>
<p style={{ <p style={{
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontStyle: 'italic', fontSize: 14, fontStyle: 'italic',
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5, color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5,
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)', background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
padding: '6px 14px', borderRadius: 10, padding: '6px 14px', borderRadius: 10,
@@ -1,4 +1,4 @@
// FE-COMP-BOTTOMNAV-001 to FE-COMP-BOTTOMNAV-010 // FE-COMP-BOTTOMNAV-001 to FE-COMP-BOTTOMNAV-006
vi.mock('../../api/websocket', () => ({ vi.mock('../../api/websocket', () => ({
connect: vi.fn(), connect: vi.fn(),
@@ -30,7 +30,6 @@ const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@exampl
beforeEach(() => { beforeEach(() => {
resetAllStores(); resetAllStores();
mockNavigate.mockClear(); mockNavigate.mockClear();
sessionStorage.clear();
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true }); seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
}); });
@@ -80,37 +79,4 @@ describe('BottomNav', () => {
render(<BottomNav />); render(<BottomNav />);
expect(screen.queryByText('Foo Addon')).not.toBeInTheDocument(); expect(screen.queryByText('Foo Addon')).not.toBeInTheDocument();
}); });
// Context-aware "+" inside a trip — #1349
it('FE-COMP-BOTTOMNAV-007: in a trip, the "+" adds a place by default (plan tab)', async () => {
const user = userEvent.setup();
sessionStorage.setItem('trip-tab-42', 'plan');
render(<BottomNav />, { initialEntries: ['/trips/42'] });
await user.click(screen.getByRole('button', { name: 'Add Place/Activity' }));
expect(mockNavigate).toHaveBeenCalledWith('/trips/42?create=place');
});
it('FE-COMP-BOTTOMNAV-008: Bookings tab → "+" creates a reservation', async () => {
const user = userEvent.setup();
sessionStorage.setItem('trip-tab-42', 'buchungen');
render(<BottomNav />, { initialEntries: ['/trips/42'] });
await user.click(screen.getByRole('button', { name: 'Manual Booking' }));
expect(mockNavigate).toHaveBeenCalledWith('/trips/42?create=reservation');
});
it('FE-COMP-BOTTOMNAV-009: Transports tab → "+" creates a transport', async () => {
const user = userEvent.setup();
sessionStorage.setItem('trip-tab-42', 'transports');
render(<BottomNav />, { initialEntries: ['/trips/42'] });
await user.click(screen.getByRole('button', { name: 'Manual Transport' }));
expect(mockNavigate).toHaveBeenCalledWith('/trips/42?create=transport');
});
it('FE-COMP-BOTTOMNAV-010: Costs tab → "+" creates an expense', async () => {
const user = userEvent.setup();
sessionStorage.setItem('trip-tab-42', 'finanzplan');
render(<BottomNav />, { initialEntries: ['/trips/42'] });
await user.click(screen.getByRole('button', { name: 'Add expense' }));
expect(mockNavigate).toHaveBeenCalledWith('/trips/42?create=expense');
});
}); });
+6 -9
View File
@@ -25,15 +25,12 @@ function useCreateAction(): { label: string; run: () => void } {
const onJourneyList = useMatch('/journey') const onJourneyList = useMatch('/journey')
if (inTrip) { if (inTrip) {
// The "+" is context-aware per active tab: Bookings → reservation, // On the Costs tab the "+" adds an expense; otherwise it adds a place.
// Transports → transport, Costs → expense. Tabs without a create modal const tripTab = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(`trip-tab-${inTrip.params.id}`) : null
// (lists / files / collab) fall through to adding a place. #1349 if (tripTab === 'finanzplan') {
const id = inTrip.params.id return { label: t('costs.addExpense'), run: () => navigate(`/trips/${inTrip.params.id}?create=expense`) }
const tripTab = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(`trip-tab-${id}`) : null }
if (tripTab === 'finanzplan') return { label: t('costs.addExpense'), run: () => navigate(`/trips/${id}?create=expense`) } return { label: t('places.addPlace'), run: () => navigate(`/trips/${inTrip.params.id}?create=place`) }
if (tripTab === 'buchungen') return { label: t('reservations.addManual'), run: () => navigate(`/trips/${id}?create=reservation`) }
if (tripTab === 'transports') return { label: t('transport.addManual'), run: () => navigate(`/trips/${id}?create=transport`) }
return { label: t('places.addPlace'), run: () => navigate(`/trips/${id}?create=place`) }
} }
if (inJourney) { if (inJourney) {
return { label: t('journey.detail.addEntry'), run: () => navigate(`/journey/${inJourney.params.id}?create=entry`) } return { label: t('journey.detail.addEntry'), run: () => navigate(`/journey/${inJourney.params.id}?create=entry`) }
+13 -13
View File
@@ -287,12 +287,12 @@ export default function DemoBanner(): React.ReactElement | null {
{/* Header */} {/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} /> <img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} />
<h2 style={{ margin: 0, fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 5 }}> <h2 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 5 }}>
{t.titleBefore}<img src="/text-dark.svg" alt="TREK" style={{ height: 18 }} />{t.titleAfter} {t.titleBefore}<img src="/text-dark.svg" alt="TREK" style={{ height: 18 }} />{t.titleAfter}
</h2> </h2>
</div> </div>
<p style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}> <p style={{ fontSize: 13, color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}>
{t.description} {t.description}
</p> </p>
@@ -303,7 +303,7 @@ export default function DemoBanner(): React.ReactElement | null {
background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '8px 10px', background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '8px 10px',
}}> }}>
<Clock size={13} style={{ flexShrink: 0, color: '#0284c7' }} /> <Clock size={13} style={{ flexShrink: 0, color: '#0284c7' }} />
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#0369a1', fontWeight: 600 }}> <span style={{ fontSize: 11, color: '#0369a1', fontWeight: 600 }}>
{t.resetIn} {minutesLeft} {t.minutes} {t.resetIn} {minutesLeft} {t.minutes}
</span> </span>
</div> </div>
@@ -312,7 +312,7 @@ export default function DemoBanner(): React.ReactElement | null {
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '8px 10px', background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '8px 10px',
}}> }}>
<Upload size={13} style={{ flexShrink: 0, color: '#b45309' }} /> <Upload size={13} style={{ flexShrink: 0, color: '#b45309' }} />
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b45309' }}>{t.uploadNote}</span> <span style={{ fontSize: 11, color: '#b45309' }}>{t.uploadNote}</span>
</div> </div>
</div> </div>
@@ -323,15 +323,15 @@ export default function DemoBanner(): React.ReactElement | null {
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Map size={14} style={{ color: '#111827' }} /> <Map size={14} style={{ color: '#111827' }} />
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}> <span style={{ fontSize: 12, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}>
{t.whatIs} {t.whatIs}
</span> </span>
</div> </div>
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p> <p style={{ fontSize: 12, color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
</div> </div>
{/* Addons */} {/* Addons */}
<p style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}> <p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
<Puzzle size={12} /> <Puzzle size={12} />
{t.addonsTitle} {t.addonsTitle}
</p> </p>
@@ -345,16 +345,16 @@ export default function DemoBanner(): React.ReactElement | null {
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<Icon size={12} style={{ flexShrink: 0, color: '#111827' }} /> <Icon size={12} style={{ flexShrink: 0, color: '#111827' }} />
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: '#111827' }}>{name}</span> <span style={{ fontSize: 11, fontWeight: 700, color: '#111827' }}>{name}</span>
</div> </div>
<p style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: '#94a3b8', margin: 0, lineHeight: 1.3, paddingLeft: 18 }}>{desc}</p> <p style={{ fontSize: 10, color: '#94a3b8', margin: 0, lineHeight: 1.3, paddingLeft: 18 }}>{desc}</p>
</div> </div>
) )
})} })}
</div> </div>
{/* Full version features */} {/* Full version features */}
<p style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}> <p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
<Shield size={12} /> <Shield size={12} />
{t.fullVersionTitle} {t.fullVersionTitle}
</p> </p>
@@ -362,7 +362,7 @@ export default function DemoBanner(): React.ReactElement | null {
{t.features.map((text, i) => { {t.features.map((text, i) => {
const Icon = featureIcons[i] const Icon = featureIcons[i]
return ( return (
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#4b5563', padding: '4px 0' }}> <div key={text} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: '#4b5563', padding: '4px 0' }}>
<Icon size={13} style={{ flexShrink: 0, color: '#9ca3af' }} /> <Icon size={13} style={{ flexShrink: 0, color: '#9ca3af' }} />
<span>{text}</span> <span>{text}</span>
</div> </div>
@@ -377,7 +377,7 @@ export default function DemoBanner(): React.ReactElement | null {
position: 'sticky', bottom: 0, background: 'white', position: 'sticky', bottom: 0, background: 'white',
marginTop: 'auto', marginTop: 'auto',
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#9ca3af' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
<Github size={13} /> <Github size={13} />
<span>{t.selfHost}</span> <span>{t.selfHost}</span>
<a href="https://github.com/mauriceboe/TREK" target="_blank" rel="noopener noreferrer" <a href="https://github.com/mauriceboe/TREK" target="_blank" rel="noopener noreferrer"
@@ -387,7 +387,7 @@ export default function DemoBanner(): React.ReactElement | null {
</div> </div>
<button onClick={() => setDismissed(true)} style={{ <button onClick={() => setDismissed(true)} style={{
background: '#111827', color: 'white', border: 'none', background: '#111827', color: 'white', border: 'none',
borderRadius: 10, padding: '8px 20px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 10, padding: '8px 20px', fontSize: 12,
fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}> }}>
{t.close} {t.close}
@@ -55,7 +55,7 @@ export default function InAppNotificationBell(): React.ReactElement {
className="absolute -top-0.5 -right-0.5 flex items-center justify-center rounded-full text-white font-bold" className="absolute -top-0.5 -right-0.5 flex items-center justify-center rounded-full text-white font-bold"
style={{ style={{
background: '#ef4444', background: '#ef4444',
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontSize: 9,
minWidth: 14, minWidth: 14,
height: 14, height: 14,
padding: '0 3px', padding: '0 3px',
+3 -11
View File
@@ -5,7 +5,7 @@ import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen } from 'lucide-react' import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx' import InAppNotificationBell from './InAppNotificationBell.tsx'
@@ -154,7 +154,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
<Link key={tab.id} to={tab.path} <Link key={tab.id} to={tab.path}
className="flex items-center gap-1.5 transition-colors" className="flex items-center gap-1.5 transition-colors"
style={{ style={{
padding: '5px 16px', borderRadius: 9, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 500, padding: '5px 16px', borderRadius: 9, fontSize: 13.5, fontWeight: 500,
color: isActive ? 'var(--text-primary)' : 'var(--text-muted)', color: isActive ? 'var(--text-primary)' : 'var(--text-muted)',
background: isActive ? 'var(--bg-card)' : 'transparent', background: isActive ? 'var(--bg-card)' : 'transparent',
boxShadow: isActive ? '0 1px 2px rgba(0,0,0,0.06), 0 2px 6px rgba(0,0,0,0.05)' : 'none', boxShadow: isActive ? '0 1px 2px rgba(0,0,0,0.06), 0 2px 6px rgba(0,0,0,0.05)' : 'none',
@@ -252,14 +252,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{t('nav.settings')} {t('nav.settings')}
</Link> </Link>
<Link to="/help" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<BookOpen className="w-4 h-4" />
{t('nav.help')}
</Link>
{user.role === 'admin' && ( {user.role === 'admin' && (
<Link to="/admin" onClick={() => setUserMenuOpen(false)} <Link to="/admin" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary" className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
@@ -282,7 +274,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}> <div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} /> <img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span> <span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
</div> </div>
<a href="https://discord.gg/NhZBDSd4qW" target="_blank" rel="noopener noreferrer" <a href="https://discord.gg/NhZBDSd4qW" target="_blank" rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: 99, background: 'var(--bg-tertiary)', transition: 'background 0.15s' }} style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: 99, background: 'var(--bg-tertiary)', transition: 'background 0.15s' }}
@@ -1,42 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import { render } from '../../../tests/helpers/render'
import OfflineBanner from './OfflineBanner'
vi.mock('../../sync/mutationQueue', () => ({
mutationQueue: {
pendingCount: vi.fn(),
failedCount: vi.fn(),
},
}))
import { mutationQueue } from '../../sync/mutationQueue'
const pendingCount = mutationQueue.pendingCount as ReturnType<typeof vi.fn>
const failedCount = mutationQueue.failedCount as ReturnType<typeof vi.fn>
afterEach(() => {
vi.clearAllMocks()
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true })
})
describe('OfflineBanner (B3 surface)', () => {
it('shows the failed pill when failedCount > 0 while online', async () => {
pendingCount.mockResolvedValue(0)
failedCount.mockResolvedValue(2)
render(<OfflineBanner />)
expect(await screen.findByText(/2 changes failed to sync/i)).toBeInTheDocument()
})
it('stays hidden when online with nothing pending or failed', async () => {
pendingCount.mockResolvedValue(0)
failedCount.mockResolvedValue(0)
const { container } = render(<OfflineBanner />)
// Give the async poll a tick to resolve.
await waitFor(() => expect(failedCount).toHaveBeenCalled())
expect(container.querySelector('[role="status"]')).toBeNull()
})
})
+14 -28
View File
@@ -2,7 +2,6 @@
* OfflineBanner connectivity + sync state indicator. * OfflineBanner connectivity + sync state indicator.
* *
* States: * States:
* N failed red pill "N changes failed to sync" (takes priority)
* offline + N queued amber pill "Offline · N queued" * offline + N queued amber pill "Offline · N queued"
* offline + 0 queued amber pill "Offline" * offline + 0 queued amber pill "Offline"
* online + N pending blue pill "Syncing N…" * online + N pending blue pill "Syncing N…"
@@ -13,7 +12,7 @@
* headers. On mobile it hovers just above the bottom tab bar. * headers. On mobile it hovers just above the bottom tab bar.
*/ */
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { WifiOff, RefreshCw, AlertTriangle } from 'lucide-react' import { WifiOff, RefreshCw } from 'lucide-react'
import { mutationQueue } from '../../sync/mutationQueue' import { mutationQueue } from '../../sync/mutationQueue'
const POLL_MS = 3_000 const POLL_MS = 3_000
@@ -21,7 +20,6 @@ const POLL_MS = 3_000
export default function OfflineBanner(): React.ReactElement | null { export default function OfflineBanner(): React.ReactElement | null {
const [isOnline, setIsOnline] = useState(navigator.onLine) const [isOnline, setIsOnline] = useState(navigator.onLine)
const [pendingCount, setPendingCount] = useState(0) const [pendingCount, setPendingCount] = useState(0)
const [failedCount, setFailedCount] = useState(0)
useEffect(() => { useEffect(() => {
const onOnline = () => setIsOnline(true) const onOnline = () => setIsOnline(true)
@@ -37,36 +35,26 @@ export default function OfflineBanner(): React.ReactElement | null {
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
async function poll() { async function poll() {
const [n, failed] = await Promise.all([ const n = await mutationQueue.pendingCount()
mutationQueue.pendingCount(), if (!cancelled) setPendingCount(n)
mutationQueue.failedCount(),
])
if (!cancelled) {
setPendingCount(n)
setFailedCount(failed)
}
} }
poll() poll()
const id = setInterval(poll, POLL_MS) const id = setInterval(poll, POLL_MS)
return () => { cancelled = true; clearInterval(id) } return () => { cancelled = true; clearInterval(id) }
}, []) }, [])
const hidden = isOnline && pendingCount === 0 && failedCount === 0 const hidden = isOnline && pendingCount === 0
if (hidden) return null if (hidden) return null
const offline = !isOnline const offline = !isOnline
// Failed mutations are the most important signal — they mean data was dropped. const bg = offline ? '#92400e' : '#1e40af'
const failed = failedCount > 0
const bg = failed ? '#b91c1c' : offline ? '#92400e' : '#1e40af'
const text = '#fff' const text = '#fff'
const label = failed const label = offline
? `${failedCount} change${failedCount !== 1 ? 's' : ''} failed to sync` ? pendingCount > 0
: offline ? `Offline · ${pendingCount} queued`
? pendingCount > 0 : 'Offline'
? `Offline · ${pendingCount} queued` : `Syncing ${pendingCount}`
: 'Offline'
: `Syncing ${pendingCount}`
return ( return (
<div <div
@@ -88,17 +76,15 @@ export default function OfflineBanner(): React.ReactElement | null {
padding: '6px 14px', padding: '6px 14px',
borderRadius: 999, borderRadius: 999,
boxShadow: '0 4px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.08)', boxShadow: '0 4px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.08)',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontSize: 12,
fontWeight: 600, fontWeight: 600,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
pointerEvents: 'none', pointerEvents: 'none',
}} }}
> >
{failed {offline
? <AlertTriangle size={12} /> ? <WifiOff size={12} />
: offline : <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
? <WifiOff size={12} />
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
} }
{label} {label}
</div> </div>
+23 -37
View File
@@ -5,9 +5,6 @@ export interface PageSidebarTab {
id: string id: string
label: string label: string
icon: LucideIcon icon: LucideIcon
/** Optional group heading shown above the first tab of each group. Tabs that
* share a group must be contiguous in the array. */
group?: string
} }
interface PageSidebarProps { interface PageSidebarProps {
@@ -163,40 +160,29 @@ function SidebarInner({
</div> </div>
)} )}
<nav className="flex flex-col gap-1 flex-1"> <nav className="flex flex-col gap-1 flex-1">
{(() => { {tabs.map((tab) => {
let lastGroup: string | undefined const Icon = tab.icon
return tabs.map((tab) => { const active = tab.id === activeTab
const Icon = tab.icon return (
const active = tab.id === activeTab <button
const showHeader = !!tab.group && tab.group !== lastGroup key={tab.id}
lastGroup = tab.group onClick={() => onTabChange(tab.id)}
return ( className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors ${active ? 'text-content font-semibold' : 'text-content-secondary font-medium'}`}
<React.Fragment key={tab.id}> style={{
{showHeader && ( background: active ? 'var(--bg-hover)' : 'transparent',
<div className="text-[10px] font-bold tracking-widest uppercase text-content-faint px-3 mt-3 mb-0.5 first:mt-0"> }}
{tab.group} onMouseEnter={(e) => {
</div> if (!active) e.currentTarget.style.background = 'var(--bg-hover)'
)} }}
<button onMouseLeave={(e) => {
onClick={() => onTabChange(tab.id)} if (!active) e.currentTarget.style.background = 'transparent'
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors ${active ? 'text-content font-semibold' : 'text-content-secondary font-medium'}`} }}
style={{ >
background: active ? 'var(--bg-hover)' : 'transparent', <Icon size={16} className="shrink-0" />
}} <span className="truncate">{tab.label}</span>
onMouseEnter={(e) => { </button>
if (!active) e.currentTarget.style.background = 'var(--bg-hover)' )
}} })}
onMouseLeave={(e) => {
if (!active) e.currentTarget.style.background = 'transparent'
}}
>
<Icon size={16} className="shrink-0" />
<span className="truncate">{tab.label}</span>
</button>
</React.Fragment>
)
})
})()}
</nav> </nav>
{footer && ( {footer && (
<div <div
@@ -1,54 +0,0 @@
import { useEffect, useState } from 'react'
import { Navigation } from 'lucide-react'
export interface CompassMap {
getBearing: () => number
on: (type: 'rotate', listener: () => void) => unknown
off: (type: 'rotate', listener: () => void) => unknown
easeTo: (options: { bearing: number; pitch: number; duration: number }) => unknown
}
/**
* Round compass pill for the GL planner map. The map can be rotated and
* pitched, so this shows the current bearing (the arrow points to north) and snaps
* the camera back to north + flat on click. Rendered next to the POI "explore" pill
* (GL only) and built as the SAME frosted shell (padding 4 around a 34px button)
* so its height and transparency match the POI pill exactly.
*/
export function MapCompassPill({ map }: { map: CompassMap }) {
const [bearing, setBearing] = useState(() => map.getBearing())
useEffect(() => {
const update = () => setBearing(map.getBearing())
update()
map.on('rotate', update)
return () => { map.off('rotate', update) }
}, [map])
return (
<div style={{
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
}}>
<button
type="button"
onClick={() => map.easeTo({ bearing: 0, pitch: 0, duration: 300 })}
aria-label="Reset north"
className="text-content-muted"
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: 'transparent', padding: 0,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Navigation size={16} strokeWidth={2} style={{ transform: `rotate(${-bearing}deg)`, transition: 'transform 0.1s linear' }} />
</button>
</div>
)
}
+5 -77
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react' import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
import DOM from 'react-dom' import DOM from 'react-dom'
import { renderToStaticMarkup } from 'react-dom/server' import { renderToStaticMarkup } from 'react-dom/server'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet' import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster' import MarkerClusterGroup from 'react-leaflet-cluster'
import L from 'leaflet' import L from 'leaflet'
import 'leaflet.markercluster/dist/MarkerCluster.css' import 'leaflet.markercluster/dist/MarkerCluster.css'
@@ -10,7 +10,6 @@ import { mapsApi } from '../../api/client'
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons' import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import ReservationOverlay from './ReservationOverlay' import ReservationOverlay from './ReservationOverlay'
import type { Reservation } from '../../types' import type { Reservation } from '../../types'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
function categoryIconSvg(iconName: string | null | undefined, size: number): string { function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin'] const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -119,44 +118,6 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
return fallbackIcon return fallbackIcon
} }
// Small coloured pin for an OSM "explore" POI — distinct from the photo-circle
// markers of planned places; the colour matches its pill category.
const poiIconCache = new Map<string, L.DivIcon>()
function createPoiIcon(category: string) {
const cached = poiIconCache.get(category)
if (cached) return cached
const cat = POI_CATEGORY_BY_KEY[category]
const color = cat?.color || '#6b7280'
const svg = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
const icon = L.divIcon({
className: '',
html: `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;">${svg}</div>`,
iconSize: [26, 26],
iconAnchor: [13, 13],
tooltipAnchor: [0, -14],
})
poiIconCache.set(category, icon)
return icon
}
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
// OSM places for the visible area.
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
const map = useMap()
useEffect(() => {
if (!onViewportChange) return
const emit = () => {
const b = map.getBounds()
onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
}
map.whenReady(emit) // ensure the first bbox is captured once the map is laid out
map.on('moveend', emit)
map.on('zoomend', emit)
return () => { map.off('moveend', emit); map.off('zoomend', emit) }
}, [map, onViewportChange])
return null
}
interface SelectionControllerProps { interface SelectionControllerProps {
places: Place[] places: Place[]
selectedPlaceId: number | null selectedPlaceId: number | null
@@ -170,21 +131,10 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
useEffect(() => { useEffect(() => {
if (selectedPlaceId && selectedPlaceId !== prev.current) { if (selectedPlaceId && selectedPlaceId !== prev.current) {
// Pan to the selected place without changing zoom. Offset the centre by the // Pan to the selected place without changing zoom
// side-panel + bottom-inspector padding so the pin lands in the middle of the
// *visible* map area rather than the geometric centre (where the bottom panel
// would cover it). Reuses the same paddingOpts the fit-bounds path uses.
const selected = places.find(p => p.id === selectedPlaceId) const selected = places.find(p => p.id === selectedPlaceId)
if (selected?.lat != null && selected?.lng != null) { if (selected?.lat && selected?.lng) {
const latlng: [number, number] = [selected.lat, selected.lng] map.panTo([selected.lat, selected.lng], { animate: true })
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
const br = paddingOpts.paddingBottomRight as [number, number] | undefined
if (tl && br && typeof map.project === 'function' && typeof map.unproject === 'function') {
const point = map.project(latlng).add([(br[0] - tl[0]) / 2, (br[1] - tl[1]) / 2])
map.panTo(map.unproject(point), { animate: true })
} else {
map.panTo(latlng, { animate: true })
}
} }
} }
prev.current = selectedPlaceId prev.current = selectedPlaceId
@@ -406,21 +356,7 @@ export const MapView = memo(function MapView({
showReservationStats = false, showReservationStats = false,
visibleConnectionIds = [] as number[], visibleConnectionIds = [] as number[],
onReservationClick, onReservationClick,
pois = [] as Poi[],
onPoiClick,
onViewportChange,
}: any) { }: any) {
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
<Marker
key={`poi-${poi.osm_id}`}
position={[poi.lat, poi.lng]}
icon={createPoiIcon(poi.category)}
zIndexOffset={500}
eventHandlers={{ click: () => onPoiClick?.(poi) }}
>
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">{poi.name}</Tooltip>
</Marker>
)), [pois, onPoiClick])
const visibleReservations = useMemo(() => { const visibleReservations = useMemo(() => {
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return [] if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
const set = new Set(visibleConnectionIds) const set = new Set(visibleConnectionIds)
@@ -569,12 +505,7 @@ export const MapView = memo(function MapView({
// Desktop browsers only get IP-based geolocation (city-level accuracy), // Desktop browsers only get IP-based geolocation (city-level accuracy),
// so the button would be misleading. Mobile, where real GPS lives, keeps it. // so the button would be misleading. Mobile, where real GPS lives, keeps it.
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
// When the day-detail panel is open it slides up over the map (bottom: navh+20, const locationButtonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)'
// height var(--day-panel-h)) and covers the button's band, so lift the button
// above it; otherwise keep the plain bottom-nav offset. #1348
const locationButtonBottom = hasDayDetail
? 'calc(var(--bottom-nav-h, 84px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 84px) + 12px)'
return ( return (
<> <>
@@ -601,7 +532,6 @@ export const MapView = memo(function MapView({
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} /> <SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} /> <MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} /> <MapContextMenuHandler onContextMenu={onMapContextMenu} />
<ViewportController onViewportChange={onViewportChange} />
<LeafletLocationLayer position={userPosition} mode={trackingMode} /> <LeafletLocationLayer position={userPosition} mode={trackingMode} />
<MarkerClusterGroup <MarkerClusterGroup
@@ -642,8 +572,6 @@ export const MapView = memo(function MapView({
showStats={showReservationStats} showStats={showReservationStats}
onEndpointClick={onReservationClick} onEndpointClick={onReservationClick}
/> />
{poiMarkers}
</MapContainer> </MapContainer>
{isMobile && <LocationButton {isMobile && <LocationButton
mode={trackingMode} mode={trackingMode}
+2 -22
View File
@@ -1,36 +1,16 @@
import { lazy, Suspense } from 'react'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { MapView } from './MapView' import { MapView } from './MapView'
import { MapViewGL } from './MapViewGL'
// MapLibre/Mapbox pull in a ~230 KB (gzip) GL engine. Lazy-load the GL renderer so
// Leaflet-only installs never download it — it ships only once a GL provider is picked.
const MapViewGL = lazy(() => import('./MapViewGL').then(m => ({ default: m.MapViewGL })))
// Auto-selects the map renderer based on user settings. Keeps the existing // Auto-selects the map renderer based on user settings. Keeps the existing
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively // Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
// behind a toggle. Atlas is not affected — it imports Leaflet directly. // behind a toggle. Atlas is not affected — it imports Leaflet directly.
//
// Offline maps: only the Leaflet renderer supports full pre-download (raster
// tiles via sync/tilePrefetcher.ts). GL maps are best-effort offline — their
// vector tiles are cached opportunistically by the Service Worker as you view
// them online (see the GL tile rules in vite.config.js), not prefetched.
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function MapViewAuto(props: any) { export function MapViewAuto(props: any) {
const provider = useSettingsStore(s => s.settings.map_provider) const provider = useSettingsStore(s => s.settings.map_provider)
const token = useSettingsStore(s => s.settings.mapbox_access_token) const token = useSettingsStore(s => s.settings.mapbox_access_token)
// Fall back to Leaflet when Mapbox is selected but no token is set, // Fall back to Leaflet when Mapbox is selected but no token is set,
// so trip planner never shows an empty map due to a missing token. // so trip planner never shows an empty map due to a missing token.
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' if (provider === 'mapbox-gl' && token) return <MapViewGL {...props} />
: provider === 'mapbox-gl' && token ? 'mapbox-gl'
: null
if (glProvider) {
// Render the previous Leaflet map as the fallback so there's no blank flash
// while the GL chunk loads on first use.
return (
<Suspense fallback={<MapView {...props} />}>
<MapViewGL {...props} glProvider={glProvider} />
</Suspense>
)
}
return <MapView {...props} /> return <MapView {...props} />
} }
+9 -75
View File
@@ -31,62 +31,19 @@ const glMap = vi.hoisted(() => ({
vi.mock('mapbox-gl', () => ({ vi.mock('mapbox-gl', () => ({
default: { default: {
accessToken: '', accessToken: '',
Map: vi.fn(function () { Map: vi.fn(() => glMap),
return glMap Marker: vi.fn(() => ({
}), setLngLat: vi.fn().mockReturnThis(),
Marker: vi.fn(function () { addTo: vi.fn().mockReturnThis(),
return { remove: vi.fn(),
setLngLat: vi.fn().mockReturnThis(), getElement: vi.fn(() => document.createElement('div')),
addTo: vi.fn().mockReturnThis(), })),
remove: vi.fn(), LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
getElement: vi.fn(() => document.createElement('div')),
}
}),
LngLatBounds: vi.fn(function () {
return { extend: vi.fn().mockReturnThis() }
}),
NavigationControl: vi.fn(), NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return {
setLngLat: vi.fn().mockReturnThis(),
setHTML: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
}
}),
}, },
})) }))
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({})) vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
vi.mock('maplibre-gl', () => ({
default: {
Map: vi.fn(function () {
return glMap
}),
Marker: vi.fn(function () {
return {
setLngLat: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
getElement: vi.fn(() => document.createElement('div')),
}
}),
LngLatBounds: vi.fn(function () {
return { extend: vi.fn().mockReturnThis() }
}),
NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return {
setLngLat: vi.fn().mockReturnThis(),
setHTML: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
}
}),
},
}))
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}))
vi.mock('./mapboxSetup', () => ({ vi.mock('./mapboxSetup', () => ({
isStandardFamily: vi.fn(() => false), isStandardFamily: vi.fn(() => false),
supportsCustom3d: vi.fn(() => false), supportsCustom3d: vi.fn(() => false),
@@ -100,9 +57,7 @@ vi.mock('./locationMarkerMapbox', () => ({
})) }))
vi.mock('./reservationsMapbox', () => ({ vi.mock('./reservationsMapbox', () => ({
ReservationMapboxOverlay: vi.fn(function () { ReservationMapboxOverlay: vi.fn().mockImplementation(() => ({ update: vi.fn() })),
return { update: vi.fn() }
}),
})) }))
vi.mock('../../hooks/useGeolocation', () => ({ vi.mock('../../hooks/useGeolocation', () => ({
@@ -206,25 +161,4 @@ describe('MapViewGL', () => {
await act(async () => {}) await act(async () => {})
expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first) expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first)
}) })
it('FE-COMP-MAPVIEWGL-004: renders with the MapLibre provider and no token', async () => {
const mapboxgl = (await import('mapbox-gl')).default
const maplibregl = (await import('maplibre-gl')).default
useSettingsStore.setState({
settings: {
...useSettingsStore.getState().settings,
map_provider: 'maplibre-gl',
mapbox_access_token: '', // MapLibre/OpenFreeMap is tokenless — must not short-circuit
maplibre_style: 'https://tiles.openfreemap.org/styles/liberty',
},
} as any)
const places = [buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 })]
render(<MapViewGL places={places} fitKey={1} glProvider="maplibre-gl" />)
await act(async () => {})
// The MapLibre engine builds the map even without a token; Mapbox is not used.
expect(maplibregl.Map).toHaveBeenCalled()
expect(mapboxgl.Map).not.toHaveBeenCalled()
})
}) })
+34 -156
View File
@@ -1,9 +1,7 @@
import { useEffect, useRef, useMemo, useState, createElement } from 'react' import { useEffect, useRef, useMemo, useState, createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server' import { renderToStaticMarkup } from 'react-dom/server'
import mapboxgl from 'mapbox-gl' import mapboxgl from 'mapbox-gl'
import maplibregl from 'maplibre-gl'
import 'mapbox-gl/dist/mapbox-gl.css' import 'mapbox-gl/dist/mapbox-gl.css'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
@@ -11,12 +9,9 @@ import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup' import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox' import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
import { ReservationMapboxOverlay } from './reservationsMapbox' import { ReservationMapboxOverlay } from './reservationsMapbox'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
import LocationButton from './LocationButton' import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation' import { useGeolocation } from '../../hooks/useGeolocation'
import type { Place, Reservation } from '../../types' import type { Place, Reservation } from '../../types'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { buildPlacePopupHtml, buildPoiPopupHtml } from './placePopup'
function categoryIconSvg(iconName: string | null | undefined, size: number): string { function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin'] const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -54,12 +49,6 @@ interface Props {
visibleConnectionIds?: number[] visibleConnectionIds?: number[]
showReservationStats?: boolean showReservationStats?: boolean
onReservationClick?: (reservationId: number) => void onReservationClick?: (reservationId: number) => void
pois?: Poi[]
onPoiClick?: (poi: Poi) => void
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
glProvider?: GlMapProvider
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onMapReady?: (map: any | null) => void
} }
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement { function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
@@ -96,8 +85,8 @@ function createMarkerElement(place: Place & { category_color?: string; category_
} }
const wrap = document.createElement('div') const wrap = document.createElement('div')
// Do NOT set `position: relative` here — GL map libraries ship // Do NOT set `position: relative` here — mapbox-gl ships
// marker classes with `position: absolute` and rely on it. An inline // `.mapboxgl-marker { position: absolute }` and relies on it. An inline
// `position: relative` here overrides the class, turns every marker into // `position: relative` here overrides the class, turns every marker into
// a static block element, and stacks them in document order inside the // a static block element, and stacks them in document order inside the
// canvas container. The result looks exactly like "markers drift as the // canvas container. The result looks exactly like "markers drift as the
@@ -139,17 +128,6 @@ function createMarkerElement(place: Place & { category_color?: string; category_
return wrap return wrap
} }
// Small coloured pin for an OSM "explore" POI (matches the pill category colour).
function createPoiMarkerElement(category: string): HTMLDivElement {
const cat = POI_CATEGORY_BY_KEY[category]
const color = cat?.color || '#6b7280'
const svg = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
const el = document.createElement('div')
el.style.cssText = 'width:26px;height:26px;cursor:pointer;'
el.innerHTML = `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;">${svg}</div>`
return el
}
export function MapViewGL({ export function MapViewGL({
places = [], places = [],
dayPlaces = [], dayPlaces = [],
@@ -171,93 +149,56 @@ export function MapViewGL({
visibleConnectionIds = [], visibleConnectionIds = [],
showReservationStats = false, showReservationStats = false,
onReservationClick, onReservationClick,
pois = [],
onPoiClick,
onViewportChange,
glProvider = 'mapbox-gl',
onMapReady,
}: Props) { }: Props) {
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE) const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '') const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false) const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true) const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
const mapLang = useSettingsStore(s => s.settings.language)
const isMapLibre = glProvider === 'maplibre-gl'
const gl = (isMapLibre ? maplibregl : mapboxgl) as any
const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
const enableMapbox3d = !isMapLibre && mapbox3d
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled) const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs) const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
const [mapReady, setMapReady] = useState(false) const [mapReady, setMapReady] = useState(false)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any const mapRef = useRef<mapboxgl.Map | null>(null)
const mapRef = useRef<any | null>(null) const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<number, any>>(new Map())
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null) const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null) const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
// Refs so the reservation overlay always sees the latest callback / // Refs so the reservation overlay always sees the latest callback /
// options without forcing a full overlay rebuild on every prop change. // options without forcing a full overlay rebuild on every prop change.
const onReservationClickRef = useRef(onReservationClick) const onReservationClickRef = useRef(onReservationClick)
onReservationClickRef.current = onReservationClick onReservationClickRef.current = onReservationClick
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const poiMarkersRef = useRef<any[]>([])
// Single reusable hover popup (name/category/address card) shared by planned
// places and POI markers — mirrors the Leaflet map's hover tooltip.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const popupRef = useRef<any | null>(null)
const onPoiClickRef = useRef(onPoiClick)
onPoiClickRef.current = onPoiClick
const onViewportChangeRef = useRef(onViewportChange)
onViewportChangeRef.current = onViewportChange
const onMapReadyRef = useRef(onMapReady)
onMapReadyRef.current = onMapReady
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation() const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu }) const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
onClickRefs.current.marker = onMarkerClick onClickRefs.current.marker = onMarkerClick
onClickRefs.current.map = onMapClick onClickRefs.current.map = onMapClick
onClickRefs.current.context = onMapContextMenu onClickRefs.current.context = onMapContextMenu
// Build/rebuild the map on provider/style/token/3d change // Build/rebuild the map on style/token/3d change
useEffect(() => { useEffect(() => {
if (!containerRef.current || (!isMapLibre && !mapboxToken)) return if (!containerRef.current || !mapboxToken) return
if (!isMapLibre) mapboxgl.accessToken = mapboxToken mapboxgl.accessToken = mapboxToken
const mapOptions: Record<string, unknown> = { const map = new mapboxgl.Map({
container: containerRef.current, container: containerRef.current,
style: glStyle, style: mapboxStyle,
center: [center[1], center[0]], center: [center[1], center[0]],
zoom, zoom,
pitch: enableMapbox3d ? 45 : 0, pitch: mapbox3d ? 45 : 0,
attributionControl: true, attributionControl: true,
antialias: mapboxQuality, antialias: mapboxQuality,
} projection: mapboxQuality ? 'globe' : 'mercator',
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
const map = new gl.Map(mapOptions as any)
mapRef.current = map
popupRef.current = new gl.Popup({
closeButton: false,
closeOnClick: false,
offset: 18,
maxWidth: '240px',
className: 'trek-map-popup',
}) })
// Hand the map out so the trip planner can render its own compass pill next to mapRef.current = map
// the POI pill (a custom round control instead of Mapbox's default top-right one).
onMapReadyRef.current?.(map)
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).__trek_map = map ;(window as any).__trek_map = map
map.on('load', () => { map.on('load', () => {
if (enableMapbox3d) { if (mapbox3d) {
// Terrain is only valuable on satellite styles — on clean vector // Terrain is only valuable on satellite styles — on clean vector
// styles it makes route lines drift off the HTML markers because // styles it makes route lines drift off the HTML markers because
// the lines snap to DEM height while markers stay at sea level. // the lines snap to DEM height while markers stay at sea level.
if (!isStandardFamily(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map) if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
if (supportsCustom3d(glStyle)) { if (supportsCustom3d(mapboxStyle)) {
const dark = document.documentElement.classList.contains('dark') const dark = document.documentElement.classList.contains('dark')
addCustom3dBuildings(map, dark) addCustom3dBuildings(map, dark)
} }
@@ -270,7 +211,7 @@ export function MapViewGL({
// non-satellite Standard style still looks great without terrain, // non-satellite Standard style still looks great without terrain,
// so flatten it out to keep markers pinned. (Satellite variants // so flatten it out to keep markers pinned. (Satellite variants
// are left alone — the DEM is what gives them their character.) // are left alone — the DEM is what gives them their character.)
if (glStyle === MAPBOX_DEFAULT_STYLE) { if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
try { map.setTerrain(null) } catch { /* noop */ } try { map.setTerrain(null) } catch { /* noop */ }
} }
// initial route source — kept around so updates can setData() cheaply // initial route source — kept around so updates can setData() cheaply
@@ -316,18 +257,10 @@ export function MapViewGL({
map.on('click', (e) => { map.on('click', (e) => {
const t = e.originalEvent.target as HTMLElement const t = e.originalEvent.target as HTMLElement
if (t.closest('.mapboxgl-marker, .maplibregl-marker')) return // markers handle their own click if (t.closest('.mapboxgl-marker')) return // markers handle their own click
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } }) onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
}) })
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore // In the mapbox-gl map the right mouse button is reserved for the
// pill can fetch OSM places for the visible area.
const emitViewport = () => {
const b = map.getBounds()
onViewportChangeRef.current?.({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
}
map.on('moveend', emitViewport)
map.once('idle', emitViewport)
// In the GL map the right mouse button is reserved for the
// built-in rotate/pitch gesture, so we bind the "add place" action // built-in rotate/pitch gesture, so we bind the "add place" action
// to the middle mouse button (button === 1) instead. // to the middle mouse button (button === 1) instead.
const canvas = map.getCanvasContainer() const canvas = map.getCanvasContainer()
@@ -374,9 +307,7 @@ export function MapViewGL({
const ll = marker.getLngLat() const ll = marker.getLngLat()
let alt = 0 let alt = 0
try { try {
const e = typeof map.queryTerrainElevation === 'function' const e = map.queryTerrainElevation([ll.lng, ll.lat])
? map.queryTerrainElevation([ll.lng, ll.lat])
: null
if (typeof e === 'number' && Number.isFinite(e)) alt = e if (typeof e === 'number' && Number.isFinite(e)) alt = e
} catch { /* terrain not ready */ } } catch { /* terrain not ready */ }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -388,17 +319,13 @@ export function MapViewGL({
} }
}) })
} }
// Terrain altitude sync only matters with mapbox 3D/terrain on; skip the per-frame map.on('render', syncMarkerAltitudes)
// listener entirely for MapLibre and flat mapbox styles.
if (enableMapbox3d) map.on('render', syncMarkerAltitudes)
return () => { return () => {
canvas.removeEventListener('mousedown', onAuxDown) canvas.removeEventListener('mousedown', onAuxDown)
canvas.removeEventListener('auxclick', onAuxClick) canvas.removeEventListener('auxclick', onAuxClick)
markersRef.current.forEach(m => m.remove()) markersRef.current.forEach(m => m.remove())
markersRef.current.clear() markersRef.current.clear()
if (popupRef.current) { popupRef.current.remove(); popupRef.current = null }
onMapReadyRef.current?.(null)
if (reservationOverlayRef.current) { if (reservationOverlayRef.current) {
reservationOverlayRef.current.destroy() reservationOverlayRef.current.destroy()
reservationOverlayRef.current = null reservationOverlayRef.current = null
@@ -411,17 +338,7 @@ export function MapViewGL({
mapRef.current = null mapRef.current = null
setMapReady(false) setMapReady(false)
} }
}, [glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality]) // rebuild on provider/style changes only }, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only
// Pin the basemap label language to the UI language so labels don't fall back to the
// browser/OS locale and stack multiple scripts per place (e.g. "India/भारत/India", #1299).
// Mapbox Standard exposes this via a basemap config property; classic and MapLibre styles
// are left as-is. Runs on load (mapReady) and whenever the UI language changes.
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady || isMapLibre || !isStandardFamily(glStyle)) return
try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLang)) } catch { /* style/SDK may not support the basemap language property */ }
}, [mapLang, mapReady, isMapLibre, glStyle])
// Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch // Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch
// simultaneous thumb arrivals into one re-render. // simultaneous thumb arrivals into one re-render.
@@ -482,10 +399,6 @@ export function MapViewGL({
useEffect(() => { useEffect(() => {
const map = mapRef.current const map = mapRef.current
if (!map) return if (!map) return
// Markers are about to be rebuilt; drop any open hover popup first. A marker
// recreated under the pointer (e.g. when its photo streams in) never fires
// mouseleave, which would otherwise leave the popup orphaned on the map.
popupRef.current?.remove()
const ids = new Set(places.map(p => p.id)) const ids = new Set(places.map(p => p.id))
markersRef.current.forEach((marker, id) => { markersRef.current.forEach((marker, id) => {
@@ -506,12 +419,6 @@ export function MapViewGL({
ev.stopPropagation() ev.stopPropagation()
onClickRefs.current.marker?.(place.id) onClickRefs.current.marker?.(place.id)
}) })
el.addEventListener('mouseenter', () => {
popupRef.current?.setLngLat([place.lng, place.lat])
.setHTML(buildPlacePopupHtml(place as Place & { category_color?: string; category_icon?: string; category_name?: string }, photoUrl))
.addTo(map)
})
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
// Recreate marker each time rather than patching internal state — // Recreate marker each time rather than patching internal state —
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps. // mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
const existing = markersRef.current.get(place.id) const existing = markersRef.current.get(place.id)
@@ -521,32 +428,12 @@ export function MapViewGL({
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain, // pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
// but it rotates the element by the pitch angle and visually offsets // but it rotates the element by the pitch angle and visually offsets
// the anchor by ~100px at 45° tilt, which caused the observed drift. // the anchor by ~100px at 45° tilt, which caused the observed drift.
const m = new gl.Marker({ element: el, anchor: 'center' }) const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
.setLngLat([place.lng, place.lat]) .setLngLat([place.lng, place.lat])
.addTo(map) .addTo(map)
markersRef.current.set(place.id, m) markersRef.current.set(place.id, m)
}) })
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider]) }, [places, selectedPlaceId, dayOrderMap, photoUrls])
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
// planned-place markers so they don't cluster or get confused with them).
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
popupRef.current?.remove() // same orphan-popup guard as the place markers
poiMarkersRef.current.forEach(m => m.remove())
poiMarkersRef.current = []
for (const poi of (pois as Poi[])) {
const el = createPoiMarkerElement(poi.category)
el.addEventListener('mouseenter', () => {
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
})
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
poiMarkersRef.current.push(m)
}
}, [pois, mapReady, glProvider])
// Update route geojson // Update route geojson
useEffect(() => { useEffect(() => {
@@ -610,7 +497,7 @@ export function MapViewGL({
showStats: showReservationStats, showStats: showReservationStats,
showEndpointLabels, showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id), onEndpointClick: (id) => onReservationClickRef.current?.(id),
}, gl.Marker as any) })
} }
reservationOverlayRef.current.update(visibleReservations, { reservationOverlayRef.current.update(visibleReservations, {
showConnections: true, showConnections: true,
@@ -618,7 +505,7 @@ export function MapViewGL({
showEndpointLabels, showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id), onEndpointClick: (id) => onReservationClickRef.current?.(id),
}) })
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady, glProvider]) }, [visibleReservations, showReservationStats, showEndpointLabels, mapReady])
// Fit bounds on fitKey change — matches the Leaflet BoundsController // Fit bounds on fitKey change — matches the Leaflet BoundsController
const paddingOpts = useMemo(() => { const paddingOpts = useMemo(() => {
@@ -638,14 +525,14 @@ export function MapViewGL({
const target = dayPlaces.length > 0 ? dayPlaces : places const target = dayPlaces.length > 0 ? dayPlaces : places
const valid = target.filter(p => p.lat && p.lng) const valid = target.filter(p => p.lat && p.lng)
if (valid.length === 0) return if (valid.length === 0) return
const bounds = new gl.LngLatBounds() const bounds = new mapboxgl.LngLatBounds()
valid.forEach(p => bounds.extend([p.lng, p.lat])) valid.forEach(p => bounds.extend([p.lng, p.lat]))
const run = () => { const run = () => {
try { try {
map.fitBounds(bounds, { map.fitBounds(bounds, {
padding: paddingOpts, padding: paddingOpts,
maxZoom: 15, maxZoom: 15,
pitch: enableMapbox3d ? 45 : 0, pitch: mapbox3d ? 45 : 0,
duration: 400, duration: 400,
}) })
} catch { /* noop */ } } catch { /* noop */ }
@@ -664,15 +551,11 @@ export function MapViewGL({
map.flyTo({ map.flyTo({
center: [target.lng, target.lat], center: [target.lng, target.lat],
zoom: Math.max(map.getZoom(), 14), zoom: Math.max(map.getZoom(), 14),
pitch: enableMapbox3d ? 45 : 0, pitch: mapbox3d ? 45 : 0,
duration: 400, duration: 400,
// Account for the side panels and the bottom inspector / day-detail panel
// so the selected pin lands in the centre of the *visible* map area rather
// than the geometric centre (where the bottom panel would cover it).
padding: paddingOpts,
}) })
} catch { /* noop */ } } catch { /* noop */ }
}, [selectedPlaceId, enableMapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps }, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
// External center/zoom prop changes — jump without animation // External center/zoom prop changes — jump without animation
useEffect(() => { useEffect(() => {
@@ -695,7 +578,7 @@ export function MapViewGL({
} }
if (!userPosition) return if (!userPosition) return
const apply = () => { const apply = () => {
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map, gl.Marker as any) if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map)
locationMarkerRef.current.update(userPosition) locationMarkerRef.current.update(userPosition)
if (trackingMode === 'follow') { if (trackingMode === 'follow') {
// easeTo is gentler than flyTo for continuous updates // easeTo is gentler than flyTo for continuous updates
@@ -711,9 +594,9 @@ export function MapViewGL({
} }
if (map.loaded()) apply() if (map.loaded()) apply()
else map.once('load', apply) else map.once('load', apply)
}, [userPosition, trackingMode, glProvider]) }, [userPosition, trackingMode])
if (!isMapLibre && !mapboxToken) { if (!mapboxToken) {
return ( return (
<div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6"> <div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6">
<div className="text-sm text-zinc-500"> <div className="text-sm text-zinc-500">
@@ -727,12 +610,7 @@ export function MapViewGL({
// Desktop browsers only get IP-based geolocation (city-level accuracy), // Desktop browsers only get IP-based geolocation (city-level accuracy),
// so the button would be misleading. Mobile, where real GPS lives, keeps it. // so the button would be misleading. Mobile, where real GPS lives, keeps it.
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
// When the day-detail panel is open it slides up over the map (bottom: navh+20, const buttonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)'
// height var(--day-panel-h)) and covers the button's band, so lift the button
// above it; otherwise keep the plain bottom-nav offset. #1348
const buttonBottom = hasDayDetail
? 'calc(var(--bottom-nav-h, 84px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 84px) + 12px)'
return ( return (
<div className="w-full h-full relative"> <div className="w-full h-full relative">
@@ -1,101 +0,0 @@
import { RotateCw, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { POI_CATEGORIES } from './poiCategories'
interface Props {
active: Set<string>
onToggle: (key: string) => void
loadingKeys?: Set<string>
/** categories whose last fetch failed → show a retry affordance */
errorKeys?: Set<string>
/** true when the map moved since the last search → offer "search this area" */
moved?: boolean
onSearchArea?: () => void
}
// Frosted, icon-only segmented control that floats over the map. Active segments
// fill with the category colour (matching their markers); the label shows in a
// custom tooltip on hover so the pill stays compact and never needs to scroll.
export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKeys, moved, onSearchArea }: Props) {
const { t } = useTranslation()
const anyError = !!errorKeys && Array.from(active).some(k => errorKeys.has(k))
const frosted: React.CSSProperties = {
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
}
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 2, padding: 4, borderRadius: 999, pointerEvents: 'auto', ...frosted }}>
{POI_CATEGORIES.map(cat => {
const on = active.has(cat.key)
const loading = loadingKeys?.has(cat.key)
return (
<Tooltip key={cat.key} label={t(cat.labelKey)} placement="bottom">
<button
type="button"
onClick={() => onToggle(cat.key)}
aria-pressed={on}
aria-label={t(cat.labelKey)}
className={on ? '' : 'text-content-muted'}
style={{
position: 'relative',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: on ? cat.color : 'transparent',
color: on ? '#fff' : undefined,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { if (!on) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!on) e.currentTarget.style.background = 'transparent' }}
>
{loading ? (
<span
className="animate-spin"
style={{
width: 14, height: 14, borderRadius: 999, display: 'inline-block',
border: '2px solid', borderColor: on ? 'rgba(255,255,255,0.45)' : 'var(--border-primary)',
borderTopColor: on ? '#fff' : 'var(--text-muted)',
}}
/>
) : (
<cat.Icon size={16} strokeWidth={2} />
)}
{on && !loading && errorKeys?.has(cat.key) && (
<span style={{
position: 'absolute', top: 2, right: 2, width: 8, height: 8,
borderRadius: 999, background: '#ef4444', border: '1.5px solid var(--sidebar-bg)',
}} />
)}
</button>
</Tooltip>
)
})}
</div>
{(moved || anyError) && active.size > 0 && (
<button
type="button"
onClick={onSearchArea}
className="text-content"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '6px 13px', borderRadius: 999, border: 'none', cursor: 'pointer',
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', pointerEvents: 'auto',
color: anyError ? '#ef4444' : undefined,
...frosted,
}}
>
{anyError
? <AlertTriangle size={13} strokeWidth={2.4} />
: <RotateCw size={13} strokeWidth={2.4} />}
{t('poi.searchThisArea')}
</button>
)}
</div>
)
}
@@ -3,7 +3,6 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet' import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
import L from 'leaflet' import L from 'leaflet'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react' import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { escapeHtml } from '@trek/shared'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import type { Reservation, ReservationEndpoint } from '../../types' import type { Reservation, ReservationEndpoint } from '../../types'
@@ -43,7 +42,7 @@ function useEndpointPane() {
function endpointIcon(type: TransportType, label: string | null): L.DivIcon { function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
const { icon: IconCmp, color } = TYPE_META[type] const { icon: IconCmp, color } = TYPE_META[type]
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 })) const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
const labelHtml = label ? `<span>${escapeHtml(label)}</span>` : '' const labelHtml = label ? `<span>${label}</span>` : ''
const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26 const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26
return L.divIcon({ return L.divIcon({
className: 'trek-endpoint-marker', className: 'trek-endpoint-marker',
@@ -54,7 +53,7 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
border:1.5px solid #fff;color:#fff; border:1.5px solid #fff;color:#fff;
font-family:var(--font-system);font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1; font-family:var(--font-system);font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
box-sizing:border-box;height:22px;white-space:nowrap; 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">${escapeHtml(label)}</span>` : ''}</div>`, "><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], iconSize: [estWidth, 22],
iconAnchor: [estWidth / 2, 11], iconAnchor: [estWidth / 2, 11],
popupAnchor: [0, -11], popupAnchor: [0, -11],
@@ -158,7 +157,6 @@ interface TransportItem {
res: Reservation res: Reservation
from: ReservationEndpoint from: ReservationEndpoint
to: ReservationEndpoint to: ReservationEndpoint
waypoints: ReservationEndpoint[]
type: TransportType type: TransportType
arcs: [number, number][][] arcs: [number, number][][]
primaryArc: [number, number][] primaryArc: [number, number][]
@@ -174,8 +172,8 @@ function buildStatsHtml(color: string, mainLabel: string | null, subLabel: strin
) + 22 ) + 22
const hasBoth = !!mainLabel && !!subLabel const hasBoth = !!mainLabel && !!subLabel
const height = hasBoth ? 36 : 22 const height = hasBoth ? 36 : 22
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${escapeHtml(mainLabel)}</span>` : '' 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' : ''}">${escapeHtml(subLabel)}</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=" const html = `<div class="trek-stats-inner" style="
display:flex;flex-direction:column;align-items:center;justify-content:center; display:flex;flex-direction:column;align-items:center;justify-content:center;
width:100%;height:100%; width:100%;height:100%;
@@ -354,29 +352,15 @@ export default function ReservationOverlay({ reservations, showConnections, show
const out: TransportItem[] = [] const out: TransportItem[] = []
for (const r of reservations) { for (const r of reservations) {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
// Ordered waypoints (from · stops · to). A single-leg booking has exactly two, const eps = r.endpoints || []
// so the arc + markers below are byte-identical to before for it. const from = eps.find(e => e.role === 'from')
const waypoints = (r.endpoints || []) const to = eps.find(e => e.role === 'to')
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop') if (!from || !to) continue
.slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
if (waypoints.length < 2) continue
const from = waypoints[0]
const to = waypoints[waypoints.length - 1]
const type = r.type as TransportType const type = r.type as TransportType
const isGeo = TYPE_META[type].geodesic const isGeo = TYPE_META[type].geodesic
// One arc per leg (between consecutive waypoints), concatenated. const arcs = isGeo
const arcs: [number, number][][] = [] ? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
let distanceKm = 0 : [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
for (let i = 0; i < waypoints.length - 1; i++) {
const a = waypoints[i]
const b = waypoints[i + 1]
const segArcs = isGeo
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
arcs.push(...segArcs)
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
}
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0) const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
const primaryArc = arcs[primaryIdx] ?? [] const primaryArc = arcs[primaryIdx] ?? []
const fallback: [number, number] = primaryArc.length > 0 const fallback: [number, number] = primaryArc.length > 0
@@ -384,15 +368,12 @@ export default function ReservationOverlay({ reservations, showConnections, show
: [(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 duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
const distance = `${Math.round(distanceKm)} km` const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
// Show the full route (FRA → BER → HND) when every waypoint has a code. const mainLabel = from.code && to.code ? `${from.code}${to.code}` : null
const mainLabel = waypoints.every(w => w.code)
? waypoints.map(w => w.code).join(' → ')
: (from.code && to.code ? `${from.code}${to.code}` : null)
const subParts = [duration, distance].filter(Boolean) as string[] const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, fallback, mainLabel, subLabel }) out.push({ res: r, from, to, type, arcs, primaryArc, fallback, mainLabel, subLabel })
} }
return out return out
}, [reservations]) }, [reservations])
@@ -434,21 +415,38 @@ export default function ReservationOverlay({ reservations, showConnections, show
/> />
)))} )))}
{visibleItems.flatMap(item => item.waypoints.map((wp, wi) => ( {visibleItems.flatMap(item => [
<Marker <Marker
key={`wp-${item.res.id}-${wi}`} key={`from-${item.res.id}`}
position={[wp.lat, wp.lng]} position={[item.from.lat, item.from.lng]}
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (wp.code || cleanName(wp.name)) : null)} icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.from.code || cleanName(item.from.name)) : null)}
pane={ENDPOINT_PANE} pane={ENDPOINT_PANE}
zIndexOffset={1000} zIndexOffset={1000}
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }} eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
> >
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip"> <Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
<div style={{ fontWeight: 600, fontSize: 12 }}>{wp.name}</div> <div style={{ fontWeight: 600, fontSize: 12 }}>{item.from.name}</div>
{item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>} {item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
</Tooltip> </Tooltip>
</Marker> </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 className="text-content-muted" style={{ fontSize: 11 }}>{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} />
))}
</> </>
) )
} }
@@ -6,7 +6,6 @@ import {
calculateSegments, calculateSegments,
optimizeRoute, optimizeRoute,
generateGoogleMapsUrl, generateGoogleMapsUrl,
withHotelBookends,
} from './RouteCalculator' } from './RouteCalculator'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1' const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
@@ -162,62 +161,6 @@ describe('optimizeRoute', () => {
expect(result[1]).toEqual(c) expect(result[1]).toEqual(c)
expect(result[2]).toEqual(b) expect(result[2]).toEqual(b)
}) })
it('FE-COMP-ROUTECALCULATOR-016: start anchor begins the chain at the anchor-nearest stop', () => {
const a = { lat: 10, lng: 1 }
const b = { lat: 2, lng: 1 }
const c = { lat: 5, lng: 1 }
// From the accommodation anchor (1,1): nearest is b(2,1), then c(5,1), then a(10,1)
const result = optimizeRoute([a, b, c], { start: { lat: 1, lng: 1 } })
expect(result).toEqual([b, c, a])
})
it('FE-COMP-ROUTECALCULATOR-017: start + end anchors reorder a shuffled day and keep the end-nearest stop last', () => {
const a = { lat: 2, lng: 1 }
const b = { lat: 5, lng: 1 }
const c = { lat: 8, lng: 1 }
// Transfer day: start at hotel A (1,1), end at hotel B (9,1). c is nearest B, so it must be last.
const result = optimizeRoute([c, a, b], { start: { lat: 1, lng: 1 }, end: { lat: 9, lng: 1 } })
expect(result).toEqual([a, b, c])
})
it('FE-COMP-ROUTECALCULATOR-018: an anchor makes even a two-stop day sortable', () => {
const a = { lat: 10, lng: 1 }
const b = { lat: 2, lng: 1 }
// Without anchors two stops are returned unchanged; the start anchor orders them by proximity.
const result = optimizeRoute([a, b], { start: { lat: 1, lng: 1 } })
expect(result).toEqual([b, a])
})
it('FE-COMP-ROUTECALCULATOR-019: 2-opt untangles a round-trip into a clean loop around the hotel', () => {
const hotel = { lat: 48.8668, lng: 2.3013 } // Rue Marbeuf
const stops = [
{ id: 1, lat: 48.8565, lng: 2.3324 },
{ id: 2, lat: 48.8813, lng: 2.3151 },
{ id: 3, lat: 48.8796, lng: 2.308 },
{ id: 4, lat: 48.8723, lng: 2.2926 },
{ id: 5, lat: 48.866, lng: 2.3102 }, // nearest the hotel
]
const d = (a: { lat: number; lng: number }, b: { lat: number; lng: number }) =>
Math.hypot(a.lat - b.lat, a.lng - b.lng)
const loop = (order: typeof stops) =>
d(hotel, order[0]) + order.slice(1).reduce((s, p, i) => s + d(order[i], p), 0) + d(order[order.length - 1], hotel)
const result = optimizeRoute(stops, { start: hotel, end: hotel })
// The optimized loop is no longer than the original order…
expect(loop(result)).toBeLessThanOrEqual(loop(stops) + 1e-9)
// …and the hotel-adjacent stop sits at one end of the loop, right next to the hotel.
expect([result[0].id, result[result.length - 1].id]).toContain(5)
})
it('FE-COMP-ROUTECALCULATOR-020: an end anchor without a start finishes at the stop nearest it', () => {
const a = { lat: 2, lng: 1 }
const b = { lat: 5, lng: 1 }
const c = { lat: 9, lng: 1 }
// a is nearest the end anchor, so the route must finish at a rather than start there.
const result = optimizeRoute([a, b, c], { end: { lat: 1, lng: 1 } })
expect(result[result.length - 1]).toEqual(a)
})
}) })
// ── generateGoogleMapsUrl ────────────────────────────────────────────────────── // ── generateGoogleMapsUrl ──────────────────────────────────────────────────────
@@ -242,46 +185,3 @@ describe('generateGoogleMapsUrl', () => {
expect(result).toContain('48.86,2.36') expect(result).toContain('48.86,2.36')
}) })
}) })
// ── withHotelBookends (#1275: draw the hotel → first / last → hotel legs) ────────
describe('withHotelBookends', () => {
const hotel = { lat: 1, lng: 1 }
const a = { lat: 2, lng: 2 }
const b = { lat: 3, lng: 3 }
const evening = { lat: 4, lng: 4 }
it('FE-COMP-ROUTECALCULATOR-021: leaves runs untouched when there is no hotel', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, a, b, null, null)).toEqual([[a, b]])
})
it('FE-COMP-ROUTECALCULATOR-022: prepends hotel→first and appends last→hotel around the runs', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, a, b, hotel, evening)).toEqual([
[hotel, a],
[a, b],
[b, evening],
])
})
it('FE-COMP-ROUTECALCULATOR-023: a single stop with no runs still draws hotel→stop→hotel', () => {
expect(withHotelBookends([], a, a, hotel, evening)).toEqual([
[hotel, a],
[a, evening],
])
})
it('FE-COMP-ROUTECALCULATOR-024: a missing first/last waypoint skips that bookend', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, undefined, undefined, hotel, evening)).toEqual([[a, b]])
})
it('FE-COMP-ROUTECALCULATOR-025: only the start hotel adds just the opening leg', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, a, b, hotel, null)).toEqual([
[hotel, a],
[a, b],
])
})
})
+20 -113
View File
@@ -1,6 +1,4 @@
import { useSettingsStore } from '../../store/settingsStore' import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint } from '../../types'
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
import { formatDistance } from '../../utils/units'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1' const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
@@ -62,34 +60,13 @@ export async function calculateRoute(
coordinates, coordinates,
distance, distance,
duration, duration,
distanceText: formatRouteDistance(distance), distanceText: formatDistance(distance),
durationText: formatDuration(duration), durationText: formatDuration(duration),
walkingText: formatDuration(walkingDuration), walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(drivingDuration), drivingText: formatDuration(drivingDuration),
} }
} }
/**
* Prepends a hotelfirst-waypoint run and appends a last-waypointhotel run to the
* day's activity runs, so the drawn route starts and ends at the day's accommodation
* (matching the sidebar's hotel connectors). A bookend is only added when both its
* hotel and the first/last located waypoint exist; passing nulls leaves `runs`
* untouched. The shared first/last waypoint is repeated so the polylines join.
*/
export function withHotelBookends(
runs: Waypoint[][],
firstWay: Waypoint | undefined,
lastWay: Waypoint | undefined,
startHotel: Waypoint | null,
endHotel: Waypoint | null,
): Waypoint[][] {
const out: Waypoint[][] = []
if (startHotel && firstWay) out.push([startHotel, firstWay])
out.push(...runs)
if (endHotel && lastWay) out.push([lastWay, endHotel])
return out
}
export function generateGoogleMapsUrl(places: Waypoint[]): string | null { export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
const valid = places.filter((p) => p.lat && p.lng) const valid = places.filter((p) => p.lat && p.lng)
if (valid.length === 0) return null if (valid.length === 0) return null
@@ -100,98 +77,35 @@ export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
return `https://www.google.com/maps/dir/${stops}` return `https://www.google.com/maps/dir/${stops}`
} }
// Squared planar distance — enough for nearest-neighbor comparisons and cheaper than a full haversine. /** Reorders waypoints using a nearest-neighbor heuristic to minimize total Euclidean distance. */
function sqDist(a: Waypoint, b: Waypoint): number { export function optimizeRoute<T extends Waypoint>(places: T[]): T[] {
return (a.lat - b.lat) ** 2 + (a.lng - b.lng) ** 2 const valid = places.filter((p) => p.lat && p.lng)
} if (valid.length <= 2) return places
// Length of visiting `order` in sequence, optionally pinned to a fixed start and/or end anchor.
// With start === end this is a closed loop back to the anchor (a day out from and back to the hotel).
function tourLength(order: Waypoint[], start?: Waypoint, end?: Waypoint): number {
if (order.length === 0) return 0
let total = 0
if (start) total += Math.sqrt(sqDist(start, order[0]))
for (let i = 0; i < order.length - 1; i++) total += Math.sqrt(sqDist(order[i], order[i + 1]))
if (end) total += Math.sqrt(sqDist(order[order.length - 1], end))
return total
}
// Greedy nearest-neighbor ordering, seeded at the start anchor when there is one.
function nearestNeighborOrder<T extends Waypoint>(valid: T[], start?: Waypoint): T[] {
const visited = new Set<number>() const visited = new Set<number>()
const result: T[] = [] const result: T[] = []
let current: Waypoint let current = valid[0]
if (start) { visited.add(0)
current = start result.push(current)
} else {
current = valid[0]
visited.add(0)
result.push(valid[0])
}
while (result.length < valid.length) { while (result.length < valid.length) {
let nearestIdx = -1 let nearestIdx = -1
let minDist = Infinity let minDist = Infinity
for (let i = 0; i < valid.length; i++) { for (let i = 0; i < valid.length; i++) {
if (visited.has(i)) continue if (visited.has(i)) continue
const d = sqDist(valid[i], current) const d = Math.sqrt(
Math.pow(valid[i].lat - current.lat, 2) + Math.pow(valid[i].lng - current.lng, 2)
)
if (d < minDist) { minDist = d; nearestIdx = i } if (d < minDist) { minDist = d; nearestIdx = i }
} }
if (nearestIdx === -1) break if (nearestIdx === -1) break
visited.add(nearestIdx) visited.add(nearestIdx)
current = valid[nearestIdx] current = valid[nearestIdx]
result.push(valid[nearestIdx]) result.push(current)
} }
return result return result
} }
// 2-opt: repeatedly reverse a sub-segment whenever it shortens the tour. This removes the crossings
// a pure nearest-neighbor pass leaves behind. The start/end anchors stay fixed, so a round trip
// (start === end) is untangled into a clean loop rather than an open path.
function twoOptImprove<T extends Waypoint>(order: T[], start?: Waypoint, end?: Waypoint): T[] {
if (order.length < 3) return order
let best = order
let bestLen = tourLength(best, start, end)
let improved = true
while (improved) {
improved = false
for (let i = 0; i < best.length - 1; i++) {
for (let j = i + 1; j < best.length; j++) {
const candidate = best.slice(0, i).concat(best.slice(i, j + 1).reverse(), best.slice(j + 1))
const len = tourLength(candidate, start, end)
if (len < bestLen - 1e-12) {
best = candidate
bestLen = len
improved = true
}
}
}
}
return best
}
/**
* Reorders waypoints to minimize travel distance: a nearest-neighbor pass for a good starting order,
* then 2-opt to untangle crossings. Optional anchors (e.g. the day's accommodation) pin the route's
* ends start === end makes it a loop out from and back to the hotel; a transfer day runs start end.
*/
export function optimizeRoute<T extends Waypoint>(places: T[], anchors: RouteAnchors = {}): T[] {
const { start, end } = anchors
const valid = places.filter((p) => p.lat && p.lng)
if (valid.length <= 1) return places
// Two unanchored stops have no meaningful order to optimize; anchors can still flip them.
if (valid.length === 2 && !start && !end) return places
const order = twoOptImprove(nearestNeighborOrder(valid, start), start, end)
// A round trip's loop direction is arbitrary, so orient it to begin at the stop nearest the hotel —
// that reads naturally as "leave the hotel, head to the closest place, …, come back".
if (start && end && start.lat === end.lat && start.lng === end.lng && order.length > 1) {
if (sqDist(order[order.length - 1], start) < sqDist(order[0], start)) order.reverse()
}
return order
}
/** Fetches per-leg distance/duration from OSRM and returns segment metadata (midpoints, walking/driving times). */ /** Fetches per-leg distance/duration from OSRM and returns segment metadata (midpoints, walking/driving times). */
export async function calculateSegments( export async function calculateSegments(
waypoints: Waypoint[], waypoints: Waypoint[],
@@ -220,7 +134,7 @@ export async function calculateSegments(
duration: leg.duration, duration: leg.duration,
walkingText: formatDuration(walkingDuration), walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(leg.duration), drivingText: formatDuration(leg.duration),
distanceText: formatRouteDistance(leg.distance), distanceText: formatDistance(leg.distance),
} }
}) })
} }
@@ -240,9 +154,7 @@ export async function calculateRouteWithLegs(
} }
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';') const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
// The cached result carries formatted leg distances, so the active distance unit is const cacheKey = `${profile}:${coords}`
// part of the key — otherwise switching km↔mi would return stale text (#1300).
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}`
const cached = routeCache.get(cacheKey) const cached = routeCache.get(cacheKey)
if (cached) return cached if (cached) return cached
@@ -269,7 +181,7 @@ export async function calculateRouteWithLegs(
duration: leg.duration, duration: leg.duration,
walkingText: formatDuration(walkingDuration), walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(leg.duration), drivingText: formatDuration(leg.duration),
distanceText: formatRouteDistance(leg.distance), distanceText: formatDistance(leg.distance),
durationText: formatDuration(leg.duration), durationText: formatDuration(leg.duration),
} }
} }
@@ -284,16 +196,11 @@ export async function calculateRouteWithLegs(
return result return result
} }
function getDistanceUnit(): DistanceUnit { function formatDistance(meters: number): string {
return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric' if (meters < 1000) {
}
function formatRouteDistance(meters: number): string {
const unit = getDistanceUnit()
if (unit === 'metric' && meters < 1000) {
return `${Math.round(meters)} m` return `${Math.round(meters)} m`
} }
return formatDistance(meters / 1000, unit) return `${(meters / 1000).toFixed(1)} km`
} }
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
@@ -1,72 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
MAPBOX_DEFAULT_STYLE,
OPENFREEMAP_DEFAULT_STYLE,
isOpenFreeMapStyle,
normalizeStyleForProvider,
styleForActiveProvider,
basemapLanguage,
} from './glProviders'
describe('glProviders', () => {
it('keeps OpenFreeMap styles for MapLibre', () => {
const style = 'https://tiles.openfreemap.org/styles/bright'
expect(normalizeStyleForProvider('maplibre-gl', style)).toBe(style)
})
it('falls back to OpenFreeMap for MapLibre styles outside the CSP allowlist', () => {
expect(normalizeStyleForProvider('maplibre-gl', 'https://demotiles.maplibre.org/style.json')).toBe(
OPENFREEMAP_DEFAULT_STYLE,
)
expect(normalizeStyleForProvider('maplibre-gl', MAPBOX_DEFAULT_STYLE)).toBe(OPENFREEMAP_DEFAULT_STYLE)
})
it('leaves Mapbox styles unchanged for Mapbox GL', () => {
expect(normalizeStyleForProvider('mapbox-gl', MAPBOX_DEFAULT_STYLE)).toBe(MAPBOX_DEFAULT_STYLE)
})
it('matches the OpenFreeMap CSP host', () => {
expect(isOpenFreeMapStyle('https://tiles.openfreemap.org/styles/liberty')).toBe(true)
expect(isOpenFreeMapStyle('https://demotiles.maplibre.org/style.json')).toBe(false)
})
it('rejects host/userinfo spoofing and http downgrade', () => {
expect(isOpenFreeMapStyle('https://tiles.openfreemap.org.evil.com/styles/x')).toBe(false)
expect(isOpenFreeMapStyle('https://evil.com/@tiles.openfreemap.org/styles/x')).toBe(false)
expect(isOpenFreeMapStyle('http://tiles.openfreemap.org/styles/liberty')).toBe(false)
expect(isOpenFreeMapStyle(' https://tiles.openfreemap.org/styles/liberty ')).toBe(true)
})
it('falls back to provider defaults for empty/whitespace styles', () => {
expect(normalizeStyleForProvider('maplibre-gl', '')).toBe(OPENFREEMAP_DEFAULT_STYLE)
expect(normalizeStyleForProvider('maplibre-gl', ' ')).toBe(OPENFREEMAP_DEFAULT_STYLE)
expect(normalizeStyleForProvider('mapbox-gl', '')).toBe(MAPBOX_DEFAULT_STYLE)
expect(normalizeStyleForProvider('mapbox-gl', null)).toBe(MAPBOX_DEFAULT_STYLE)
})
it('styleForActiveProvider reads each provider\'s own style slot', () => {
const mb = 'mapbox://styles/me/custom'
const ofm = 'https://tiles.openfreemap.org/styles/bright'
expect(styleForActiveProvider('mapbox-gl', mb, ofm)).toBe(mb)
expect(styleForActiveProvider('maplibre-gl', mb, ofm)).toBe(ofm)
// An empty MapLibre slot falls back to the OpenFreeMap default, leaving mapbox untouched.
expect(styleForActiveProvider('maplibre-gl', mb, '')).toBe(OPENFREEMAP_DEFAULT_STYLE)
})
it('basemapLanguage maps TREK UI codes to basemap label codes (#1299)', () => {
// Pass-through for plain ISO 639-1 codes.
expect(basemapLanguage('en')).toBe('en')
expect(basemapLanguage('de')).toBe('de')
expect(basemapLanguage('fr')).toBe('fr')
// TREK-specific overrides.
expect(basemapLanguage('br')).toBe('pt')
expect(basemapLanguage('gr')).toBe('el')
expect(basemapLanguage('zh')).toBe('zh-Hans')
expect(basemapLanguage('zhTw')).toBe('zh-Hant')
expect(basemapLanguage('zh-TW')).toBe('zh-Hant')
// Falls back to English when unset.
expect(basemapLanguage(undefined)).toBe('en')
expect(basemapLanguage('')).toBe('en')
})
})
-87
View File
@@ -1,87 +0,0 @@
export type GlMapProvider = 'mapbox-gl' | 'maplibre-gl'
export interface GlStylePreset {
name: string
url: string
tags?: string[]
}
export const MAPBOX_DEFAULT_STYLE = 'mapbox://styles/mapbox/standard'
export const OPENFREEMAP_DEFAULT_STYLE = 'https://tiles.openfreemap.org/styles/liberty'
export const MAPBOX_STYLE_PRESETS: GlStylePreset[] = [
{ name: 'Mapbox Standard', url: MAPBOX_DEFAULT_STYLE, tags: ['3D', 'Apple-like'] },
{ name: 'Standard Satellite', url: 'mapbox://styles/mapbox/standard-satellite', tags: ['3D', 'Satellite'] },
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12', tags: ['3D', 'Classic'] },
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12', tags: ['3D', 'Terrain'] },
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11', tags: ['3D', 'Minimal'] },
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11', tags: ['3D', 'Dark'] },
{ name: 'Satellite', url: 'mapbox://styles/mapbox/satellite-v9', tags: ['3D', 'Satellite'] },
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12', tags: ['3D', 'Satellite'] },
{ name: 'Navigation Day', url: 'mapbox://styles/mapbox/navigation-day-v1', tags: ['3D', 'Apple-like'] },
{ name: 'Navigation Night', url: 'mapbox://styles/mapbox/navigation-night-v1', tags: ['3D', 'Dark'] },
]
export const OPENFREEMAP_STYLE_PRESETS: GlStylePreset[] = [
{ name: 'OpenFreeMap Liberty', url: OPENFREEMAP_DEFAULT_STYLE, tags: ['OpenFreeMap', '2D'] },
{ name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright', tags: ['OpenFreeMap', 'Classic'] },
{ name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron', tags: ['OpenFreeMap', 'Minimal'] },
]
export function getStylePresets(provider: GlMapProvider): GlStylePreset[] {
return provider === 'maplibre-gl' ? OPENFREEMAP_STYLE_PRESETS : MAPBOX_STYLE_PRESETS
}
export function defaultStyleForProvider(provider: GlMapProvider): string {
return provider === 'maplibre-gl' ? OPENFREEMAP_DEFAULT_STYLE : MAPBOX_DEFAULT_STYLE
}
export function isOpenFreeMapStyle(style?: string | null): boolean {
return (style || '').trim().startsWith('https://tiles.openfreemap.org/')
}
export function normalizeStyleForProvider(provider: GlMapProvider, style?: string | null): string {
const trimmed = (style || '').trim()
if (!trimmed) return defaultStyleForProvider(provider)
if (provider === 'maplibre-gl') {
return isOpenFreeMapStyle(trimmed) ? trimmed : OPENFREEMAP_DEFAULT_STYLE
}
return trimmed
}
/** The settings key that holds the style for a given GL provider. */
export function styleSettingKey(provider: GlMapProvider): 'mapbox_style' | 'maplibre_style' {
return provider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
}
/**
* Each GL provider keeps its style in its own slot (mapbox_style / maplibre_style), so
* switching providers never overwrites the other one's custom style. Picks and normalizes
* the style for the active provider.
*/
export function styleForActiveProvider(
provider: GlMapProvider,
mapboxStyle?: string | null,
maplibreStyle?: string | null,
): string {
return normalizeStyleForProvider(provider, provider === 'maplibre-gl' ? maplibreStyle : mapboxStyle)
}
// A few TREK UI language codes differ from what the GL basemap expects for its labels.
const BASEMAP_LANG_OVERRIDES: Record<string, string> = {
br: 'pt', // TREK 'br' = Brazilian Portuguese
gr: 'el', // TREK 'gr' = Greek
zh: 'zh-Hans',
zhTw: 'zh-Hant',
'zh-TW': 'zh-Hant',
}
/**
* Maps a TREK UI language code to the label language the GL basemap expects. Used to pin
* Mapbox Standard's basemap labels to the user's language so they don't fall back to the
* browser/OS locale and stack multiple scripts per place (#1299).
*/
export function basemapLanguage(uiLang: string | undefined): string {
const code = (uiLang || 'en').trim()
return BASEMAP_LANG_OVERRIDES[code] ?? code
}
@@ -1,13 +1,6 @@
import type mapboxgl from 'mapbox-gl' import mapboxgl from 'mapbox-gl'
import type { GeoPosition } from '../../hooks/useGeolocation' import type { GeoPosition } from '../../hooks/useGeolocation'
type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string }) => {
setLngLat: (lngLat: mapboxgl.LngLatLike) => { addTo: (map: mapboxgl.Map) => unknown }
addTo: (map: mapboxgl.Map) => unknown
remove: () => void
getElement: () => HTMLElement
}
// Build the DOM element that backs the mapbox Marker. We animate the // Build the DOM element that backs the mapbox Marker. We animate the
// heading cone via a CSS rotation so the DOM stays stable across updates // heading cone via a CSS rotation so the DOM stays stable across updates
// and mapbox doesn't get confused about which element to position. // and mapbox doesn't get confused about which element to position.
@@ -73,10 +66,10 @@ export interface LocationMarkerHandle {
// mapbox map. Returns a handle the caller uses to push position updates // mapbox map. Returns a handle the caller uses to push position updates
// and clean up. Keeps its own DOM element and GeoJSON source so it can // and clean up. Keeps its own DOM element and GeoJSON source so it can
// coexist with the regular trip markers. // coexist with the regular trip markers.
export function attachLocationMarker(map: mapboxgl.Map, MarkerCtor: MarkerConstructor): LocationMarkerHandle { export function attachLocationMarker(map: mapboxgl.Map): LocationMarkerHandle {
ensurePulseStyle() ensurePulseStyle()
const { root, cone } = buildLocationEl() const { root, cone } = buildLocationEl()
const marker = new MarkerCtor({ element: root, anchor: 'center' }) const marker = new mapboxgl.Marker({ element: root, anchor: 'center' })
const ensureAccuracyLayer = () => { const ensureAccuracyLayer = () => {
if (map.getSource('trek-location-accuracy')) return if (map.getSource('trek-location-accuracy')) return
-68
View File
@@ -1,68 +0,0 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import type { Place } from '../../types'
// HTML builders for the Mapbox GL hover popup. The Leaflet map already shows a
// name/category/address card on hover (a cursor-following overlay); Mapbox GL has
// no equivalent, so these produce the same card as an HTML string for a
// mapboxgl.Popup. Kept framework-agnostic (plain strings) on purpose.
type PlaceWithCategory = Place & {
category_color?: string | null
category_icon?: string | null
category_name?: string | null
}
function esc(s: string | null | undefined): string {
if (!s) return ''
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
// Render a lucide category icon to an inline SVG string in the given colour.
function iconSvg(iconName: string | null | undefined, size: number, color: string): string {
const Icon = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
try {
return renderToStaticMarkup(createElement(Icon, { size, color, strokeWidth: 2 }))
} catch {
return ''
}
}
// Only data: thumbnails and our own photo-proxy URLs are safe to drop straight
// into an <img src> — everything else is a fetch seed, not a displayable URL.
function isDisplayablePhoto(url: string | null | undefined): url is string {
return !!url && (url.startsWith('data:') || url.startsWith('/api/maps/place-photo/'))
}
const CARD_OPEN = '<div style="font-family:var(--font-system);max-width:220px;">'
const NAME_STYLE = 'font-weight:600;font-size:12.5px;color:#111827;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
const ADDR_STYLE = 'font-size:11px;color:#9ca3af;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
/** Hover-popup card for a planned place: optional photo, name, category row, address. */
export function buildPlacePopupHtml(place: PlaceWithCategory, photoUrl: string | null): string {
const img = isDisplayablePhoto(photoUrl)
? `<div style="width:100%;height:84px;border-radius:8px;overflow:hidden;margin-bottom:6px;background:#f3f4f6;"><img src="${esc(photoUrl)}" style="width:100%;height:100%;object-fit:cover;display:block;" /></div>`
: ''
const category =
place.category_name && place.category_icon
? `<div style="display:flex;align-items:center;gap:4px;margin-top:2px;">${iconSvg(place.category_icon, 11, place.category_color || '#6b7280')}<span style="font-size:11px;color:#6b7280;">${esc(place.category_name)}</span></div>`
: ''
const address = place.address ? `<div style="${ADDR_STYLE}">${esc(place.address)}</div>` : ''
return `${CARD_OPEN}${img}<div style="${NAME_STYLE}">${esc(place.name)}</div>${category}${address}</div>`
}
/** Hover-popup card for an OSM "explore" POI: category-coloured icon, name, address. */
export function buildPoiPopupHtml(poi: Poi): string {
const cat = POI_CATEGORY_BY_KEY[poi.category]
const color = cat?.color || '#6b7280'
const icon = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 12, color, strokeWidth: 2 })) : ''
const head = `<div style="display:flex;align-items:center;gap:5px;"><span style="flex-shrink:0;display:inline-flex;line-height:0;">${icon}</span><span style="${NAME_STYLE}">${esc(poi.name)}</span></div>`
const address = poi.address ? `<div style="${ADDR_STYLE}">${esc(poi.address)}</div>` : ''
return `${CARD_OPEN}${head}${address}</div>`
}
@@ -1,43 +0,0 @@
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, type LucideIcon } from 'lucide-react'
// The POI categories shown in the map "explore" pill. The `key` is the contract
// with the server (CATEGORY_OSM_FILTERS in mapsService.ts) — the OSM tag mapping
// lives there; label/icon/colour live here. `color` doubles as the active-pill
// fill AND the marker colour, so the pill and the map agree visually.
export interface PoiCategory {
key: string
labelKey: string
Icon: LucideIcon
color: string
}
export const POI_CATEGORIES: PoiCategory[] = [
{ key: 'restaurant', labelKey: 'poi.cat.restaurants', Icon: Utensils, color: '#EF4444' },
{ key: 'cafe', labelKey: 'poi.cat.cafes', Icon: Coffee, color: '#B45309' },
{ key: 'bar', labelKey: 'poi.cat.bars', Icon: Wine, color: '#A855F7' },
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: '#2563EB' },
{ key: 'sights', labelKey: 'poi.cat.sights', Icon: Camera, color: '#EC4899' },
{ key: 'museum', labelKey: 'poi.cat.museums', Icon: Landmark, color: '#6366F1' },
{ key: 'nature', labelKey: 'poi.cat.nature', Icon: Trees, color: '#16A34A' },
{ key: 'activity', labelKey: 'poi.cat.activities', Icon: Ticket, color: '#F59E0B' },
]
export const POI_CATEGORY_BY_KEY: Record<string, PoiCategory> = Object.fromEntries(
POI_CATEGORIES.map(c => [c.key, c]),
)
// One POI result from /api/maps/pois (mirror of the server's OverpassPoi).
export interface Poi {
osm_id: string
name: string
lat: number
lng: number
category: string
poi_type: string
address: string | null
website: string | null
phone: string | null
opening_hours: string | null
cuisine: string | null
source: 'openstreetmap'
}
+40 -49
View File
@@ -8,9 +8,8 @@
import { createElement } from 'react' import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server' import { renderToStaticMarkup } from 'react-dom/server'
import type mapboxgl from 'mapbox-gl' import mapboxgl from 'mapbox-gl'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react' import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { escapeHtml } from '@trek/shared'
import type { Reservation, ReservationEndpoint } from '../../types' import type { Reservation, ReservationEndpoint } from '../../types'
export const RESERVATION_SOURCE_ID = 'trek-reservations' export const RESERVATION_SOURCE_ID = 'trek-reservations'
@@ -126,7 +125,6 @@ interface TransportItem {
res: Reservation res: Reservation
from: ReservationEndpoint from: ReservationEndpoint
to: ReservationEndpoint to: ReservationEndpoint
waypoints: ReservationEndpoint[]
type: TransportType type: TransportType
arcs: [number, number][][] arcs: [number, number][][]
primaryArc: [number, number][] primaryArc: [number, number][]
@@ -138,38 +136,23 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
const out: TransportItem[] = [] const out: TransportItem[] = []
for (const r of reservations) { for (const r of reservations) {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
// Ordered waypoints (from · stops · to); a single-leg booking has exactly two. const eps = r.endpoints || []
const waypoints = (r.endpoints || []) const from = eps.find(e => e.role === 'from')
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop') const to = eps.find(e => e.role === 'to')
.slice() if (!from || !to) continue
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
if (waypoints.length < 2) continue
const from = waypoints[0]
const to = waypoints[waypoints.length - 1]
const type = r.type as TransportType const type = r.type as TransportType
const isGeo = TYPE_META[type].geodesic const isGeo = TYPE_META[type].geodesic
// One arc per leg (between consecutive waypoints), concatenated. const arcs = isGeo
const arcs: [number, number][][] = [] ? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
let distanceKm = 0 : [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
for (let i = 0; i < waypoints.length - 1; i++) {
const a = waypoints[i]
const b = waypoints[i + 1]
const segArcs = isGeo
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
arcs.push(...segArcs)
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
}
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0) const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
const primaryArc = arcs[primaryIdx] ?? [] const primaryArc = arcs[primaryIdx] ?? []
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null) const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
const distance = `${Math.round(distanceKm)} km` const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
const mainLabel = waypoints.every(w => w.code) const mainLabel = from.code && to.code ? `${from.code}${to.code}` : null
? waypoints.map(w => w.code).join(' → ')
: (from.code && to.code ? `${from.code}${to.code}` : null)
const subParts = [duration, distance].filter(Boolean) as string[] const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, mainLabel, subLabel }) out.push({ res: r, from, to, type, arcs, primaryArc, mainLabel, subLabel })
} }
return out return out
} }
@@ -178,7 +161,7 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
function endpointMarkerHtml(type: TransportType, label: string | null): string { function endpointMarkerHtml(type: TransportType, label: string | null): string {
const { icon: IconCmp } = TYPE_META[type] const { icon: IconCmp } = TYPE_META[type]
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 })) const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
const labelHtml = label ? `<span style="display:inline-flex;align-items:center;line-height:1">${escapeHtml(label)}</span>` : '' const labelHtml = label ? `<span style="display:inline-flex;align-items:center;line-height:1">${label}</span>` : ''
return `<div style=" return `<div style="
display:inline-flex;align-items:center;justify-content:center;gap:4px; display:inline-flex;align-items:center;justify-content:center;gap:4px;
padding:0 8px;border-radius:999px; padding:0 8px;border-radius:999px;
@@ -196,8 +179,8 @@ function buildStatsHtml(mainLabel: string | null, subLabel: string | null): { ht
) + 22 ) + 22
const hasBoth = !!mainLabel && !!subLabel const hasBoth = !!mainLabel && !!subLabel
const height = hasBoth ? 36 : 22 const height = hasBoth ? 36 : 22
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${escapeHtml(mainLabel)}</span>` : '' 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' : ''}">${escapeHtml(subLabel)}</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=" const html = `<div class="trek-stats-inner" style="
display:flex;flex-direction:column;align-items:center;justify-content:center; display:flex;flex-direction:column;align-items:center;justify-content:center;
width:100%;height:100%; width:100%;height:100%;
@@ -220,29 +203,18 @@ export interface ReservationOverlayOptions {
onEndpointClick?: (reservationId: number) => void onEndpointClick?: (reservationId: number) => void
} }
type GlMarker = {
setLngLat: (lngLat: mapboxgl.LngLatLike) => GlMarker
addTo: (map: mapboxgl.Map) => GlMarker
remove: () => void
getElement: () => HTMLElement
}
type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string }) => GlMarker
export class ReservationMapboxOverlay { export class ReservationMapboxOverlay {
private map: mapboxgl.Map private map: mapboxgl.Map
private items: TransportItem[] = [] private items: TransportItem[] = []
private opts: ReservationOverlayOptions private opts: ReservationOverlayOptions
private MarkerCtor: MarkerConstructor private endpointMarkers: mapboxgl.Marker[] = []
private endpointMarkers: GlMarker[] = [] private statsMarkers: { marker: mapboxgl.Marker; arc: [number, number][] }[] = []
private statsMarkers: { marker: GlMarker; arc: [number, number][] }[] = []
private rerender: () => void private rerender: () => void
private destroyed = false private destroyed = false
constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions, MarkerCtor: MarkerConstructor) { constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions) {
this.map = map this.map = map
this.opts = opts this.opts = opts
this.MarkerCtor = MarkerCtor
this.rerender = () => { if (!this.destroyed) this.render() } this.rerender = () => { if (!this.destroyed) this.render() }
this.setupLayer() this.setupLayer()
map.on('zoomend', this.rerender) map.on('zoomend', this.rerender)
@@ -348,7 +320,7 @@ export class ReservationMapboxOverlay {
if (show) { if (show) {
for (const item of visibleItems) { for (const item of visibleItems) {
const showLabel = this.opts.showEndpointLabels && labelVisibleIds.has(item.res.id) const showLabel = this.opts.showEndpointLabels && labelVisibleIds.has(item.res.id)
for (const ep of item.waypoints) { for (const ep of [item.from, item.to]) {
const label = showLabel ? (ep.code || cleanName(ep.name)) : null const label = showLabel ? (ep.code || cleanName(ep.name)) : null
const el = document.createElement('div') const el = document.createElement('div')
el.innerHTML = endpointMarkerHtml(item.type, label) el.innerHTML = endpointMarkerHtml(item.type, label)
@@ -361,7 +333,7 @@ export class ReservationMapboxOverlay {
this.opts.onEndpointClick?.(item.res.id) this.opts.onEndpointClick?.(item.res.id)
}) })
} }
const marker = new this.MarkerCtor({ element: node, anchor: 'center' }) const marker = new mapboxgl.Marker({ element: node, anchor: 'center' })
.setLngLat([ep.lng, ep.lat]) .setLngLat([ep.lng, ep.lat])
.addTo(map) .addTo(map)
this.endpointMarkers.push(marker) this.endpointMarkers.push(marker)
@@ -369,10 +341,29 @@ export class ReservationMapboxOverlay {
} }
} }
// Stats badge removed — the floating route/duration label on the arc is no // ── stats label (flights only) ──────────────────────────────────
// longer drawn; only the connection line and the airport markers remain.
this.statsMarkers.forEach(s => s.marker.remove()) this.statsMarkers.forEach(s => s.marker.remove())
this.statsMarkers = [] this.statsMarkers = []
if (show && this.opts.showStats) {
for (const item of visibleItems) {
if (item.type !== 'flight') continue
if (!labelVisibleIds.has(item.res.id)) continue
if (!item.mainLabel && !item.subLabel) continue
const arc = item.primaryArc
if (arc.length < 2) continue
const mid = arc[Math.floor(arc.length / 2)]!
const { html, width, height } = buildStatsHtml(item.mainLabel, item.subLabel)
const el = document.createElement('div')
el.style.cssText = `width:${width}px;height:${height}px;pointer-events:none;`
el.innerHTML = html
const marker = new mapboxgl.Marker({ element: el, anchor: 'center' })
.setLngLat([mid[1], mid[0]])
.addTo(map)
this.statsMarkers.push({ marker, arc })
}
}
// Prime rotation once so labels don't flash horizontal on first paint.
this.updateStatsRotation()
} }
// Match the Leaflet overlay's "rotate the label along the arc" look. // Match the Leaflet overlay's "rotate the label along the arc" look.
-115
View File
@@ -1,115 +0,0 @@
import { useState, useRef, useCallback, useMemo } from 'react'
import { mapsApi } from '../../api/client'
import type { Poi } from './poiCategories'
export interface Bbox { south: number; west: number; north: number; east: number }
// A request we cancelled on purpose (newer search superseded it) — not a failure.
function isAbortError(err: unknown): boolean {
const e = err as { name?: string; code?: string } | null
return e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED' || e?.name === 'AbortError'
}
/**
* State for the map POI "explore" pill. Toggling a category fetches its OSM POIs
* for the current viewport; panning/zooming does NOT auto-refetch it just marks
* the results stale (`moved`) so the pill can offer "search this area". This keeps
* Overpass load (and visual churn) down.
*/
export function usePoiExplore() {
const [active, setActive] = useState<Set<string>>(() => new Set())
const [byCat, setByCat] = useState<Record<string, Poi[]>>({})
const [loadingKeys, setLoadingKeys] = useState<Set<string>>(() => new Set())
const [moved, setMoved] = useState(false)
// Categories whose last fetch genuinely failed (all Overpass mirrors down), so
// the pill can offer a retry instead of looking like "no places here".
const [errorKeys, setErrorKeys] = useState<Set<string>>(() => new Set())
const bboxRef = useRef<Bbox | null>(null)
// activeRef always mirrors the latest active set so async callbacks (fetch
// completions) can check whether a category is still wanted.
const activeRef = useRef(active)
activeRef.current = active
// One in-flight AbortController per category, so re-toggling / re-searching
// cancels the previous (possibly slow) Overpass request instead of racing it.
const abortRef = useRef<Record<string, AbortController>>({})
const setLoading = useCallback((key: string, on: boolean) => setLoadingKeys(prev => {
const next = new Set(prev)
if (on) next.add(key); else next.delete(key)
return next
}), [])
const setError = useCallback((key: string, on: boolean) => setErrorKeys(prev => {
if (on === prev.has(key)) return prev
const next = new Set(prev)
if (on) next.add(key); else next.delete(key)
return next
}), [])
const fetchCat = useCallback(async (key: string, bbox: Bbox) => {
abortRef.current[key]?.abort()
const ctrl = new AbortController()
abortRef.current[key] = ctrl
setLoading(key, true)
setError(key, false)
try {
const res = await mapsApi.pois(key, bbox, ctrl.signal)
// Drop the result if the user toggled this category off while the (slow)
// Overpass request was in flight — otherwise stale results re-appear.
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: res.pois } : prev))
} catch (err) {
// A superseded request was aborted on purpose — leave its state untouched
// so the newer request owns the spinner and results.
if (isAbortError(err)) return
// A real failure (every Overpass mirror down/timed out): surface it instead
// of a silent empty so the user can retry rather than assume "no places".
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: [] } : prev))
if (activeRef.current.has(key)) setError(key, true)
} finally {
// Only the latest controller for this key clears the spinner; a superseded
// one must not, or it would hide the newer request's in-flight state.
if (abortRef.current[key] === ctrl) {
setLoading(key, false)
delete abortRef.current[key]
}
}
}, [setLoading, setError])
const onViewportChange = useCallback((bbox: Bbox) => {
bboxRef.current = bbox
if (activeRef.current.size > 0) setMoved(true)
}, [])
// Single-select: clicking a category switches to it (dropping the previous one
// and its markers immediately) and fetches it for the current viewport; clicking
// the already-active category turns it off.
const toggle = useCallback((key: string) => {
const isOnlyActive = activeRef.current.has(key) && activeRef.current.size === 1
setMoved(false)
setErrorKeys(new Set())
// Switching to another category (or turning off) — cancel any in-flight
// fetches so their results can't land after the selection changed.
Object.values(abortRef.current).forEach(c => c.abort())
abortRef.current = {}
if (isOnlyActive) {
setActive(new Set())
setByCat({})
return
}
setActive(new Set([key]))
setByCat({})
if (bboxRef.current) fetchCat(key, bboxRef.current)
}, [fetchCat])
const searchArea = useCallback(() => {
const bbox = bboxRef.current
if (!bbox) return
setMoved(false)
activeRef.current.forEach(key => fetchCat(key, bbox))
}, [fetchCat])
const pois = useMemo(() => Object.values(byCat).flat(), [byCat])
return { active, pois, loadingKeys, errorKeys, moved, toggle, searchArea, onViewportChange }
}
@@ -146,20 +146,4 @@ describe('downloadJourneyBookPDF', () => {
expect(html).toContain('Journey Book'); expect(html).toContain('Journey Book');
expect(html).toContain('The End'); expect(html).toContain('The End');
}); });
it('FE-COMP-JOURNEYPDF-007: sanitises HTML injected via an entry story and keeps the iframe script-free', async () => {
const journey = buildJourney();
journey.entries[0].story = 'Hello <script>alert(1)</script> <img src=x onerror="alert(2)"> world';
await downloadJourneyBookPDF(journey);
const iframe = getIframe()!;
const html = iframe.srcdoc;
// The script tag, image beacon and event handler are stripped from the story.
expect(html).not.toContain('<script');
expect(html).not.toContain('onerror');
expect(html).not.toContain('alert(2)');
// Benign prose survives.
expect(html).toContain('Hello');
expect(html).toContain('world');
});
}); });
+2 -7
View File
@@ -1,6 +1,5 @@
// Journey Photo Book PDF — Polarsteps-inspired, magazine-density // Journey Photo Book PDF — Polarsteps-inspired, magazine-density
import { marked } from 'marked' import { marked } from 'marked'
import { sanitizeRichTextHtml } from '@trek/shared'
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore' import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
function esc(str: string | null | undefined): string { function esc(str: string | null | undefined): string {
@@ -10,9 +9,7 @@ function esc(str: string | null | undefined): string {
function md(str: string | null | undefined): string { function md(str: string | null | undefined): string {
if (!str) return '' if (!str) return ''
// marked passes embedded raw HTML through by default, so sanitise the result return marked.parse(str, { async: false, breaks: true }) as string
// before it goes into the srcdoc iframe (keeps prose markup, drops scripts).
return sanitizeRichTextHtml(marked.parse(str, { async: false, breaks: true }) as string)
} }
function abs(url: string | null | undefined): string { function abs(url: string | null | undefined): string {
@@ -311,9 +308,7 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
const iframe = document.createElement('iframe') const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;' iframe.style.cssText = 'flex:1;width:100%;border:none;'
// No script runs inside the document (print is triggered from the parent via iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
// contentWindow.print()), so withhold allow-scripts to keep the sandbox tight.
iframe.sandbox = 'allow-same-origin allow-modals'
iframe.srcdoc = html iframe.srcdoc = html
card.appendChild(header) card.appendChild(header)
-65
View File
@@ -84,22 +84,6 @@ const transportReservation = {
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }), metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
} as any } as any
const multiLegFlight = {
id: 401,
title: 'Flight to Tokyo',
type: 'flight',
day_id: 10,
reservation_time: '2025-06-01T08:00:00',
confirmation_number: 'XYZ789',
metadata: JSON.stringify({
legs: [
{ from: 'FRA', to: 'BER', airline: 'Lufthansa', flight_number: 'LH1' },
{ from: 'BER', to: 'HND', airline: 'Lufthansa', flight_number: 'LH2' },
],
departure_airport: 'FRA', arrival_airport: 'HND', airline: 'Lufthansa', flight_number: 'LH1',
}),
} as any
const richArgs = { const richArgs = {
trip: { id: 10, title: 'Italy Trip', description: 'Summer adventure', cover_image: '/uploads/cover.jpg' } as any, trip: { id: 10, title: 'Italy Trip', description: 'Summer adventure', cover_image: '/uploads/cover.jpg' } as any,
days: [dayWithPlaces], days: [dayWithPlaces],
@@ -212,16 +196,6 @@ describe('downloadTripPDF', () => {
const iframe = getIframe() const iframe = getIframe()
expect(iframe!.srcdoc).toContain('Flight to Rome') expect(iframe!.srcdoc).toContain('Flight to Rome')
expect(iframe!.srcdoc).toContain('ABC123') expect(iframe!.srcdoc).toContain('ABC123')
// Single-leg flight keeps its full-route subtitle.
expect(iframe!.srcdoc).toContain('Air Italia · AI123 · CDG → FCO')
})
it('FE-COMP-TRIPPDF-013b: renders every flight number for a multi-leg flight', async () => {
await downloadTripPDF({ ...richArgs, reservations: [multiLegFlight] })
const iframe = getIframe()
// One subtitle line per leg, each with its own flight number and segment route.
expect(iframe!.srcdoc).toContain('Lufthansa · LH1 · FRA → BER')
expect(iframe!.srcdoc).toContain('Lufthansa · LH2 · BER → HND')
}) })
it('FE-COMP-TRIPPDF-014: renders cover image when trip has cover_image', async () => { it('FE-COMP-TRIPPDF-014: renders cover image when trip has cover_image', async () => {
@@ -285,23 +259,6 @@ describe('downloadTripPDF', () => {
expect(iframe!.srcdoc).toContain('colosseum.jpg') expect(iframe!.srcdoc).toContain('colosseum.jpg')
}) })
it('FE-COMP-TRIPPDF-018b: renders a persisted place-photo proxy image_url as an <img>, not the category icon (#1130)', async () => {
const args = {
...richArgs,
assignments: {
'10': [{
...assignmentForDay,
place: { ...placeWithDetails, image_url: '/api/maps/place-photo/ChIJabc/bytes' },
}],
} as any,
}
await downloadTripPDF(args)
const iframe = getIframe()
// The proxy path (no file extension) must still embed as an absolute <img>.
expect(iframe!.srcdoc).toContain('http://localhost:3000/api/maps/place-photo/ChIJabc/bytes')
expect(iframe!.srcdoc).toContain('class="place-thumb"')
})
it('FE-COMP-TRIPPDF-019: fetches google place photos for places with google_place_id', async () => { it('FE-COMP-TRIPPDF-019: fetches google place photos for places with google_place_id', async () => {
let photoCalled = false let photoCalled = false
server.use( server.use(
@@ -323,28 +280,6 @@ describe('downloadTripPDF', () => {
expect(photoCalled).toBe(true) expect(photoCalled).toBe(true)
}) })
it('FE-COMP-TRIPPDF-019b: fetches photos for OSM places via osm_id recovered from the places pool (#1130)', async () => {
let fetchedId: string | null = null
server.use(
http.get('/api/maps/place-photo/:placeId', ({ params }) => {
fetchedId = params.placeId as string
return HttpResponse.json({ photoUrl: 'https://example.com/osm.jpg' })
}),
)
// The assignment projection drops osm_id; the full place in `places` carries it.
const osmPlace = { ...placeWithDetails, id: 101, image_url: null, google_place_id: null, osm_id: 'node/240109189', lat: 41.89, lng: 12.49 }
const args = {
...richArgs,
places: [osmPlace],
assignments: {
'10': [{ ...assignmentForDay, id: 201, place_id: 101, place: { ...placeWithDetails, id: 101, image_url: null, google_place_id: null } }],
} as any,
}
await downloadTripPDF(args)
// osm_id is used as the photo key (not the coords fallback), proving the pool lookup works.
expect(fetchedId).toBe('node/240109189')
})
it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => { it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => {
const args = { const args = {
...minimalArgs, ...minimalArgs,
+15 -51
View File
@@ -6,7 +6,6 @@ import { accommodationsApi, mapsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types' import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder' import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
import { splitReservationDateTime } from '../../utils/formatters' import { splitReservationDateTime } from '../../utils/formatters'
import { getFlightLegs } from '../../utils/flightLegs'
function renderLucideIcon(icon:LucideIcon, props = {}) { function renderLucideIcon(icon:LucideIcon, props = {}) {
if (!_renderToStaticMarkup) return '' if (!_renderToStaticMarkup) return ''
@@ -56,10 +55,6 @@ function absUrl(url) {
function safeImg(url) { function safeImg(url) {
if (!url) return null if (!url) return null
if (url.startsWith('https://') || url.startsWith('http://')) return url if (url.startsWith('https://') || url.startsWith('http://')) return url
// The in-app place-photo proxy always streams a JPEG but has no file extension
// (it ends in …/bytes), so the extension check below would wrongly reject it —
// which is why persisted place photos showed as category icons in the PDF.
if (url.startsWith('/api/maps/place-photo/')) return absUrl(url)
return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null
} }
@@ -97,29 +92,21 @@ function dayCost(assignments, dayId, locale) {
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
} }
// Pre-fetch place photos for all assigned places. // Pre-fetch Google Place photos for all assigned places
// Assignment places are a server-side projection that drops osm_id, so we recover async function fetchPlacePhotos(assignments: AssignmentsMap) {
// the full place from the trip's places pool and key the photo off the same id the
// app UI uses (google_place_id || osm_id || coords) — otherwise OSM/coords-only
// places fell back to category icons in the PDF even though they show photos in-app.
async function fetchPlacePhotos(assignments: AssignmentsMap, places: Place[]) {
const photoMap = {} // placeId → photoUrl const photoMap = {} // placeId → photoUrl
// The assignment projection drops osm_id, so recover it from the full places pool.
const osmById = new Map((places || []).map(p => [p.id, p.osm_id]))
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean) const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()] const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
const toFetch = unique // Assignment places are a server-side projection that omits osm_id, so photo
.map(p => ({ p, osm_id: osmById.get(p.id) })) // pre-fetch keys off the google_place_id that the projection does carry.
.filter(({ p, osm_id }) => !p.image_url && (p.google_place_id || osm_id || (p.lat != null && p.lng != null))) const toFetch = unique.filter(p => !p.image_url && p.google_place_id)
await Promise.allSettled( await Promise.allSettled(
toFetch.map(async ({ p, osm_id }) => { toFetch.map(async (place) => {
// Same key the app UI uses: google_place_id || osm_id || coords.
const photoId = p.google_place_id || osm_id || `coords:${p.lat}:${p.lng}`
try { try {
const data = await mapsApi.placePhoto(photoId, p.lat, p.lng, p.name) const data = await mapsApi.placePhoto(place.google_place_id, place.lat, place.lng, place.name)
if (data.photoUrl) photoMap[p.id] = data.photoUrl if (data.photoUrl) photoMap[place.id] = data.photoUrl
} catch {} } catch {}
}) })
) )
@@ -149,8 +136,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed //retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
const accommodations = await accommodationsApi.list(trip.id); const accommodations = await accommodationsApi.list(trip.id);
// Pre-fetch place photos (Google, OSM and coords-only places) // Pre-fetch place photos from Google
const photoMap = await fetchPlacePhotos(assignments, places) const photoMap = await fetchPlacePhotos(assignments)
const totalAssigned = new Set( const totalAssigned = new Set(
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean) Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
@@ -224,30 +211,11 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const icon = reservationIconSvg(r.type) const icon = reservationIconSvg(r.type)
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6' const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
let subtitle = '' let subtitle = ''
// Flights render one subtitle line per leg (see below); everything else is a single line. if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport}${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
let subtitleLines: string[] = []
if (r.type === 'flight') {
const legs = getFlightLegs(r)
if (legs.length > 1) {
// Multi-leg: one line per leg so every flight number + segment route is shown.
subtitleLines = legs.map(l =>
[l.airline, l.flight_number,
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '']
.filter(Boolean).join(' · '))
.filter(Boolean)
} else {
// Single-leg: full route over all waypoints (FRA → BER → HND), falling back to the
// flat metadata pair for legacy single-leg flights without endpoints.
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
const route = stops.length >= 2 ? stops.join(' → ') : (meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport}${meta.arrival_airport}` : '')
subtitle = [meta.airline, meta.flight_number, route].filter(Boolean).join(' · ')
}
}
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ') else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ') else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ') else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ') else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
if (subtitleLines.length === 0 && subtitle) subtitleLines = [subtitle]
const locationLine = r.location || meta.location || '' const locationLine = r.location || meta.location || ''
const phase = pdfGetSpanPhase(r, day.id) const phase = pdfGetSpanPhase(r, day.id)
const spanLabel = pdfGetSpanLabel(r, phase) const spanLabel = pdfGetSpanLabel(r, phase)
@@ -260,7 +228,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
<span class="note-icon">${icon}</span> <span class="note-icon">${icon}</span>
<div class="note-body"> <div class="note-body">
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div> <div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
${subtitleLines.filter(Boolean).map(s => `<div class="note-time">${escHtml(s)}</div>`).join('')} ${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''} ${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''} ${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
</div> </div>
@@ -286,10 +254,9 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const cat = categories.find(c => c.id === place.category_id) const cat = categories.find(c => c.id === place.category_id)
const color = cat?.color || '#6366f1' const color = cat?.color || '#6366f1'
// Image: direct > google photo > fallback icon. Both go through safeImg // Image: direct > google photo > fallback icon
// so the proxy path is resolved to an absolute URL the PDF can load.
const directImg = safeImg(place.image_url) const directImg = safeImg(place.image_url)
const googleImg = safeImg(photoMap[place.id]) const googleImg = photoMap[place.id] || null
const img = directImg || googleImg const img = directImg || googleImg
const iconSvg = categoryIconSvg(cat?.icon, color, 24) const iconSvg = categoryIconSvg(cat?.icon, color, 24)
@@ -315,7 +282,6 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</span>` : ''} ${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</span>` : ''}
</div> </div>
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''} ${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
${(place.lat != null && place.lng != null) ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted">${Number(place.lat).toFixed(5)}, ${Number(place.lng).toFixed(5)}</span></div>` : ''}
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''} ${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
${chips ? `<div class="chips">${chips}</div>` : ''} ${chips ? `<div class="chips">${chips}</div>` : ''}
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''} ${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
@@ -603,9 +569,7 @@ ${daysHtml}
const iframe = document.createElement('iframe') const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;' iframe.style.cssText = 'flex:1;width:100%;border:none;'
// No script runs inside the document (print is parent-initiated), so withhold iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
// allow-scripts to keep the sandbox tight.
iframe.sandbox = 'allow-same-origin allow-modals'
iframe.srcdoc = html iframe.srcdoc = html
card.appendChild(header) card.appendChild(header)
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { Package } from 'lucide-react' import { Package } from 'lucide-react'
import { packingApi } from '../../api/client' import { adminApi, packingApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
@@ -28,7 +28,7 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT
const { t } = useTranslation() const { t } = useTranslation()
useEffect(() => { useEffect(() => {
packingApi.listTemplates(tripId).then(d => setTemplates(d.templates || [])).catch(() => {}) adminApi.packingTemplates().then(d => setTemplates(d.templates || [])).catch(() => {})
}, [tripId]) }, [tripId])
useEffect(() => { useEffect(() => {
@@ -82,7 +82,7 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT
style={{ style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%', display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '8px 12px', borderRadius: 8, border: 'none', cursor: 'pointer', padding: '8px 12px', borderRadius: 8, border: 'none', cursor: 'pointer',
background: 'transparent', fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', background: 'transparent', fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
}} }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
@@ -90,7 +90,7 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT
<Package size={13} className="text-content-faint" /> <Package size={13} className="text-content-faint" />
<div style={{ flex: 1, textAlign: 'left' }}> <div style={{ flex: 1, textAlign: 'left' }}>
<div style={{ fontWeight: 600 }}>{tmpl.name}</div> <div style={{ fontWeight: 600 }}>{tmpl.name}</div>
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}> <div style={{ fontSize: 10, color: 'var(--text-faint)' }}>
{tmpl.item_count} {t('admin.packingTemplates.items')} {tmpl.item_count} {t('admin.packingTemplates.items')}
</div> </div>
</div> </div>
@@ -7,7 +7,7 @@ import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildAdmin, buildTrip, buildPackingItem } from '../../../tests/helpers/factories'; import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
import PackingListPanel, { itemWeight } from './PackingListPanel'; import PackingListPanel, { itemWeight } from './PackingListPanel';
describe('itemWeight (bag total weight calc)', () => { describe('itemWeight (bag total weight calc)', () => {
@@ -34,10 +34,10 @@ beforeEach(() => {
http.get('/api/trips/:id/packing/category-assignees', () => http.get('/api/trips/:id/packing/category-assignees', () =>
HttpResponse.json({ assignees: {} }) HttpResponse.json({ assignees: {} })
), ),
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: false, addons: [] }) HttpResponse.json({ enabled: false })
), ),
http.get('/api/trips/:id/packing/templates', () => http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [] }) HttpResponse.json({ templates: [] })
), ),
); );
@@ -174,9 +174,7 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-016: delete item button exists and triggers API call', async () => { it('FE-COMP-PACKING-016: delete item button exists and triggers API call', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
// Uncategorized item: deleting it is a plain DELETE (a custom category's last const item = buildPackingItem({ id: 99, name: 'To Remove', category: 'Test' });
// item is instead converted to a placeholder — see FE-COMP-PACKING-070).
const item = buildPackingItem({ id: 99, name: 'To Remove', category: null });
let deleteCalled = false; let deleteCalled = false;
server.use( server.use(
http.delete('/api/trips/1/packing/99', () => { http.delete('/api/trips/1/packing/99', () => {
@@ -383,7 +381,7 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-030: packing template button present when templates available', async () => { it('FE-COMP-PACKING-030: packing template button present when templates available', async () => {
server.use( server.use(
http.get('/api/trips/:id/packing/templates', () => http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [{ id: 1, name: 'Beach Trip', item_count: 5 }] }) HttpResponse.json({ templates: [{ id: 1, name: 'Beach Trip', item_count: 5 }] })
) )
); );
@@ -459,8 +457,8 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-034: bag tracking enabled shows Bags button and bag sidebar', async () => { it('FE-COMP-PACKING-034: bag tracking enabled shows Bags button and bag sidebar', async () => {
server.use( server.use(
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: true, addons: [] }) HttpResponse.json({ enabled: true })
), ),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 1, name: 'Carry-on', color: '#6366f1', weight_limit_grams: null, members: [] }] }) HttpResponse.json({ bags: [{ id: 1, name: 'Carry-on', color: '#6366f1', weight_limit_grams: null, members: [] }] })
@@ -558,8 +556,8 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-039: bag modal opens when Bags button clicked with bag tracking enabled', async () => { it('FE-COMP-PACKING-039: bag modal opens when Bags button clicked with bag tracking enabled', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use( server.use(
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: true, addons: [] }) HttpResponse.json({ enabled: true })
), ),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
@@ -587,8 +585,8 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-040: bag sidebar renders BagCard with bag name when enabled and bags exist', async () => { it('FE-COMP-PACKING-040: bag sidebar renders BagCard with bag name when enabled and bags exist', async () => {
server.use( server.use(
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: true, addons: [] }) HttpResponse.json({ enabled: true })
), ),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 5, name: 'Backpack', color: '#10b981', weight_limit_grams: 10000, members: [] }] }) HttpResponse.json({ bags: [{ id: 5, name: 'Backpack', color: '#10b981', weight_limit_grams: 10000, members: [] }] })
@@ -603,36 +601,26 @@ describe('PackingListPanel', () => {
}); });
}); });
it('FE-COMP-PACKING-041: save-as-template button present for admins when items exist', async () => { it('FE-COMP-PACKING-041: save-as-template button present when items exist', async () => {
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
const user = userEvent.setup(); const user = userEvent.setup();
const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })]; const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })];
render(<PackingListPanel tripId={1} items={items} />); const { container } = render(<PackingListPanel tripId={1} items={items} />);
// Save-as-template button shows its label "Save as template" // Save-as-template button uses FolderPlus icon and "Save as template" text
const saveBtn = screen.getByText('Save as template').closest('button'); const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button');
expect(saveBtn).toBeTruthy(); expect(folderPlusBtn).toBeTruthy();
// Click to show the name input // Click to show the name input
await user.click(saveBtn!); await user.click(folderPlusBtn!);
// Template name input appears // Template name input appears
expect(await screen.findByPlaceholderText('Template name')).toBeInTheDocument(); expect(await screen.findByPlaceholderText('Template name')).toBeInTheDocument();
}); });
it('FE-COMP-PACKING-041b: save-as-template button hidden for non-admins', () => {
// Default seeded user (beforeEach) is a non-admin trip owner with edit rights.
const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })];
render(<PackingListPanel tripId={1} items={items} />);
// The "Save as template" action must not be available to normal users.
expect(screen.queryByText('Save as template')).not.toBeInTheDocument();
});
it('FE-COMP-PACKING-042: apply template dropdown opens when template button clicked', async () => { it('FE-COMP-PACKING-042: apply template dropdown opens when template button clicked', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use( server.use(
http.get('/api/trips/:id/packing/templates', () => http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [{ id: 2, name: 'Summer Packing', item_count: 10 }] }) HttpResponse.json({ templates: [{ id: 2, name: 'Summer Packing', item_count: 10 }] })
) )
); );
@@ -670,8 +658,8 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-044: bag item row shows weight input and bag button when bag tracking enabled', async () => { it('FE-COMP-PACKING-044: bag item row shows weight input and bag button when bag tracking enabled', async () => {
server.use( server.use(
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: true, addons: [] }) HttpResponse.json({ enabled: true })
), ),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [] }) HttpResponse.json({ bags: [] })
@@ -718,7 +706,6 @@ describe('PackingListPanel', () => {
}); });
it('FE-COMP-PACKING-046: save-as-template form submission calls saveAsTemplate API', async () => { it('FE-COMP-PACKING-046: save-as-template form submission calls saveAsTemplate API', async () => {
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
const user = userEvent.setup(); const user = userEvent.setup();
let savedTemplateName = ''; let savedTemplateName = '';
server.use( server.use(
@@ -727,16 +714,16 @@ describe('PackingListPanel', () => {
savedTemplateName = String(body.name); savedTemplateName = String(body.name);
return HttpResponse.json({ success: true }); return HttpResponse.json({ success: true });
}), }),
http.get('/api/trips/:id/packing/templates', () => http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [] }) HttpResponse.json({ templates: [] })
) )
); );
const items = [buildPackingItem({ name: 'Item', category: 'Test' })]; const items = [buildPackingItem({ name: 'Item', category: 'Test' })];
render(<PackingListPanel tripId={1} items={items} />); const { container } = render(<PackingListPanel tripId={1} items={items} />);
// Click the "Save as template" button // Click the FolderPlus "Save as template" button
const saveBtn = screen.getByText('Save as template').closest('button'); const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button');
await user.click(saveBtn!); await user.click(folderPlusBtn!);
// Type template name // Type template name
const nameInput = await screen.findByPlaceholderText('Template name'); const nameInput = await screen.findByPlaceholderText('Template name');
@@ -749,8 +736,8 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-047: bag picker in item row opens when clicked with bag tracking enabled', async () => { it('FE-COMP-PACKING-047: bag picker in item row opens when clicked with bag tracking enabled', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use( server.use(
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: true, addons: [] }) HttpResponse.json({ enabled: true })
), ),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 3, name: 'Carry-on', color: '#ec4899', weight_limit_grams: null, members: [] }] }) HttpResponse.json({ bags: [{ id: 3, name: 'Carry-on', color: '#ec4899', weight_limit_grams: null, members: [] }] })
@@ -778,8 +765,8 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-048: add bag in bag modal opens form when "Add bag" clicked', async () => { it('FE-COMP-PACKING-048: add bag in bag modal opens form when "Add bag" clicked', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use( server.use(
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: true, addons: [] }) HttpResponse.json({ enabled: true })
), ),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
@@ -818,8 +805,8 @@ describe('PackingListPanel', () => {
let putBody: Record<string, unknown> | null = null; let putBody: Record<string, unknown> | null = null;
const itemId = 120; const itemId = 120;
server.use( server.use(
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: true, addons: [] }) HttpResponse.json({ enabled: true })
), ),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [] }) HttpResponse.json({ bags: [] })
@@ -874,8 +861,8 @@ describe('PackingListPanel', () => {
const itemId = 130; const itemId = 130;
let putBody: Record<string, unknown> | null = null; let putBody: Record<string, unknown> | null = null;
server.use( server.use(
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: true, addons: [] }) HttpResponse.json({ enabled: true })
), ),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 7, name: 'Trolley', color: '#10b981', weight_limit_grams: null, members: [] }] }) HttpResponse.json({ bags: [{ id: 7, name: 'Trolley', color: '#10b981', weight_limit_grams: null, members: [] }] })
@@ -943,8 +930,8 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-054: item with assigned bag shows "Unassigned" option in bag picker', async () => { it('FE-COMP-PACKING-054: item with assigned bag shows "Unassigned" option in bag picker', async () => {
const itemId = 140; const itemId = 140;
server.use( server.use(
http.get('/api/addons', () => http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ bagTracking: true, addons: [] }) HttpResponse.json({ enabled: true })
), ),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 5, name: 'MyBag', color: '#ec4899', weight_limit_grams: null, members: [] }] }) HttpResponse.json({ bags: [{ id: 5, name: 'MyBag', color: '#ec4899', weight_limit_grams: null, members: [] }] })
@@ -970,7 +957,7 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-055: apply template button click opens template dropdown and shows template', async () => { it('FE-COMP-PACKING-055: apply template button click opens template dropdown and shows template', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use( server.use(
http.get('/api/trips/:id/packing/templates', () => http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [{ id: 3, name: 'Weekend Pack', item_count: 8 }] }) HttpResponse.json({ templates: [{ id: 3, name: 'Weekend Pack', item_count: 8 }] })
) )
); );
@@ -1137,7 +1124,7 @@ describe('PackingListPanel', () => {
const user = userEvent.setup(); const user = userEvent.setup();
let applyCalled = false; let applyCalled = false;
server.use( server.use(
http.get('/api/trips/:id/packing/templates', () => http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [{ id: 5, name: 'Beach Trip', item_count: 12 }] }) HttpResponse.json({ templates: [{ id: 5, name: 'Beach Trip', item_count: 12 }] })
), ),
http.post('/api/trips/1/packing/apply-template/5', () => { http.post('/api/trips/1/packing/apply-template/5', () => {
@@ -1190,7 +1177,7 @@ describe('PackingListPanel', () => {
const user = userEvent.setup(); const user = userEvent.setup();
let createBody: Record<string, unknown> | null = null; let createBody: Record<string, unknown> | null = null;
server.use( server.use(
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
// Start with one bag so the sidebar renders (sidebar requires bags.length > 0) // Start with one bag so the sidebar renders (sidebar requires bags.length > 0)
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 1, name: 'Existing Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) HttpResponse.json({ bags: [{ id: 1, name: 'Existing Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
@@ -1220,7 +1207,7 @@ describe('PackingListPanel', () => {
const user = userEvent.setup(); const user = userEvent.setup();
let deleteCalled = false; let deleteCalled = false;
server.use( server.use(
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 9, name: 'Old Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) HttpResponse.json({ bags: [{ id: 9, name: 'Old Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
), ),
@@ -1248,7 +1235,7 @@ describe('PackingListPanel', () => {
const user = userEvent.setup(); const user = userEvent.setup();
let updateBody: Record<string, unknown> | null = null; let updateBody: Record<string, unknown> | null = null;
server.use( server.use(
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 11, name: 'Carry-on', color: '#10b981', weight_limit_grams: null, members: [] }] }) HttpResponse.json({ bags: [{ id: 11, name: 'Carry-on', color: '#10b981', weight_limit_grams: null, members: [] }] })
), ),
@@ -1286,7 +1273,7 @@ describe('PackingListPanel', () => {
current_user_id: 1, current_user_id: 1,
}) })
), ),
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 12, name: 'Day Pack', color: '#ec4899', weight_limit_grams: null, members: [] }] }) HttpResponse.json({ bags: [{ id: 12, name: 'Day Pack', color: '#ec4899', weight_limit_grams: null, members: [] }] })
) )
@@ -1327,7 +1314,7 @@ describe('PackingListPanel', () => {
current_user_id: 1, current_user_id: 1,
}) })
), ),
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () => http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 13, name: 'Weekend Bag', color: '#f97316', weight_limit_grams: null, members: [] }] }) HttpResponse.json({ bags: [{ id: 13, name: 'Weekend Bag', color: '#f97316', weight_limit_grams: null, members: [] }] })
), ),
@@ -1365,7 +1352,7 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-068: inline bag create in item row picker creates bag and assigns it', async () => { it('FE-COMP-PACKING-068: inline bag create in item row picker creates bag and assigns it', async () => {
let createBody: Record<string, unknown> | null = null; let createBody: Record<string, unknown> | null = null;
server.use( server.use(
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] })), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] })),
http.post('/api/trips/1/packing/bags', async ({ request }) => { http.post('/api/trips/1/packing/bags', async ({ request }) => {
createBody = await request.json() as Record<string, unknown>; createBody = await request.json() as Record<string, unknown>;
@@ -1417,83 +1404,4 @@ describe('PackingListPanel', () => {
expect(clickSpy).toHaveBeenCalled(); expect(clickSpy).toHaveBeenCalled();
clickSpy.mockRestore(); clickSpy.mockRestore();
}); });
it('FE-COMP-PACKING-070: deleting the last item of a custom category converts the row to a placeholder so the category persists in place (#1289)', async () => {
const user = userEvent.setup();
const item = buildPackingItem({ id: 99, name: 'Tent', category: 'Camping Gear' });
// handleDeleteItem decides "last in category" from the rendered list.
seedStore(useTripStore, { packingItems: [item] });
let deleted = false;
let putBody: Record<string, unknown> | null = null;
server.use(
http.delete('/api/trips/1/packing/99', () => {
deleted = true;
return HttpResponse.json({ success: true });
}),
http.put('/api/trips/1/packing/99', async ({ request }) => {
putBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: 99, name: '...', category: 'Camping Gear' }) });
})
);
render(<PackingListPanel tripId={1} items={[item]} />);
await user.click(screen.getByTitle('Delete'));
// The row is updated in place (same id) rather than deleted, so colour/position hold.
await waitFor(() => expect(putBody).toMatchObject({ name: '...' }));
expect(deleted).toBe(false);
});
it('FE-COMP-PACKING-071: deleting the placeholder row deletes it, dismissing the empty category (#1289)', async () => {
const user = userEvent.setup();
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
seedStore(useTripStore, { packingItems: [placeholder] });
let deleted = false;
let converted = false;
server.use(
http.delete('/api/trips/1/packing/5', () => {
deleted = true;
return HttpResponse.json({ success: true });
}),
http.put('/api/trips/1/packing/5', () => {
converted = true;
return HttpResponse.json({ item: placeholder });
})
);
render(<PackingListPanel tripId={1} items={[placeholder]} />);
await user.click(screen.getByTitle('Delete'));
await waitFor(() => expect(deleted).toBe(true));
// It is the placeholder itself — it must be removed, not re-converted.
expect(converted).toBe(false);
});
it('FE-COMP-PACKING-072: adding an item to an empty category reuses the placeholder row instead of appending (#1289)', async () => {
const user = userEvent.setup();
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
seedStore(useTripStore, { packingItems: [placeholder] });
let posted = false;
let putBody: Record<string, unknown> | null = null;
server.use(
http.post('/api/trips/1/packing', () => {
posted = true;
return HttpResponse.json({ item: buildPackingItem({ id: 6 }) });
}),
http.put('/api/trips/1/packing/5', async ({ request }) => {
putBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: 5, name: 'Tent', category: 'Camping Gear' }) });
})
);
render(<PackingListPanel tripId={1} items={[placeholder]} />);
// Open the category's inline "Add item" and add a real entry.
await user.click(screen.getByText('Add item'));
const input = await screen.findByPlaceholderText('Item name...');
await user.type(input, 'Tent');
await user.keyboard('{Enter}');
await waitFor(() => expect(putBody).toMatchObject({ name: 'Tent' }));
expect(posted).toBe(false);
});
}); });
@@ -69,13 +69,13 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
const isSelected = memberIds.includes(m.id) const isSelected = memberIds.includes(m.id)
return ( return (
<button key={m.id} onClick={() => { toggleMember(m.id); }} <button key={m.id} onClick={() => { toggleMember(m.id); }}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', background: isSelected ? 'var(--bg-tertiary)' : 'transparent', cursor: 'pointer', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-primary)', fontFamily: 'inherit' }} style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', background: isSelected ? 'var(--bg-tertiary)' : 'transparent', cursor: 'pointer', fontSize: 11, color: 'var(--text-primary)', fontFamily: 'inherit' }}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-secondary)' }} onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-secondary)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}> onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}>
{m.avatar ? ( {m.avatar ? (
<img src={m.avatar} alt="" style={{ width: 20, height: 20, borderRadius: '50%', objectFit: 'cover' }} /> <img src={m.avatar} alt="" style={{ width: 20, height: 20, borderRadius: '50%', objectFit: 'cover' }} />
) : ( ) : (
<span style={{ width: 20, height: 20, borderRadius: '50%', background: 'var(--bg-tertiary)', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-faint)' }}> <span style={{ width: 20, height: 20, borderRadius: '50%', background: 'var(--bg-tertiary)', fontSize: 10, fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-faint)' }}>
{m.username[0].toUpperCase()} {m.username[0].toUpperCase()}
</span> </span>
)} )}
@@ -84,9 +84,9 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
</button> </button>
) )
})} })}
{tripMembers.length === 0 && <div style={{ padding: '8px 10px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{t('packing.noMembers')}</div>} {tripMembers.length === 0 && <div style={{ padding: '8px 10px', fontSize: 11, color: 'var(--text-faint)' }}>{t('packing.noMembers')}</div>}
<div style={{ borderTop: '1px solid var(--border-secondary)', marginTop: 4, paddingTop: 4 }}> <div style={{ borderTop: '1px solid var(--border-secondary)', marginTop: 4, paddingTop: 4 }}>
<button onClick={() => setShowUserPicker(false)} style={{ width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', background: 'transparent', cursor: 'pointer', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontFamily: 'inherit', textAlign: 'center' }}> <button onClick={() => setShowUserPicker(false)} style={{ width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', background: 'transparent', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit', textAlign: 'center' }}>
{t('common.close')} {t('common.close')}
</button> </button>
</div> </div>
@@ -14,7 +14,7 @@ export function BagModal(S: PackingState) {
<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 }} <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()}> onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<h3 style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.bags')}</h3> <h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.bags')}</h3>
<button onClick={() => setShowBagModal(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><X size={18} /></button> <button onClick={() => setShowBagModal(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><X size={18} /></button>
</div> </div>
@@ -37,19 +37,19 @@ export function BagModal(S: PackingState) {
<div style={{ marginBottom: 16, opacity: 0.6 }}> <div style={{ marginBottom: 16, opacity: 0.6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ width: 12, height: 12, borderRadius: '50%', border: '2px dashed var(--border-primary)', flexShrink: 0 }} /> <span style={{ width: 12, height: 12, borderRadius: '50%', border: '2px dashed var(--border-primary)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-faint)' }}>{t('packing.noBag')}</span> <span style={{ flex: 1, fontSize: 14, fontWeight: 600, color: 'var(--text-faint)' }}>{t('packing.noBag')}</span>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}> <span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
{unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`} {unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
</span> </span>
</div> </div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div> <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div>
</div> </div>
) )
})()} })()}
{/* Total */} {/* Total */}
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}> <div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)' }}> <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span> <span>{t('packing.totalWeight')}</span>
<span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span> <span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
</div> </div>
@@ -61,7 +61,7 @@ export function BagModal(S: PackingState) {
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)} <input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }} onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
placeholder={t('packing.bagName')} placeholder={t('packing.bagName')}
style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none' }} /> style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none' }} />
<button onClick={handleCreateBag} disabled={!newBagName.trim()} <button onClick={handleCreateBag} disabled={!newBagName.trim()}
style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: newBagName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newBagName.trim() ? 'pointer' : 'default', display: 'flex', alignItems: 'center' }}> style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: newBagName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newBagName.trim() ? 'pointer' : 'default', display: 'flex', alignItems: 'center' }}>
<Plus size={14} /> <Plus size={14} />
@@ -69,7 +69,7 @@ export function BagModal(S: PackingState) {
</div> </div>
) : ( ) : (
<button onClick={() => setShowAddBag(true)} <button onClick={() => setShowAddBag(true)}
style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 14, padding: '9px 14px', borderRadius: 10, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%', transition: 'all 0.15s' }} style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 14, padding: '9px 14px', borderRadius: 10, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%', transition: 'all 0.15s' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-secondary)' }} onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-secondary)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}> onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
<Plus size={14} /> {t('packing.addBag')} <Plus size={14} /> {t('packing.addBag')}
@@ -10,7 +10,7 @@ export function BagSidebar(S: PackingState) {
} = S } = S
return ( return (
<div className="hidden xl:block" style={{ width: 260, borderLeft: '1px solid var(--border-secondary)', overflowY: 'auto', padding: 16, flexShrink: 0 }}> <div className="hidden xl:block" style={{ width: 260, borderLeft: '1px solid var(--border-secondary)', overflowY: 'auto', padding: 16, flexShrink: 0 }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-faint)', marginBottom: 12 }}> <div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-faint)', marginBottom: 12 }}>
{t('packing.bags')} {t('packing.bags')}
</div> </div>
@@ -33,19 +33,19 @@ export function BagSidebar(S: PackingState) {
<div style={{ marginBottom: 14, opacity: 0.6 }}> <div style={{ marginBottom: 14, opacity: 0.6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', border: '2px dashed var(--border-primary)', flexShrink: 0 }} /> <span style={{ width: 10, height: 10, borderRadius: '50%', border: '2px dashed var(--border-primary)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-faint)' }}>{t('packing.noBag')}</span> <span style={{ flex: 1, fontSize: 12, fontWeight: 600, color: 'var(--text-faint)' }}>{t('packing.noBag')}</span>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}> <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>
{unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`} {unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
</span> </span>
</div> </div>
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div> <div style={{ fontSize: 10, color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div>
</div> </div>
) )
})()} })()}
{/* Total */} {/* Total */}
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}> <div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)' }}> <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span> <span>{t('packing.totalWeight')}</span>
<span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span> <span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
</div> </div>
@@ -57,14 +57,14 @@ export function BagSidebar(S: PackingState) {
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)} <input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }} onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
placeholder={t('packing.bagName')} placeholder={t('packing.bagName')}
style={{ flex: 1, padding: '5px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontFamily: 'inherit', outline: 'none' }} /> style={{ flex: 1, padding: '5px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 11, fontFamily: 'inherit', outline: 'none' }} />
<button onClick={handleCreateBag} style={{ padding: '4px 8px', borderRadius: 8, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}> <button onClick={handleCreateBag} style={{ padding: '4px 8px', borderRadius: 8, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Plus size={12} /> <Plus size={12} />
</button> </button>
</div> </div>
) : ( ) : (
<button onClick={() => setShowAddBag(true)} <button onClick={() => setShowAddBag(true)}
style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 12, padding: '5px 8px', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%' }}> style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 12, padding: '5px 8px', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%' }}>
<Plus size={11} /> {t('packing.addBag')} <Plus size={11} /> {t('packing.addBag')}
</button> </button>
))} ))}
@@ -18,7 +18,6 @@ interface KategorieGruppeProps {
allCategories: string[] allCategories: string[]
onRename: (oldName: string, newName: string) => Promise<void> onRename: (oldName: string, newName: string) => Promise<void>
onDeleteAll: (items: PackingItem[]) => Promise<void> onDeleteAll: (items: PackingItem[]) => Promise<void>
onDeleteItem: (item: PackingItem) => Promise<void>
onAddItem: (category: string, name: string) => Promise<void> onAddItem: (category: string, name: string) => Promise<void>
assignees: CategoryAssignee[] assignees: CategoryAssignee[]
tripMembers: TripMember[] tripMembers: TripMember[]
@@ -29,7 +28,7 @@ interface KategorieGruppeProps {
canEdit?: boolean canEdit?: boolean
} }
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) { export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
const [offen, setOffen] = useState(true) const [offen, setOffen] = useState(true)
const [editingName, setEditingName] = useState(false) const [editingName, setEditingName] = useState(false)
const [editKatName, setEditKatName] = useState(kategorie) const [editKatName, setEditKatName] = useState(kategorie)
@@ -99,10 +98,10 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
onChange={e => setEditKatName(e.target.value)} onChange={e => setEditKatName(e.target.value)}
onBlur={handleSaveKatName} onBlur={handleSaveKatName}
onKeyDown={e => { if (e.key === 'Enter') handleSaveKatName(); if (e.key === 'Escape') { setEditingName(false); setEditKatName(kategorie) } }} onKeyDown={e => { if (e.key === 'Enter') handleSaveKatName(); if (e.key === 'Escape') { setEditingName(false); setEditKatName(kategorie) } }}
style={{ flex: 1, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', borderBottom: '2px solid var(--text-primary)', outline: 'none', background: 'transparent', fontFamily: 'inherit', color: 'var(--text-primary)', padding: '0 2px' }} style={{ flex: 1, fontSize: 12.5, fontWeight: 600, border: 'none', borderBottom: '2px solid var(--text-primary)', outline: 'none', background: 'transparent', fontFamily: 'inherit', color: 'var(--text-primary)', padding: '0 2px' }}
/> />
) : ( ) : (
<span style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em' }}> <span style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{kategorie} {kategorie}
</span> </span>
)} )}
@@ -118,7 +117,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
width: 22, height: 22, borderRadius: '50%', flexShrink: 0, cursor: canEdit ? 'pointer' : 'default', width: 22, height: 22, borderRadius: '50%', flexShrink: 0, cursor: canEdit ? 'pointer' : 'default',
background: `hsl(${a.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`, background: `hsl(${a.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'white', textTransform: 'uppercase', fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
border: '2px solid var(--bg-card)', transition: 'opacity 0.15s', border: '2px solid var(--bg-card)', transition: 'opacity 0.15s',
}} }}
> >
@@ -128,7 +127,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)', position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
marginTop: 6, padding: '3px 8px', borderRadius: 6, zIndex: 60, marginTop: 6, padding: '3px 8px', borderRadius: 6, zIndex: 60,
background: 'var(--text-primary)', color: 'var(--bg-primary)', background: 'var(--text-primary)', color: 'var(--bg-primary)',
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, whiteSpace: 'nowrap', fontSize: 10, fontWeight: 600, whiteSpace: 'nowrap',
pointerEvents: 'none', opacity: 0, transition: 'opacity 0.15s', pointerEvents: 'none', opacity: 0, transition: 'opacity 0.15s',
}}> }}>
{a.username} {a.username}
@@ -168,7 +167,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
display: 'flex', alignItems: 'center', gap: 8, width: '100%', display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '6px 10px', borderRadius: 8, border: 'none', cursor: 'pointer', padding: '6px 10px', borderRadius: 8, border: 'none', cursor: 'pointer',
background: isAssigned ? 'var(--bg-hover)' : 'transparent', background: isAssigned ? 'var(--bg-hover)' : 'transparent',
fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
transition: 'background 0.1s', transition: 'background 0.1s',
}} }}
onMouseEnter={e => { if (!isAssigned) e.currentTarget.style.background = 'var(--bg-tertiary)' }} onMouseEnter={e => { if (!isAssigned) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
@@ -178,7 +177,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
width: 20, height: 20, borderRadius: '50%', flexShrink: 0, width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
background: `hsl(${m.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`, background: `hsl(${m.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'white', textTransform: 'uppercase', fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
}}> }}>
{m.username[0]} {m.username[0]}
</div> </div>
@@ -188,7 +187,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
) )
})} })}
{tripMembers.length === 0 && ( {tripMembers.length === 0 && (
<div style={{ padding: '8px 10px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{t('packing.noMembers')}</div> <div style={{ padding: '8px 10px', fontSize: 11, color: 'var(--text-faint)' }}>{t('packing.noMembers')}</div>
)} )}
</div> </div>
)} )}
@@ -197,7 +196,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
</div> </div>
<span style={{ <span style={{
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, padding: '1px 8px', borderRadius: 99, fontSize: 11, fontWeight: 600, padding: '1px 8px', borderRadius: 99,
background: alleAbgehakt ? 'rgba(22,163,74,0.12)' : 'var(--bg-tertiary)', background: alleAbgehakt ? 'rgba(22,163,74,0.12)' : 'var(--bg-tertiary)',
color: alleAbgehakt ? '#16a34a' : 'var(--text-muted)', color: alleAbgehakt ? '#16a34a' : 'var(--text-muted)',
}}> }}>
@@ -232,7 +231,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
{offen && ( {offen && (
<div style={{ padding: '4px 4px 6px' }}> <div style={{ padding: '4px 4px 6px' }}>
{items.map(item => ( {items.map(item => (
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} /> <ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
))} ))}
{/* Inline add item */} {/* Inline add item */}
{canEdit && (showAddItem ? ( {canEdit && (showAddItem ? (
@@ -251,7 +250,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
if (e.key === 'Escape') { setShowAddItem(false); setNewItemName('') } if (e.key === 'Escape') { setShowAddItem(false); setNewItemName('') }
}} }}
placeholder={t('packing.addItemPlaceholder')} placeholder={t('packing.addItemPlaceholder')}
style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'var(--bg-input)' }} style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 12.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'var(--bg-input)' }}
/> />
<button onClick={() => { if (newItemName.trim()) { onAddItem(kategorie, newItemName.trim()); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) } }} <button onClick={() => { if (newItemName.trim()) { onAddItem(kategorie, newItemName.trim()); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) } }}
disabled={!newItemName.trim()} disabled={!newItemName.trim()}
@@ -265,7 +264,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
</div> </div>
) : ( ) : (
<button onClick={() => { setShowAddItem(true); setTimeout(() => addItemRef.current?.focus(), 30) }} <button onClick={() => { setShowAddItem(true); setTimeout(() => addItemRef.current?.focus(), 30) }}
style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px', margin: '2px 4px', borderRadius: 8, border: 'none', background: 'none', cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontFamily: 'inherit' }} style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px', margin: '2px 4px', borderRadius: 8, border: 'none', background: 'none', cursor: 'pointer', fontSize: 12, color: 'var(--text-faint)', fontFamily: 'inherit' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}> onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Plus size={12} /> {t('packing.addItem')} <Plus size={12} /> {t('packing.addItem')}
@@ -289,7 +288,7 @@ function MenuItem({ icon, label, onClick, danger = false }: MenuItemProps) {
<button onClick={onClick} style={{ <button onClick={onClick} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%', display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '7px 10px', background: 'none', border: 'none', cursor: 'pointer', padding: '7px 10px', background: 'none', border: 'none', cursor: 'pointer',
fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontFamily: 'inherit', borderRadius: 7, textAlign: 'left', fontSize: 12.5, fontFamily: 'inherit', borderRadius: 7, textAlign: 'left',
color: danger ? '#ef4444' : 'var(--text-secondary)', color: danger ? '#ef4444' : 'var(--text-secondary)',
}} }}
onMouseEnter={e => e.currentTarget.style.background = danger ? '#fef2f2' : 'var(--bg-tertiary)'} onMouseEnter={e => e.currentTarget.style.background = danger ? '#fef2f2' : 'var(--bg-tertiary)'}

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