Compare commits

...

181 Commits

Author SHA1 Message Date
Maurice bfc857d246 feat(oidc): use the picture claim as avatar when none is uploaded
When a user signs in via OIDC and hasn't uploaded a custom avatar, their
`picture` claim is now used as their avatar. The users.avatar column holds
either an uploaded file name or an absolute https URL from the claim, and a
single resolver on each side (server avatarUrl, client avatarSrc) renders
both. An uploaded avatar always wins and is never overwritten; the picture
refreshes on each login otherwise. Only https URLs are stored, matching the
image CSP.

All the scattered /uploads/avatars/ builders now go through the resolvers,
which also fixes collection member avatars that were rendering a bare file
name.

Discussion #1399
2026-07-02 11:22:40 +02:00
Maurice 05269f0710 feat(planner): show a day's route distances inline on mobile
Seeing the driving/walking distances between a day's places on mobile
meant tapping the day (which closes the plan sheet), reopening it, then
tapping Route. Now the per-day Route button in the mobile footer toggles
that day's leg distances in place, so the sheet stays open and you get the
distances between places without selecting the day first.

The leg computation runs for every route-toggled day instead of only the
selected one, and the leg/hotel-bookend maps are nested per day so several
toggled days can't overwrite each other's segments. Desktop is unchanged.

Discussion #1374
2026-07-02 10:39:10 +02:00
Maurice 5152a5849f feat(planner): shorten the map-open button labels to "Google Maps" / "OpenStreetMap" 2026-07-01 21:39:44 +02:00
Maurice 1fdc19712f feat(planner): add an "Open in OpenStreetMap" button to the place inspector
Next to the existing "Open in Google Maps" action, add an OpenStreetMap button that
opens the place on openstreetmap.org (a marker at its coordinates, or a name search
when it has none) — the same map source TREK already renders, and a jumping-off point
for OSM-based apps like OrganicMaps / CoMaps. Requested in discussion #880.

Strings across all 22 locales; a unit test for the URL builder.
2026-07-01 21:39:44 +02:00
Maurice a83ecc4ffb fix: resolve a batch of reported bugs (planner, budget, atlas, bookings, mobile)
- #1394 planner: two transports on one day no longer draw a phantom airport→airport
  road route between them (a run is only a drive when it holds a real place); mirrored
  in the map hook and the sidebar's leg list, with a regression test.
- #1392 planner: the per-day Route button now points the selection at the tapped day
  before toggling, so on mobile it computes that day's route instead of the previously
  selected one, and only the selected day's button reads as active.
- #1372 planner: the "open in Google Maps" route now includes the day's hotel bookends,
  matching the drawn map route.
- #1375 planner: a multi-day accommodation no longer thrashes the plan scroll — the
  auto-scroll lock keys on the selection identity, not the per-day row.
- #1377 planner: the reset-orientation compass is now shown on small screens too.
- #1382 budget: settlement nets in the trip's canonical currency and converts to the
  display currency once, so balances no longer drift with live FX and no phantom
  third-party micro-flows appear (identity, hence unchanged, when they're the same).
- #1366 atlas: countries reached only by a transport booking (no lodging/place) now
  count as visited, on both the dashboard and the Atlas page.
- #1383 bookings: a hotel linked to an accommodation shows only its day-range, not a
  duplicate stamped date row, and the range stays correct after an edit.
- #1353 bookings: any non-hotel reservation can now link an existing trip place/activity.
- #1390 i18n: fix the Polish word for "buddies" (Towarzysze → Współpodróżnicy).
- #1265 planner: drag-and-drop of places now works on touch devices via a polyfill.
2026-07-01 21:16:19 +02:00
jubnl 60cbd20327 fix: back-merge v3.1.4 hotfixes into dev (#1371)
Port the three main-only fixes onto dev's (post-rewrite) architecture:
- fix(backups): prevent recursion when the backup path sits inside the backed-up dir
- fix(share): convert budget items to the viewer's base currency instead of a flat EUR
- fix(files): surface the descriptive server error for unsupported upload types (#1363)

Cherry-picked from 819aa793 on main; the SharedTripPage and useTripPlanner
conflicts were resolved to keep dev's font-scaling and full import set while
taking the fixes' currency conversion and translateApiError wiring.
2026-07-01 20:23:31 +02:00
mauriceboe 3686792ce8 chore(i18n): backfill datepicker keys for sv + vi locales added on dev 2026-07-01 18:27:36 +02:00
Gio Cettuzzi 2af292bd64 fix(date-picker): add missing locale file and fix let-to-const lint error
- Add missing common.datepicker.* keys to overlooked locale file
- Change reassigned `let` to `const` where value is not mutated
  to satisfy lint rules
2026-07-01 18:27:36 +02:00
Gio Cettuzzi 33fcbba414 fix(date-picker): locale-aware keyboard input parsing and i18n control labels
- Replace fixed-order date parser with locale-aware implementation
  using Intl.DateTimeFormat.formatToParts to detect field order;
  adds swap fallback for unambiguous inputs (day > 12) to handle
  locale mismatches gracefully
- Pre-fill keyboard input with locale-formatted numeric date
  (e.g. 14.06.2026) instead of raw ISO value
- Replace all hardcoded English aria-labels and titles with t()
  calls; add new keys under common.datepicker.* namespace across
  all locale files
- Update FE-COMP-DATEPICKER-013 to use unambiguous day value (> 12)
  to avoid locale-dependent test failures
2026-07-01 18:27:36 +02:00
Gio Cettuzzi 3011cf6c2f feat(date-picker): add month/year drill-down navigation and keyboard input trigger
- Add three-level calendar view (days → months → years) via clickable
  header label, allowing fast navigation to distant dates without
  repeated arrow clicks
- Replace double-click text input affordance with a visible keyboard
  icon button; compact/borderless variants show the icon in the
  calendar footer
- Pre-fill text input with locale-aware numeric date (DD.MM.YYYY)
  when a value is already selected
- Add aria-label and aria-pressed to all interactive calendar elements
  for screen reader support
- Update existing tests to reflect new two-button trigger layout
- Add FE-COMP-DATEPICKER-018 through 027 covering drill-down
  view transitions, prev/next behaviour per view, aria-pressed
  state, and keyboard icon trigger
2026-07-01 18:27:36 +02:00
Maurice 7d87d87eec docs(wiki): add Collections addon page (#1081)
New wiki/Collections.md in the style of the other addon pages (lists, status,
categories, adding/bulk-adding places, place detail, filters + bulk actions,
fusion sharing with member roles, dashboard widget). Add it to the Addons
overview table + the sidebar navigation.
2026-07-01 18:02:44 +02:00
Maurice 8014264b21 test(collections): client component tests + select in All saved, off the map (#1081)
- Add client tests for the new collections UI (80 tests): collectionsModel (incl.
  the undefined-entry guards that fix the white-screen regression), StatusBadge,
  CollectionFilterBar, CollectionList, CollectionPlaceDetail (permission gating),
  MoveToListModal.
- Offer the select toggle in "All saved" too (server enforces per-place rights).
- Drop the now-duplicate select button from the map controls (it lives in the
  filter row).
2026-07-01 18:02:44 +02:00
Maurice baf156a36b style(collections): widen the share modal (#1081) 2026-07-01 18:02:44 +02:00
Maurice 59134ae38e style(collections): custom dropdown for the permission role pickers (#1081)
Swap the two native <select> role pickers in the share modal (invite + per-member)
for the app's CustomSelect (portal dropdown, size sm) so they match the rest of
the UI instead of the browser's native control.
2026-07-01 18:02:44 +02:00
Maurice 6cdcc82d9c feat(collections): bulk-add selected trip places to a list (#1081)
Add a "Save to collection" action to the trip place list's selection bar (next to
bulk category + delete): it opens a list picker and copies every selected place
into the chosen list in one request, instead of one-by-one from each place.

- Server: saveFromTripPlaces (one access check + one WS notify), POST
  places/from-trip-many; dedups by name/coords, skips missing ids, honours force.
- Client: saveFromTripMany api + SaveTripPlacesToListModal; the selection-bar
  button is gated on the collections addon being enabled.
- Copy count / skipped-duplicates toast; strings in all 22 locales.
- Service + controller tests for the bulk path.
2026-07-01 18:02:44 +02:00
Maurice 1e0feb93dd feat(collections): per-member permission roles on shared lists (#1081)
The owner now assigns each member a role — viewer (read + copy-to-trip only),
editor (default: add + edit places) or admin (full incl. delete). The owner is
always full. Existing members default to editor via migration 152, so nothing
regresses.

- Server: role column on collection_members (migration 152 + schema); roleOf +
  assertCanEdit (save/update/status/list-meta) + assertCanDelete (delete) layered
  on assertAccess; sendInvite takes a role; new setMemberRole (owner-only) +
  POST members/role; members payload carries each role.
- Client: Share modal gains a role picker on invite and a per-member role select
  for the owner (read-only role badge for others); the page hides add / edit /
  status / move / delete for roles that can't perform them (server still enforces).
- Roles in all 22 locales; service + controller tests for the new gating.
2026-07-01 18:02:44 +02:00
Maurice f2972481fe fix(collections): mobile polish — touch targets, safe-areas, overflow (#1081)
From a mobile UX audit of the collections page:
- Detail sheet: the read-mode footer no longer clips "Remove from list" (it wraps,
  drops the growing spacer) and clears the home indicator (safe-area padding,
  84dvh instead of 84vh).
- Bigger touch targets on phones (≥40px): view toggle, filter dropdowns, select-bar
  buttons, detail close/actions, drawer rail rows, and the interactive status badge
  (enlarged tap area via a pseudo-element, look unchanged).
- Select action bar breaks its bulk actions onto their own line instead of
  stranding them behind a growing spacer.
- Lists drawer honours device safe-areas and gets an explicit close button.
- Page honours the top safe-area and goes full-width on phones (drop the 95vw cap);
  filter popovers cap their width so long category names don't overflow.
- Add-place: Cancel/Add pinned in the modal footer (reachable without scrolling),
  status pills wrap.
- Drop dead hero mobile CSS left over from the hero refactor.
2026-07-01 18:02:44 +02:00
Maurice b9da2494d4 test(collections): unit-test the nest controller (branch coverage) (#1081)
The collections nest module had no controller test, dragging src/nest/** branch
coverage below the 80% gate. Cover the controller's branches: reorder/deleteMany
payload validation, owner-gated invite/cancel/remove/available-users, invite +
accept error surfacing, the cover demo-mode + no-file guards, and the x-socket-id
forwarding on the mutating endpoints.
2026-07-01 18:02:44 +02:00
Maurice fd5258129a fix(collections): saved-places picker height + list filter, all-saved first-load (#1081)
- Trip "Saved places" picker: drop the fixed 360px cap so the list fills the
  panel instead of stopping half-way, and add list + status filter dropdowns
  (filter by which collection the place is saved in).
- "All saved" showed nothing on first open: setActive(ALL_SAVED) unioned the
  lists from the store, but on first load those aren't fetched yet (loadAll still
  running). Load them first when empty so the union isn't blank.
2026-07-01 18:02:44 +02:00
Maurice 56fee19d64 style(dashboard): accent follows the user's theme instead of a fixed orange (#1081)
The .trek-dash scope (dashboard, collections, vacay, atlas) hardcoded an orange
accent, ignoring the appearance theme. Drop the override so --accent inherits the
theme tokens (index.css): monochrome black/white by default, coloured per
data-scheme / custom accent. --accent-ink/-soft now map onto --accent-on/-subtle,
and accent-filled elements use --accent-text for legible text on any scheme.
Category colours are set explicitly per element and stay untouched.
2026-07-01 18:02:44 +02:00
Maurice 6be8a67c82 feat(collections): select toolbar — select-all, move/duplicate to another list (#1081)
- The select toggle now sits at the right of the filter row (same height as the
  status/category dropdowns) instead of the top toolbar.
- Select mode gains a "select all / deselect all" toggle and shows even with
  nothing selected yet.
- Selected places can be moved or duplicated into another of your lists via a
  target-list picker (move re-points collection_id; duplicate re-saves the place
  data, carrying description / category / notes / etc.).
- New strings across all 22 locales.
2026-07-01 18:02:44 +02:00
Maurice 3198fd35eb fix(collections): white screen when editing a place (undefined in places) (#1081)
updatePlace wrote `res.place` into the places list, but the endpoint returns the
updated place directly (not wrapped in { place }, unlike savePlace) — so an
`undefined` slipped into the list and the category-filter's presentCategories()
crashed on `undefined.category_id`, blanking the whole page. The WS echo used to
mask it by refetching; excluding the editor's own socket exposed it.

- Read the updated place directly and guard against a falsy response.
- Fix the api return types to match (updatePlace/setStatus return the place).
- Harden filterPlaces / statusCounts / presentCategories / mappablePlaces against
  a stray undefined entry so a single bad row can never white-screen the page.
2026-07-01 18:02:44 +02:00
Maurice ef3f043e00 fix(collections): stop the address pin from shrinking on long addresses (#1081)
The little map pin in front of a place's address sits in a flex row with the
address text but had no flex-shrink guard, so a long address squeezed the icon
smaller. Pin the SVG to its size.
2026-07-01 18:02:44 +02:00
Maurice 6a3e6ae083 fix(collections): copy-to-trip labels + Unsplash cover search (#1081)
- Copy-to-trip modal showed blank rows: trips are keyed by `title`, not `name`,
  so nothing rendered. Read `title` and add the trip's date range under it.
- List editor gains an Unsplash cover search (same source as trip creation) next
  to the upload button; picking a photo sets it as the list cover.
- Add-place result rows: pin keeps a hard min width so a long address can't
  squeeze it.
2026-07-01 18:02:44 +02:00
Maurice b8ceb081fa feat(collections): hero edit/share row, place photos, edit-echo fix, wider page (#1081)
- Editing a place (category, status, …) no longer reloads the view: the mutating
  client's own socket is now excluded from the WS broadcast (x-socket-id threaded
  through save/update/status/delete + list update/cover), so the optimistic update
  stands on its own instead of being chased by an echoed refetch.
- Detail sheet pulls a higher-res cover photo from the maps provider when the
  place has no image of its own (the avatar thumbnail was too low-res).
- Hero: Share moved onto the title row (no more empty top band) with an Edit
  button beside it; editing/deleting a list now happens there. The list rail drops
  its per-row kebab entirely (and with it the janky open animation).
- The list editor can delete the collection from its footer (owner only).
- Wider, screen-relative page (max-width min(2100px, 95vw)).
- List rows: the place avatar no longer shrinks when the address is long.
2026-07-01 18:02:44 +02:00
Maurice 0c484959ce feat(collections): filters, add-place popup, category badges, map-click hardening (#1081)
- Map: markers no longer rebuild on every unrelated re-render (memoised the
  mappable list + only update the hero size when it really changes), the floating
  controls bar is click-through except its buttons, and the collection map runs
  with the hover tooltip off. Together these stop a marker click from landing on
  a mid-rebuild element / the tooltip so the pick actually registers.
- Filters moved out of the hero into a compact status + category dropdown row
  above the places (custom dropdowns); the hero no longer carries the stat chips.
- Add-place is a single popup now: search fills the location, and name / status /
  category / description / links are all editable together before saving.
- Category shown as a badge top-left on the detail cover and next to the status
  in each list row (divided by a hairline).
- Slimmer hero: shorter, tighter spacing, links tucked into the eyebrow row
  instead of their own line.
2026-07-01 18:02:44 +02:00
Maurice f97e284592 feat(collections): detail redesign + categories, close-on-map, highlight fix (#1081)
- Rebuild the place detail as a clean, opaque, sectioned sheet (cover → meta →
  status segment → description → links) with a proper footer action bar — the
  loose "white lower half" is gone.
- Assign a place to a central (admin-defined) category, both in the detail edit
  and when adding a place; categories are fetched once for the page.
- Add-place now sets category + description (markdown) + links + status in the
  same step, closer to the trip's place form.
- Switching to the full-map view now closes the (list-docked) detail.
- Fix the selected-row highlight: it was clipped by the column's overflow — use
  an inset ring and only clip during the map-collapse animation; a picked row
  now scrolls into view above the detail sheet.
- New category strings across all 22 locales.
2026-07-01 18:02:44 +02:00
Maurice 0910c90e7f fix(collections): detail-sheet, edit-refresh, map + rail polish (#1081)
- Editing a place (status, description, title, …) no longer reloads the view or
  closes the detail: the WS echo now refreshes via loadCollection, which keeps
  the current selection + select-mode instead of setActive resetting them.
- Place detail: docks over the list column on the desktop split (measured rect)
  instead of centred over the map, and the card is now opaque (was too see-through).
- Map: click a marker in full-map view to drop back to the split; picking a place
  scrolls its list row into view; the select toggle is disabled in full-map view;
  the floating controls are one non-overlapping top bar and less transparent.
- Hero: drop the New-list button (it's already in the rail).
- Rail: the kebab is always visible (easy to hit); menu is Edit + Delete only
  (colour moved into the editor); "Rename" → "Edit".
- Add-place: pick a result, then set description (markdown) / links / status
  before saving, all in one step.
- Share modal: member roster as cards with clearer role badges + a count.
2026-07-01 18:02:44 +02:00
Maurice de4077d461 fix(collections): review follow-ups on the B–E work (#1081)
- Block the list-cover upload in demo mode (mirror the trips cover endpoint).
- Restrict list/place links to http(s) (schema) and normalise scheme-less URLs
  to https:// on save, so a bare "booking.com" no longer resolves as a relative
  SPA route (and javascript:/data: hrefs are rejected).
- Place detail: surface save errors with a toast instead of silently swallowing
  a 400 and leaving the sheet stuck in edit mode.
- List editor: don't create a duplicate list when a retry follows a cover-upload
  failure (reuse the created id); revoke the cover preview object URL.
- Map controls: one top bar (left toggle/select, right add/search) so they can't
  overlap on a narrow split map — the search shrinks instead.
- Dashboard list badge: full-opacity colour wash so the name stays legible over
  bright covers.
2026-07-01 18:02:44 +02:00
Maurice 68e101ed37 feat(collections): list details, place detail sheet, add-place, fusion kick (#1081)
Dashboard widget (B): the collections tool now shows the user's LISTS as
compact colour-washed badges (cover image tinted with the list colour, or a
gradient) that jump to the list — one list() call, no N+1.

List details (C): lists gain a description, a custom cover image (uploaded to
/uploads/covers, tinted with the list colour in the hero) and links. A shared
ListEditorModal handles both create and edit; the hero shows the description +
link chips. New `links` JSON column on collections + collection_places
(migration 151) with parse/serialize in the service; a POST :id/cover upload
endpoint mirroring trips; cover-file cleanup path-confined locally.

Place detail (D): clicking a place opens a bottom sheet (no backdrop, so the
map stays visible) — status cycle, copy-to-trip, remove, and an edit mode with
a markdown description + links editor (collectionsApi.updatePlace, now wired
via a store action). A "+" next to the search adds a place to the list via the
maps search.

Fusion + fixes (E): the owner can now remove an accepted member (kick) — new
removeMember service/route/store + a button in ShareCollectionModal, with a
collections:removed WS bounce. findMembership no longer matches on name alone
(coordinate proximity required, killing "Starbucks everywhere" false positives).
loadCollection swallows a 403/404 after a leave/remove so the URL sync can't
throw uncaught. Grid remnants gone; the map select toggle moved onto the map.

New strings across all 22 locales; i18n parity strict passes.
2026-07-01 18:02:44 +02:00
Maurice 25b8d28574 feat(collections): list+map default, map-only toggle, deselect + tooltip fixes (#1081)
- Drop the grid/tile view. The list view is now the default and, on wide
  screens, a list + persistent map split; a top-left control on the map
  collapses the list to a full-width map (and back), animating smoothly (the
  map stays mounted and is nudged to re-layout during the transition). The
  place search moves onto the map (top-right); mobile keeps a list/map toggle.
- Let a place be deselected again: clicking it once more, clicking the map
  background, or picking another all toggle the selection (collections map now
  wires onMapClick).
- Fix the stuck hover tooltip: selecting a place swaps its marker's DOM node so
  the browser never fires mouseout/mouseleave, orphaning the fixed-position
  tooltip (it hung on screen and drifted with scroll). Both map stacks now clear
  the hover on selection change and on scroll.
- Remove CollectionGrid + PlaceCover; add hero eyebrow + map control strings.
2026-07-01 18:02:44 +02:00
Maurice 74cace7c8f feat(collections): list+map split, taller rail, list-menu popover fix (#1081)
- List view splits into a scrollable list + a sticky map on wide screens;
  clicking a place pans/highlights it on the map (single selectedPlaceId, no
  inspector over the map). Narrow screens keep the single-column list.
- Keep the list rail at least as tall as the hero (measure the hero via a
  small useElementSize hook and feed its height as the rail's min-height).
- List row kebab menu: portal the menu/colour popover to the body so the
  rail's overflow + backdrop-filter can't clip it ("renders only in the
  module"), and fade the place count on hover so the kebab stops overlapping it.
2026-07-01 18:02:44 +02:00
Maurice 4e478722c3 feat(collections): redesign the page on the dashboard glass language (#1081)
Rebuilds the /collections page from the functional placeholder into the
dashboard's glass visual language (light + dark):

- A colour-washed hero per list: eyebrow + member avatars, big title, and
  stat chips (All / Idea / Want / Visited) that double as the status filter.
- A sticky glass list rail (owned + shared + invites) with a mobile drawer.
- Gradient/photo cover place cards modelled on the trip cards, via a new
  rectangular PlaceCover (photoService-backed, gradient fallback) + a shared
  gradients util. List and map views restyled to match.
- Status pill rendered as a role=button span so it survives the .trek-dash
  button reset and can nest inside the card; the share member-count badge is
  now owner-only.
- New hero eyebrow strings across all locales.
2026-07-01 18:02:44 +02:00
Maurice 5f9da5b72f feat(collections): fusion sharing UI + dashboard widget + per-user toggle (#1081)
- ShareCollectionModal: the owner manages a list's members and invites users
  (available-users picker → invite, cancel pending); a member can leave a shared
  list. The incoming accept/decline surface stays in the lists rail. A Share
  button is added to the collections header for owners (and members, to reach
  Leave).
- CollectionsWidget: a dashboard glass card after the currency widget showing the
  saved count and the most recent saved places, double-gated by the admin addon
  and a new per-user appearance flag.
- Appearance: a 'collections' dashboard-widget flag (desktop + mobile defaults)
  wired into the appearance settings, surviving normalize.
- Sharing + settings strings across all 22 locales (parity strict passes).
2026-07-01 18:02:44 +02:00
Maurice 783332cc0e feat(collections): /collections page, entry points and i18n (#1081)
Adds the client side of the Collections addon:
- A distinct /collections page (Atlas pattern, page/hook split) gated behind the
  addon: a multi-list rail, a Grid (default) / List / Map view switch, the
  idea/want/visited status with a one-tap badge, search and status filters, and
  considered empty states. Store + hook + model + websocket wiring; the place
  detail reuses the trip place inspector via a mode guard.
- Entry points: a "Save to Collection" button next to Open-in-Google-Maps in the
  place inspector (and the two sidebar context menus), a "Copy to trip" modal,
  and a desktop-only two-column add-place picker (mobile keeps the single-column
  form).
- The collection namespace and the new keys across all 22 locales.
2026-07-01 18:02:44 +02:00
Maurice ec8509f2de feat(collections): backend for the Overall Places addon (#1081)
Adds the Collections addon backend: a server-wide-per-user library of saved
places, independent of any trip, with multiple named lists, an idea/want/visited
status, and Vacay-style fusion invitations to share a list with other users.

- Data: collection / collection_members / collection_places / collection_place_tags
  tables (+ migration and baseline schema). Saved places carry the owner plus a
  nullable saved_by so a member deleting their account can't drop shared content.
- Service: list + place CRUD with owner-or-accepted-member visibility, dedup,
  status, save-from-trip and copy-to-trip (reusing the trip copy column list),
  and the full fusion-invitation state machine mirrored from vacay (send / accept
  / decline / cancel / leave) with a websocket broadcast and an invite
  notification. Deleting a list snapshots its members and notifies them.
- NestJS module + addon guard (404 before auth), registered in the app module.
- Widens the place photo cache reference check to count collection places so the
  nightly sweep no longer evicts photos a saved place still uses.
- collection_invite notification wired across all 22 locales.
2026-07-01 18:02:44 +02:00
Maurice 37ef3a9d18 fix(map): match the GL place hover tooltip to the Leaflet map (#1385)
The MapLibre/Mapbox hover showed an anchored popup with a large photo
thumbnail, completely unlike the Leaflet map's slim, cursor-following
name/category/address card. Drop the anchored photo popup for places and
render the same cursor-following overlay the Leaflet map uses (no photo,
matching fonts/padding/shadow), so the two maps hover identically.
2026-06-30 19:34:07 +02:00
Maurice a54503f26d feat(map): group GL place markers into clusters on zoom-out (#1385)
MapLibre/Mapbox showed every place as its own rich HTML marker with no
grouping when zoomed out, unlike the Leaflet map. Feed the place points
through a clustered GeoJSON source: clustered points render as a dark
count bubble (click to zoom in and expand) while the rich HTML photo
markers are only drawn for the points the source reports as unclustered.
Always on, matching the Leaflet MarkerClusterGroup.
2026-06-30 19:12:43 +02:00
Maurice 4abb38b517 style(packing): small gap between the list and the luggage sidebar divider
The luggage sidebar's left border sat flush against the right-hand category
card. Add a little left margin so the divider has minimal breathing room.
2026-06-30 19:12:43 +02:00
Maurice 743b724cbc feat(packing): three-tier sharing — personal, shared-with-people, common pool (#858)
Rework the private-packing flag into a full sharing model. Every item is now
Common (the group pool — where all existing items live, so nothing breaks),
Personal (private to its owner) or Shared with specific people (it shows up on
those travelers' own lists, marked "by <bringer>"). is_private discriminates
restricted from common; a new packing_item_recipients table holds who a shared
item covers, and packing_item_contributors records "I can bring that too"
pledges on Common items.

The panel gains a Gemeinsam / Meine Liste view switch, each item a sharing
control (owner sets the tier + the people it covers), and Common items can be
co-brought or cloned onto your personal list. Visibility is enforced server-side
in listItems and the WebSocket broadcasts are scoped to exactly who can see an
item across every tier transition. All 22 locales stay in parity.
2026-06-30 19:12:43 +02:00
Maurice 04f2ec72c6 feat(trips): guest members for accountless participants (#1362, #1291)
Add "guest" trip participants — people without a Trek account who can still be
assigned to costs, packing, to-dos and day-plan activities. A guest is a
credential-less users row (is_guest=1) joined into trip_members, so it is
assignable everywhere a real member is, with the cost-splitting, settlement,
packing and assignment paths working unchanged.

Guests are firewalled from everything account-related: they can never sign in
(password, OIDC and reset lookups skip them), never appear in the global user
directory, the member-add picker or admin user management, are never resolved as
notification recipients, can't be invited to another trip, and can't be made
owner. The trip owner manages guests from the share dialog in a dedicated,
clearly-labelled section (add / rename / remove), and guests carry a "Guest"
badge wherever members are picked. All 22 locales stay in parity.
2026-06-30 15:03:57 +02:00
Maurice 7673aa52f2 fix(packing): drop the always-true guard in the row drag handler (#969)
The onDragOver guard `drag.isDragging || true` is a constant condition (eslint
no-constant-condition). The handler is already gated by canDrag, so run the
drag-over logic directly, matching the to-do row.
2026-06-30 15:03:57 +02:00
Maurice e56a901d82 i18n: translate the booking link field across all locales (#935)
Fan out reservations.urlLabel / reservations.urlPlaceholder to the remaining
locales so the dedicated booking URL field is localised everywhere.
2026-06-30 15:03:57 +02:00
Maurice 641711322e feat(trips): transfer trip ownership to a member (#973)
Add POST /api/trips/:id/transfer so the owner can hand a trip to one of its
existing members. The swap runs in a transaction: the new owner takes
trips.user_id and the former owner is kept on as a regular member, so nobody
loses access. The endpoint is owner-only, writes a trip.transfer_ownership
audit entry and broadcasts the refreshed trip. The members modal gains a
"Make owner" action, shown only to the current owner.
2026-06-30 15:03:57 +02:00
Maurice fac2393388 feat(lists): reorder packing/to-do lists and private packing items (#969, #858)
Add drag-to-reorder to the packing and to-do lists, mirroring the budget
panel's native HTML5 drag pattern. A drag within a filtered/grouped view is
mapped back onto the global order so untouched items keep their place, and the
order persists optimistically via the existing reorder endpoints.

Packing items can now be marked private (#858): a private item is visible only
to its owner. createItem/bulkImport stamp the owner, listItems filters by the
viewer, and the WebSocket broadcasts are scoped to the owner so a private item
never reaches another member's screen — including the public/private toggle
transitions. Owners get a lock toggle and a private indicator on their items.
2026-06-30 15:03:57 +02:00
Maurice 7eac5a5a02 feat(files): render uploaded Markdown files inline (#1345)
Markdown (.md/.markdown) is now an allowed upload type and opens in a rendered preview in the file manager instead of just downloading. Reuses the existing react-markdown stack with rehype-sanitize (these are untrusted uploads, so output is sanitized) and detects markdown by extension first since browsers send unreliable MIME for .md.
2026-06-30 15:03:57 +02:00
Maurice d6ed7a60a0 feat(bookings): add a dedicated URL field to reservations (#935)
Bookings get a first-class url column (migration) instead of users pasting links into notes. It's editable in the booking modal and rendered as a clickable link on the reservation card. The reservation request schemas are open passthroughs, so only the entity schema + service SQL enumerate it.
2026-06-30 15:03:57 +02:00
Maurice ad64df42ed test(video): cover the new upload-handler branches
Add controller tests for the gallery-video route (success / no-video / not-allowed / cleanup-on-reject), the per-asset media_types loops (gallery + entry, batch + single), and the file-manager per-type cap + unlink-on-rejection — restoring branch coverage on src/nest above the 80% gate.
2026-06-30 12:27:28 +02:00
Maurice 4af35b162e test(video): update gallery accept selector + complete fileService mocks
The gallery upload input now accepts image/*,video/* — update the two JourneyDetailPage selectors that matched the old value. The files/journey e2e suites mock fileService and were missing the new MAX_VIDEO_SIZE / isVideoExtension / isVideoMime exports, which broke module load.
2026-06-30 12:27:28 +02:00
Maurice 20c1858b23 fix(video): harden upload handling and fix video playback edge cases
Security: the gallery-video poster is now always stored as .jpg instead of the client-supplied extension, so a poster declared image/* but named x.html / x.js can't be written with that extension and served inline same-origin; local gallery files are also served with X-Content-Type-Options: nosniff.

Robustness: rejected/unauthorised uploads no longer orphan their bytes on disk (the gallery-video and file-manager handlers unlink before throwing); the file-manager per-type size cap is keyed on the extension like the filter, so a real video labelled application/octet-stream isn't wrongly rejected. UX: the file-manager thumbnail strip shows a play placeholder for video instead of a broken image; shared (public) journeys now return media_type and play videos with a play badge; and a poster-less video shows a neutral tile instead of a broken thumbnail.
2026-06-30 12:27:28 +02:00
Maurice e986c9ab27 feat(video): upload and play videos in the trip file manager
The file manager (which already attaches files to a place/activity) now accepts video uploads up to the larger video cap — other types stay at the document limit — and the lightbox plays them with the Plyr player over the plain same-origin download URL, so cookie auth and HTTP Range both work. Videos are excluded from the offline blob prefetch so one clip can't evict a trip's documents.
2026-06-30 12:27:28 +02:00
Maurice 61ffdb553e test(photos): assert the forwarded Range arg on the original stream
Follow-up to the Range-aware photo proxy.
2026-06-30 12:27:28 +02:00
Maurice 1abc9b2bc7 feat(video): link and stream Immich videos in the journey gallery
Immich timeline and album listings no longer filter out videos; each asset now carries its media type, which the provider picker forwards when linking. A linked video streams through Immich's transcoded /video/playback endpoint, and the asset proxy forwards the viewer's Range header (and passes 206/Content-Range back) so the player can seek. Synology video stays excluded until its stream API is verified.

Adds media_type/media_types to the provider-photos request contract.
2026-06-30 12:27:28 +02:00
Maurice 8713443665 feat(video): use Plyr for the gallery video player
Swaps the bare <video> element for a Plyr-wrapped player so playback controls match a consistent, cleaner skin. The instance is created per source and destroyed on unmount, so the lightbox stops playback when you navigate away.
2026-06-30 12:27:28 +02:00
Maurice c92c02e1b8 feat(video): play local gallery videos in the journey gallery
Picking a video in the journey gallery now captures a poster frame + duration in the browser and uploads the raw clip; the grid shows the poster with a play badge and the lightbox plays it with a native video player (HTTP Range seeking). Images keep their existing HEIC-normalised path. No server-side transcoding.

Server media_type work was committed separately.
2026-06-30 12:27:28 +02:00
Maurice 993d9bf713 feat(video): media_type discriminator + local gallery video upload (server)
trek_photos gains a media_type column (migration) so the registry can hold video as well as images. A new POST :id/gallery/video endpoint accepts a video plus a client-captured poster (500 MB cap, video MIME/extension allowlist), stores the poster as the thumbnail, and the photo stream serves the poster for the thumbnail kind and the raw file (HTTP Range) for the original — without running the image thumbnailer on video bytes.
2026-06-30 12:27:28 +02:00
Maurice c7e4b2781b docs(wiki): document force-offline, selective storage and conflicts 2026-06-30 10:04:15 +02:00
Maurice a88cd772cf i18n(offline): offline settings strings across all locales 2026-06-30 10:04:15 +02:00
Maurice 98d11d4267 feat(offline): Settings -> Offline controls and a status banner
The Offline tab gains a force-offline switch, a prepare-for-offline download with progress, per-trip and map-tile storage toggles, and a conflict resolver with a default strategy. The floating status pill now reflects forced-offline and unresolved conflicts.
2026-06-30 10:04:15 +02:00
Maurice 6707dac4a9 feat(offline): force-offline mode, selective sync and a conflict queue
A force-offline override routes every read to the cache and every write to the queue; preparing for offline downloads trip data, documents and map tiles up front and waits for them to finish. Map tiles and individual trips can be left out of the cache. Queued edits carry the version they were based on so the queue can surface server conflicts for a keep-mine / keep-theirs decision; chained offline edits to one entity no longer conflict with each other, and evicting a trip preserves its unsynced writes.
2026-06-30 10:04:15 +02:00
Maurice c552472b63 feat(offline): detect update conflicts on the server for places and packing
Update handlers accept an optional X-Base-Updated-At token and reject a stale overwrite with 409, returning the current server row. An absent token keeps the existing last-write-wins behaviour, so older clients are unaffected. packing_items gains an updated_at column (migration + stamped on every insert) so it can take part in conflict detection too.
2026-06-30 10:04:15 +02:00
Maurice 5fd66f4833 feat(map): include the day's route in the map fit (#1128)
Selecting a day already fits the map to that day's destinations; this also
folds the route polyline into the bounds. BoundsController fits the
destinations immediately, then re-fits once — when the day's route finishes
computing asynchronously — to destinations + the full route, so a route that
bulges past its stops (a detour or ferry) stays in view. One-shot per day
selection, so later route-profile toggles don't re-zoom.
2026-06-30 00:04:38 +02:00
Maurice 50609b078a feat(places): bulk "change category" from the selection toolbar
Closes the UI half of #1168: in the Places selection mode, a new tag button
before delete opens a category picker that applies one category (or "No
category") to every selected place in a single request. Adds a REST
/places/bulk-update endpoint reusing updatePlacesMany, an offline-aware repo +
store action that patches both the place pool and the day-assignment
projections, undo grouped by each place's prior category, and the i18n keys
across all locales.
2026-06-29 23:19:33 +02:00
Maurice 42b45dcd82 feat(dashboard): show the year on trip dates from other years
Trip dates only showed month + day, so trips from other years were ambiguous
(#1323). Dashboard cards and the boarding-pass hero now include the year, and
so does the shared formatDate (planner day headers etc.) — but only when it
isn't the current year, so this year's trips stay compact. Order and
punctuation follow the locale (EN "Sep 10, 2026", DE "10. Sep 2026").
2026-06-29 22:29:57 +02:00
Maurice 9dd9057b7b feat(mcp): add bulk_update_places tool
Apply the same field values to many places in one call instead of one
update_place per place — e.g. re-categorising 80 POIs at once. Adds the
updatePlacesMany service (one transaction, trip-scoped, partial patch
built on updatePlace) and the bulk_update_places MCP tool with the usual
demo/access/place_edit guards and a place:updated broadcast per place.
2026-06-29 22:29:57 +02:00
Maurice 23987c76bb harden calendar feeds: absolute URLs, real disable, folding, schema sync
- Resolve feed URLs against the request host when APP_URL is unset, so the
  webcal:// / Add-to-Google links work on a default install (not just behind a
  configured reverse proxy).
- Give the public link a real off switch: POST enables, PUT rotates, DELETE
  clears the token (feed_token = NULL). The subscribe dialog no longer mints a
  token just from being opened — the user opts in explicitly.
- Fold ICS content lines at 75 octets (UTF-8 safe) in exportICS, so download
  and feed both stay RFC 5545-compliant for long/non-ASCII summaries.
- Extract VEVENTs by structural line scan instead of a lazy END:VEVENT regex
  that user text could truncate.
- URL-encode the Google Calendar cid; mirror feed_token into schema.ts.
- Collapse the duplicated all-trips modal into the shared IcsSubscribeModal.
2026-06-29 21:53:06 +02:00
michael-bohr 7173e82fe8 feat(feeds): subscribable ICS calendar feeds for trips
Adds TripIt-style live calendar subscriptions alongside the existing one-time
.ics download. A trip (or all of a user's trips) exposes a secret, revocable
feed URL that Google/Apple/Outlook poll to stay in sync.

- Public read endpoints GET /api/feed/trip/:token.ics and /api/feed/user/:token.ics
  (no auth — the secret token is the credential), reusing the existing exportICS()
  generator and adding REFRESH-INTERVAL / X-PUBLISHED-TTL hints.
- JWT-guarded token endpoints to generate (lazy, idempotent) and regenerate/revoke
  per-trip and per-user feed tokens; tokens stored in nullable feed_token columns.
- All-trips feed excludes archived trips and trips ended >90 days ago.
- UI: ICS toolbar button becomes a Download/Subscribe menu; modal offers one-click
  "Add to Google Calendar" (render?cid=webcal://) and a webcal:// link for
  Apple/Outlook, plus copy-link fallbacks. All-trips feed reachable from dashboard.
- Feed base URL read from the existing APP_URL env var.

Purely additive: new endpoints + two nullable columns, no breaking changes.

Tests: server/tests/e2e/feeds.e2e.test.ts covers lazy token generate + idempotency,
regenerate-invalidates-old, 401/404 auth+access, public feed content-type + hint
injection, unknown-token 404, and the archived/>90-day all-trips exclusion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:53:06 +02:00
Maurice 72dfa2c60c docs(helm): clean up existingClaim notes
Strip stray zero-width characters from the persistence docs, move the PVC
note out of the ENCRYPTION_KEY usage block into its own Persistence section
in NOTES.txt, and document that persistence.enabled=false falls back to an
ephemeral emptyDir.
2026-06-29 21:00:25 +02:00
yael-tramier d19305bda4 fix(helm): emptyDir is used as a fallback when persistence is disabled. 2026-06-29 21:00:25 +02:00
yael-tramier 7aa2f6e4f2 feat(helm): Add existingClaim variable for custom PVC usage. 2026-06-29 21:00:25 +02:00
Maurice 3e64cb86a6 chore(i18n): sync Vietnamese with latest dev keys
Add the keys dev gained since this PR opened so the new vi locale keeps full
parity: the help namespace (wiki help center), settings appearance options,
costs split modes, dashboard Unsplash cover search, the insecure-cookie login
hint, nav.help and the admin group labels.
2026-06-29 20:48:51 +02:00
leeduc e4efcf0840 feat(i18n): add Vietnamese translations 2026-06-29 20:48:51 +02:00
Zorth Thorch e34f40b686 feat(costs): Splitwise-like cost splitting
Add per-payer and per-member custom split amounts with Equally, Custom and
Ticket split modes on top of the existing equal split, keep legacy "paid by"
expenses working, and document the modes in the Budget Tracking wiki page.
2026-06-29 20:34:24 +02:00
Maurice 3701ab6cad feat(auth): explain the plain-HTTP secure-cookie gotcha on login
When the server issues a Secure session cookie but the request arrived over
plain HTTP (the common LAN install over http://ip:3000), the browser drops
the cookie and the next request dead-ends on a bare "Access token required" —
the top source of avoidable install issues. The login response now flags this
exact case and the login page shows a localized box explaining the fix (use
HTTPS, or set COOKIE_SECURE=false) with a link to the Troubleshooting guide.
It only triggers in the real failure case, never for correct HTTPS setups.
2026-06-29 18:32:58 +02:00
Maurice e91f592f22 feat(help): embed the TREK wiki as an in-app help centre
Add a Help section (profile menu, /help) that renders the GitHub wiki inside
TREK. /api/help fetches the wiki markdown — the nav from _Sidebar.md, pages,
and proxied images — from GitHub and caches it (1h TTL, serves stale on
outage), so it auto-syncs on wiki edits with no redeploy and the client never
calls GitHub directly. The page is styled to match TREK with a section
sidebar, search and react-markdown; wiki [[links]] are rewritten to in-app
routes and HTML-comment placeholders are stripped. Page state lives in a
useHelp() hook per the page pattern. Adds nav.help and a help namespace
across all locales.
2026-06-29 18:32:58 +02:00
Maurice 1cc69fc22a refactor(admin): group the admin sidebar tabs into sections
The admin sidebar had 11 flat tabs. PageSidebar now supports optional group headings (backward-compatible; the Settings sidebar stays flat), and the admin tabs are grouped into Users, Configuration, Integrations and Maintenance. Group labels added across all locales.
2026-06-29 13:59:00 +02:00
Maurice 4d131db9af refactor(settings): rename the Display tab to General and group its settings
The Display tab became a catch-all once theming moved to its own Appearance tab, and its 'Display' label no longer fit. It is now 'General' (Allgemein) and split into 'Language & region' and 'Travel & map' sections. Tab labels and the new section titles are added across all locales.
2026-06-29 13:59:00 +02:00
Maurice f5d03e7213 chore(about): remove the monthly supporters section 2026-06-29 13:59:00 +02:00
Maurice 891171ce6c feat(appearance): mark the Readability section as experimental
Transparency-off, density and per-size typography are best-effort while the token migration is ongoing, so the section carries an Experimental badge. Adds the i18n key across all locales.
2026-06-29 13:59:00 +02:00
Maurice 720edce2ee fix(appearance): make the dashboard hero boarding-pass solid with transparency off 2026-06-29 13:59:00 +02:00
Maurice b27793f99a fix(appearance): shorten the Auto color-mode label to 'Auto' on mobile 2026-06-29 13:59:00 +02:00
Maurice 813db0ca6e feat(appearance): show per-size text controls inline with examples
The four size-class sliders (Large/Medium/Normal/Small) are now always visible instead of behind a disclosure, each with a live sample rendered at that size and an example of what it affects (e.g. Normal = place names/descriptions, Small = addresses/labels).
2026-06-29 13:59:00 +02:00
Maurice 741639edf0 feat(appearance): granular per-size text scaling with live preview
The text-size control now adjusts each size class (Large / Medium / Normal / Small) independently as well as all-at-once. Inline px sizes are mapped to a class by their value, so the per-class sliders reach real content; each class variable = global factor x its per-class factor (no double-scaling with the root font-size that handles rem text). The settings UI gains a live preview that resizes as you drag, and the four size sliders sit behind a clear toggle.
2026-06-29 13:59:00 +02:00
Maurice bb8f4d4e5e fix(appearance): keep i18n key parity and update the scaled-emoji test
Add the new appearance settings keys (widget group titles, sidebar/density hints) to every locale so the strict key-parity check passes, and update the single-emoji chat test to expect the now-scalable calc() font size.
2026-06-29 13:59:00 +02:00
Maurice fac043c691 fix(appearance): clearer widget settings, density hint, solid surfaces with transparency off
Dashboard widget settings are grouped by where they sit on the dashboard (below the hero / right sidebar / bottom of page); the right-sidebar master toggle now nests its individual widgets and greys them out when the sidebar is off, instead of a confusing flat list mixing the master with its children. Density gains an explanatory hint plus a real compact spacing effect. Transparency-off also solidifies the Atlas glass panels and tooltip, Leaflet zoom controls and GL popups — class-based surfaces via CSS, the Atlas inline panels via a noTransparency flag.
2026-06-29 13:59:00 +02:00
Maurice a3f395e5ac fix(appearance): scale inline px font sizes so text-size reaches all content
The global text-size control only set the root font-size, which scales rem-based text (navbar, menus) but not the dense inline px sizes used across the trip planner, budget, journey and panels — so place titles and addresses stayed fixed. applyAppearance now also exposes the factor as --fs-scale-text, and a codemod wraps inline numeric fontSize in calc(<px> * var(--fs-scale-text, 1)) across components and pages (map popups and PDF excluded). Sizes are byte-identical at 100%; the control now visibly resizes the actual content.
2026-06-29 13:59:00 +02:00
Maurice b6a414b79f chore(appearance): add theme:lint guard for hardcoded styles
A theme:lint script (modeled on i18n:parity) flags new inline color/fontSize literals and arbitrary-hex Tailwind classes that bypass the design tokens, so future code stays themeable. Map/PDF surfaces are exempt. The token taxonomy and the six theming rules are documented in src/theme/README.md.
2026-06-29 13:59:00 +02:00
Maurice 200108b76a feat(dashboard): per-device widget visibility with layout reflow
Dashboard widgets (currency, timezones, upcoming reservations, atlas and the stat tiles) can be shown or hidden independently on desktop and mobile from the appearance settings. The stat grid spreads its visible tiles to full width, and disabling the right sidebar collapses the layout to a single centered column.
2026-06-29 13:59:00 +02:00
Maurice a7334a9060 feat(settings): appearance settings tab
New Appearance tab with color mode (moved out of Display), color-scheme swatches, a custom accent picker with a live WCAG contrast hint, transparency and reduce-motion toggles, density, a global text-size slider with advanced per-tier controls, and per-device dashboard widget toggles. Edits preview live and commit on a short debounce. i18n keys added across all locales, translated for German.
2026-06-29 13:59:00 +02:00
Maurice 2cda779bc5 feat(appearance): token-driven theme engine with schemes and FOUC-safe boot
applyAppearance is the single writer of styling to the DOM (the .dark class plus data-scheme/-no-transparency/-density/-reduce-motion and the custom-accent/type-scale CSS vars). An external pre-paint /theme-boot.js replays a cached snapshot before first paint and complies with the production CSP (script-src 'self'), fixing the long-standing theme FOUC. Adds seven color schemes (incl. a true high-contrast that raises neutral contrast), a custom accent with auto-derived legible text, an extended token layer (accent variants, status/shadow/overlay/inverse), a scheme-gated legacy accent bridge, and a transparency-off layer. The default scheme sets no attributes, so existing users are unaffected.
2026-06-29 13:59:00 +02:00
Maurice 4742915389 feat(appearance): add per-user appearance config contract
Shared AppearanceConfig (color scheme, accent, transparency, per-tier type scale, density, reduce-motion and per-device dashboard widgets) stored as one JSON blob under the existing settings key. normalizeAppearance never throws, so a malformed/partial/future blob degrades to the neutral default and can never reach the DOM. No DB migration; the default reproduces today's look exactly.
2026-06-29 13:59:00 +02:00
Maurice d6bba454e0 fix(llm): stop the browser autofilling the LLM base URL (#1301)
The AI-parsing base URL and model inputs had no autoComplete, so a
browser password manager could drop the saved login email into the
base URL field. In the admin addon config onBlur then fired a model
lookup against e.g. "admin@trek.local", which the server rejected
with 400. Mark the base URL and model inputs as type=url /
autoComplete=off in both the admin addon config and the per-user
connection section.
2026-06-28 21:38:46 +02:00
Maurice 6f42e84183 fix(docker): keep server/reset-admin.js in the build context (#1339)
The Dockerfile copies server/reset-admin.js (the admin recovery
script), but .dockerignore also listed it, so it was stripped from
the build context and the image build failed with a not-found error.
Drop the ignore entry so the COPY resolves again.
2026-06-28 21:11:54 +02:00
Maurice cb3f9f0021 test(trips): cover the Unsplash cover download and search-race guard (#1277)
Adds unit coverage for saveUnsplashCover (host check, content-type
and size limits, download failure), the searchUnsplashPhotos error
and success paths, and the PUT handler internalising a hot-link.
Updates the existing PUT tests for the now-async handler.
2026-06-28 20:21:13 +02:00
Maurice f24d44b4a3 feat(trips): download chosen Unsplash covers into uploads (#1277)
Previously a selected Unsplash photo was stored as a remote
images.unsplash.com hot-link, so covers broke offline and on link
rot. The trip PUT handler now fetches the picked image through the
SSRF guard and saves it under uploads/covers, rewriting cover_image
to the local path (502 if the download fails). Also debounces the
cover search so a slow earlier request can no longer overwrite newer
results, drops a dead userId parameter, and reverts an unrelated
vite proxy change.
2026-06-28 20:21:13 +02:00
Azalea af90ba0911 [+] i18n 2026-06-28 20:21:13 +02:00
Azalea 8c941b52f9 [+] Unsplash 2026-06-28 20:21:13 +02:00
Maurice c7e8a5614d feat(mobile): make the bottom-nav "+" context-aware per trip tab (#1349)
On mobile the bottom-nav "+" always created a new place (except on the Costs tab,
where it added an expense). It now matches the active trip tab: Bookings adds a
reservation, Transports adds a transport, Costs adds an expense, and everything
else (Plan, plus tabs that have no create modal — Lists / Files / Collab) keeps
adding a place.

Follows the existing ?create=<intent> pattern: BottomNav.useCreateAction emits the
per-tab intent, and useTripPlanner consumes create=reservation|transport to open
the booking / transport modals (both already mounted at page level). Place and
expense were already wired; this just extends the mapping.

Tests: 4 new BottomNav cases (plan/bookings/transports/costs → correct intent +
navigate target); client tsc clean, full client suite green (2855).

Implements mauriceboe/TREK#1349
2026-06-28 16:26:16 +02:00
Maurice c10b9cc202 fix(map): keep the mobile GPS button above the day-detail panel (#1348)
On mobile the location (GPS) FAB sat at bottom: calc(var(--bottom-nav-h) + 12px),
which only clears the bottom nav. When a day is selected, DayDetailPanel slides
up over the map from bottom: navh+20 and spans nearly full width at z-index
10000, covering the button's band — so the button was hidden behind it.

DayDetailPanel now publishes its live measured height to a root CSS var
--day-panel-h (ResizeObserver, reset to 0 on unmount), and both map renderers
lift the button above the panel when it's open, reusing the hasDayDetail prop
they already receive:

  hasDayDetail
    ? calc(var(--bottom-nav-h) + 20px + var(--day-panel-h) + 12px)
    : calc(var(--bottom-nav-h) + 12px)

Applied to both the Leaflet (MapView) and GL (MapViewGL) renderers. When the
panel closes, hasDayDetail is false and the offset falls back to the bottom-nav
value. Desktop is unaffected — the button is mobile-only.

Tests: new DayDetailPanel case asserting --day-panel-h is published and reset on
unmount; client tsc clean, full client suite green (2851).
2026-06-28 14:54:31 +02:00
Maurice d1e024277f fix(pwa): stop unregistering the service worker on offline boot (#1346)
Opening the installed PWA offline showed Chrome's "no internet" page instead of
the cached app. On boot the axios response interceptor reacts to a failed
request with no response by probing /api/health; the probe collapsed "genuinely
offline" and "edge-proxy auth wall" into a single reachable=false, so the
interceptor unregistered the service worker and reloaded — straight into a dead
network. navigator.onLine is true on mobile while offline, so the existing guard
didn't help. This also defeated the offline data layer (withOfflineFallback,
authStore's offline branch), which runs later in the chain.

Fix: connectivity.probe() now returns a discriminated state
('online' | 'offline' | 'proxy-wall'). A fetch that throws, or navigator.onLine
false, is 'offline'; a cross-origin redirect (CF Access, via redirect:'manual'
→ opaqueredirect) or an HTML auth wall (Pangolin) is 'proxy-wall'. The
interceptor only tears down the SW on 'proxy-wall'; on plain offline it lets the
request reject so the cached shell + IndexedDB serve the app. CF Access /
Pangolin reauth still works — the proxy always presents a reachable redirect or
HTML wall, which the probe now detects positively.

Regression dates to v3.0.16 (#964), surfaced by the 3.1.0 rewrite.

Tests: 6 new connectivity cases (offline/online/proxy-wall discrimination);
client tsc clean, full client suite green (2850).
2026-06-28 12:48:27 +02:00
Maurice 172cff57a2 fix(airtrail): import departure/arrival times for manually-entered flights (#1336)
The mapper read only `departureScheduled`/`arrivalScheduled`, but those columns
are optional in AirTrail and stay null for manually-entered flights — where
`departure`/`arrival` are the only times set. So the import dropped the departure
clock (date-only) and the whole arrival (no date, no time), exactly as reported.

AirTrail's own rule is "use departure if available, otherwise fall back to
departureScheduled". Mirror that: prefer the scheduled instant, fall back to the
primary departure/arrival, in mapFlightToReservation, normalizeFlight, and the
sync hash. Hashing the resolved instant means flights already imported without a
scheduled time re-sync once and pick up their clock automatically; flights that
do have scheduled times are unaffected (no spurious re-sync).

Tests: 3 new mapper cases (fallback mapping, picker preview, hash tracking);
two existing cases that asserted the scheduled-only behaviour updated to the
"neither time set" case. Full server suite green (4085).
2026-06-28 12:12:48 +02:00
jufy111 0d6737726d Added focus to search places in placeFormModal 2026-06-28 11:53:42 +02:00
Maurice 6996a67670 fix(extract): don't let the day-clamp fallback break reservation resync (#1288)
This branch added a clamp-to-nearest-day fallback to resolveDayIdFromTime so an
imported booking whose exact date has no day row still lands on a day. After
rebasing onto dev, that collided with #1288's resyncReservationDays, which
relies on the original "null when no exact day" semantics to leave a booking
whose date now falls outside the range untouched — instead it snapped to an edge
day (TRIP-SVC-019 failed: expected day_id kept, got the clamped one).

Make clampToNearest an opt-in parameter (default true, preserving the import
behaviour for create/update) and have resyncReservationDays pass false, so
out-of-range bookings keep their day_id. Full server suite green (4082).
2026-06-28 11:53:19 +02:00
Maurice 84adc28684 fix(i18n): add Swedish translations for the AI booking-import settings
The Swedish (sv) locale landed on dev (#1325) after this branch added the
AI-parsing settings/reservation keys to the other locales, so sv was missing
them — strict i18n key parity failed after rebasing onto dev. Adds the 3
reservations.import.* and 17 settings.aiParsing/aiAlwaysRetry keys in sv.
2026-06-28 11:53:19 +02:00
Maurice f206fa6dff test(setup): stub websocket addListener/removeListener in the global mock
BackgroundTasksWidget (mounted globally in App) subscribes via addListener/removeListener from api/websocket, but the global test mock didn't export them, so every test that renders <App/> threw on mount. Add the two stubs. (Surfaced now that the page-pattern check passes and the client test step actually runs.)
2026-06-28 11:53:19 +02:00
Maurice c3b3c278b8 test(llm-parse): cover the extraction router, client factory and import jobs
The new LLM extraction router shipped with little branch coverage, dropping src/nest below the 80% gate. Add unit tests for routeExtraction (flights/single/union/error paths, deterministic booking-wide fill), the native Ollama format client, the provider factory, the local-router service path with its type-aware text cap, the flat->schema.org mapper's remaining reservation types, and the background import-jobs runner. Also remove the now-unused validate.ts (only its FlatLike type was still referenced; moved to flat-schemas).
2026-06-28 11:53:19 +02:00
Maurice d09a62fcc8 refactor(planner): move the import-review bridge effect into the page hook
TripPlannerPage held a useEffect (the background-import → review bridge), which trips the page-pattern check (pages must stay wiring containers). Move the effect and its store/IndexedDB wiring into useTripPlanner where the rest of the import-review state already lives.
2026-06-28 11:53:19 +02:00
Maurice f4b2143a59 feat(settings): use the shared custom dropdown for the AI parsing provider
Swap the native select for CustomSelect so the provider picker matches the rest of the app's styling (dark mode, portal dropdown).
2026-06-28 11:53:19 +02:00
Maurice 33f554b1bf fix(settings): show the Integrations tab when only AI parsing is enabled
hasIntegrations gated the tab on memories/mcp/airtrail only, so a user with just the llm_parsing addon enabled saw no Integrations tab and could not reach the AI parsing config. Include llmEnabled in the gate.
2026-06-28 11:53:19 +02:00
Maurice fc1f29bb29 feat(settings): let users set their own AI parsing model
Adds an "AI parsing" section under Settings -> Integrations where a user can choose the LLM provider, model, base URL, API key and multimodal option used for booking extraction. This per-user config applies when an admin has not configured an instance-wide model. Reuses the existing encrypted user settings: the API key is stored encrypted, never prefilled, and a blank field keeps the stored one. Adds settings.aiParsing.* across all 20 locales.
2026-06-28 11:53:19 +02:00
Maurice 01e5859564 chore(extract): recommend only Qwen3-8B (drop Qwen2.5 from the curated list)
Qwen3-8B is the identified default; the prior Qwen2.5 entries are no longer needed in the pull list.
2026-06-28 11:53:19 +02:00
Maurice 6a70f4fc41 fix(import): persist source files in IndexedDB so attach survives a reload
The source document was only kept in memory on the background task, so a page reload during the (now always-LLM ~25s) parse lost it and the booking saved without its file. Store the uploaded files in IndexedDB keyed by job id; the review loads them from there when the in-memory copy is gone, and a 1h TTL prunes abandoned imports.
2026-06-28 11:53:19 +02:00
Maurice 27fbc241e8 fix(import): preview the parsed cost as linked in the review modal
During the per-item import review the booking isn't saved yet, so the Costs section showed an empty 'Create expense' even though a linked cost will be created on save. Show the parsed price (amount + category) as the pending linked expense so the user can verify it up front. Reuses existing i18n keys.
2026-06-28 11:53:19 +02:00
Maurice 574c54c16c perf(extract): cap single-booking text tighter; require rental company
A long single-booking PDF (e.g. an 11-page rental voucher) spent ~200s on CPU prompt-eval at the 16k cap, though its data sits in the first ~2k. Cap non-flight docs at 6k (flights keep 16k for all legs). Also make the rental operator a required field so the car gets a real title.
2026-06-28 11:53:19 +02:00
Maurice 0cb0567d28 fix(import): refresh costs immediately after an imported booking is saved
The saving client gets no budget:created echo (X-Socket-Id) and the create response omits the linked budget item, so the booking's Costs section and the Costs tab stayed stale until a manual reload. Reload the budget items right after a create that carried a budget entry.
2026-06-28 11:53:19 +02:00
Maurice 76447f4a73 fix(extract): require the hotel address and ask for the rental company
After dropping the vendor templates, the model skipped the (often unlabeled) Expedia-style hotel address — making address a required schema field forces it to emit the street-address line, restoring the booking's location/place. Also hint the rental company so a car booking gets a real title instead of the generic fallback.
2026-06-28 11:53:19 +02:00
Maurice 55ff5c03dd refactor(extract): drop vendor templates, let the model drive with deterministic backfill
Now that a capable instruct model (Qwen3-8B, thinking off) reads name/address/dates/legs reliably across formats, the per-vendor template short-circuit distorted more than it fixed: brittle on layout variations and overriding the better model output. Remove the template layer; the model extracts the structure and Schicht 2 backfills the confirmation/total and takes the currency from the document's own symbol (correcting model misreads like ¥→$). Per-type prompts now also ask for address and price/currency.
2026-06-28 11:53:19 +02:00
Maurice 3277965426 feat(extract): recommend Qwen3-8B as the local extraction model
A/B against the prior default (qwen2.5:7b) on CPU showed Qwen3-8B is both faster and more accurate on tricky/multilingual booking docs (correct Airbnb year+price, correct DisneySea admission date), once thinking is disabled — which the router now does. Feature it as the recommended pull, keep qwen2.5:7b as the fallback.
2026-06-28 11:53:19 +02:00
Maurice d95d26e493 fix(extract): disable model thinking for grammar-constrained extraction
Hybrid/reasoning models (Qwen3 and similar) default to emitting reasoning tokens, which collide with Ollama's format-grammar constraint — on CPU this produced null/unparseable output and blew the latency budget (qwen3:8b: null or 300s timeouts vs ~20s with thinking off). Send think:false on the /api/chat call; Ollama ignores it for non-thinking models (verified on qwen2.5:7b), so it's safe and unlocks the stronger Qwen3 family.
2026-06-28 11:53:19 +02:00
Maurice 4abe96fe01 feat(import): attach the parsed source document to each booking
Keep the uploaded files on the background task and hand them to the review flow, so each reviewed booking pre-fills its Files with the document it was parsed from (uploaded with the booking on save). The two modals also adopt the shared resolveDayId helper.
2026-06-28 11:53:19 +02:00
Maurice 7bac753ff3 refactor(extract): dedupe currency/day helpers, drop redundant casts, support JPY vouchers
Code-audit clean-ups: share one normCurrency between the router and the templates, lift the duplicated nearest-day resolver into formatters.resolveDayId, drop two needless as-unknown-as casts at the fillBookingWideFields call sites, restore routeExtraction's doc comment, and give the broker template readable names. Plus recognise ¥/JPY and fall back to a standalone symbol amount, so a Klook-style voucher whose price sits far from any label still yields a cost.
2026-06-28 11:53:19 +02:00
Maurice 743397994e fix(import): refresh costs after a booking review so imported expenses appear without a reload
Imported bookings auto-create their linked budget items server-side, but the saving client suppresses its own budget:created echo, so the Costs list stayed stale until a manual reload. Reload the budget items when the review session ends.
2026-06-28 11:53:19 +02:00
Maurice 459426ed43 fix(import): resolve an imported transport's day from its parsed dates
A reviewed transport (e.g. a rental car) arrived with only its parsed pick-up/return dates and no day_id, so the modal kept just the time and saved a bare "HH:MM" with no date. Resolve start/end day from the parsed dates (exact match, else nearest trip day) so the booking lands on the right days.
2026-06-28 11:53:19 +02:00
Maurice b3fa87bdd6 fix(reservations): skip un-geocoded endpoints instead of failing the save
reservation_endpoints.lat/lng are NOT NULL, so saving a reviewed transport whose pick-up/return couldn't be geocoded threw a 500 and lost the whole booking (dates, linked cost). Skip those rows; the dates still persist on reservation_time/reservation_end_time.
2026-06-28 11:53:19 +02:00
Maurice 519dc3b0d8 fix(import): keep the parse-progress widget across a reload
Persist the background-import tasks (id/trip/status only) and re-fetch each job's status on mount, so a parse still running when the page reloads keeps its widget instead of vanishing; expired jobs (404) are dropped and a restored 'done' task re-fetches its items.
2026-06-28 11:53:19 +02:00
Maurice c1d61c98f0 fix(extract): backfill booking code/total and harden the reference match
Apply the deterministic confirmation-code and total fill to vendor-template results too (not just model output), and require the captured reference to contain a digit so a bare 'Confirmation'/'Reference' label no longer grabs the next prose word.
2026-06-28 11:53:19 +02:00
Maurice c7f5694f63 feat(extract): add Expedia and rental-broker booking templates
Pull the hotel/rental fields these vendors print in a stable text layout (name, address, stay/pickup dates, price, reference) deterministically, so the import stops depending on the local model for them. Handles German long/abbreviated months and English dates incl. 12-hour and comma forms.
2026-06-28 11:53:19 +02:00
Maurice d0b4052c5d fix(import): create linked costs and accommodations from reviewed bookings
Reviewing an imported booking saves it through the normal reservation
form, which dropped the parsed price (so no linked cost was created) and
only created the accommodation when both nights matched a trip day.
Carry the parsed price into a linked cost on save, and create the
accommodation from whichever day the check-in/out dates resolve to.
2026-06-28 11:53:19 +02:00
Maurice 1c81e8b959 feat(import): parse bookings in the background with a progress widget
Parsing a booking can take a while on a CPU host, so don't hold the
upload modal open for it. The async import endpoint returns a job id
right away; the parse runs server-side (one at a time per user) and
pushes progress over the user's WebSocket, and a small widget in the
bottom corner tracks it while the user keeps navigating and editing.
A finished job opens the per-item review from the widget.
2026-06-28 11:53:19 +02:00
Maurice 8f1c99a07a feat(extract): drive local parsing through a layered extraction router
The single-shot prompt was unreliable on multi-leg flights and longer
documents, and slow on a CPU host. For the local provider, run a small
router instead:

- deterministic vendor templates first, with no model call at all
- exactly one grammar-enforced call per document via Ollama's native
  `format` (flights as a flat array of legs, everything else as one flat
  reservation, the type picked from keywords or a union schema)
- booking-wide fields (booking reference, total price, the overnight
  arrival day) filled deterministically from the text afterwards, and
  dates coerced to ISO so a natural-language date can't slip through

Recommend qwen2.5 in the AI-parsing settings instead of NuExtract.
2026-06-28 11:53:19 +02:00
Maurice 5fdd4aa153 feat(import): review each parsed booking before it's saved
Instead of writing parsed items straight to the trip, the import opens the
normal edit modal pre-filled for each one, so you can check and fix it before
saving — useful when a model guesses a wrong date or address. Hotels gained an
editable address field; on save an existing place is matched by name, otherwise
the reviewed address is geocoded and a new place is created.
2026-06-28 11:53:19 +02:00
Maurice 22801938b5 fix(admin): tidy the AI parsing settings and recommend the 2B model
The provider picker is the shared CustomSelect now and the form is split into
clear sections rather than a flat stack of inputs. NuExtract 2.0 2B is the
recommended default — fastest on a CPU-only host and MIT licensed; the 4B
carries a non-commercial licence, so it's no longer flagged as recommended.
2026-06-28 11:53:19 +02:00
Maurice 8640100312 feat(extract): drive NuExtract with its native template
NuExtract isn't an instruct model — fed a plain chat prompt it just echoes the
schema back. Detect a NuExtract model by id and talk to it the way the model
cards document: the JSON template inlined in a single user message, no system
prompt, no json_schema, temperature 0. Its flat result is mapped back to the
same KiReservation shape the rest of the pipeline already uses, so nothing
downstream changes; every other model keeps the generic prompt.

Money is taken as a verbatim string and parsed locally (German "1.580,22 €"
otherwise comes back as 1.49772), a rental car's pickup/return ride the from/to
fields so a stray form label doesn't become the location, and a lodging with no
name falls back to its address instead of being dropped.
2026-06-28 11:53:19 +02:00
Maurice e666313865 fix(extract): refresh accommodations after a booking import
A freshly imported hotel links to an accommodation that lives outside the
trip store, so loadTrip alone left the reservation edit modal with blank
place/date fields. Reload the accommodations list once the import finishes.
2026-06-28 11:53:19 +02:00
Maurice aa72d527c9 feat(extract): create a linked cost from the booking price on import
When a confirmation carries a total price, record it as a real expense
linked to the reservation (in the matching Costs category) instead of
leaving the amount in metadata only. Gated on the Costs addon.
2026-06-28 11:53:19 +02:00
Maurice 684ac3b442 feat(extract): capture seat, class, platform, price + event venue contact
Request and map root-level seat/class/platform and a total price/currency into reservation metadata (shown on the card; price reuses the existing label). Read both the root and reservationFor and tolerate common field-name aliases (priceAmount, priceCurrencyISO4217Code, fareClass, ...) since models name these inconsistently. Also capture event/attraction venue telephone + url onto the auto-created place, matching lodging/restaurant.
2026-06-28 11:53:19 +02:00
Maurice f049229e25 perf(extract): cap LLM input at 4000 chars for CPU-only speed
On a GPU-less host the model's prompt-eval time scales with input length and dominates total latency. Booking details sit at the top of a confirmation, so capping the extracted text at 4000 chars (was 8000) roughly halves extraction time (~50s warm for a capable local 7B model) with no loss of fields on real hotel/rental confirmations. Tunable if a long multi-segment itinerary needs more.
2026-06-28 11:53:19 +02:00
Maurice 38565c3c6d feat(extract): fill transport/booking fields, geocode endpoints, assign days
- rental car: request+map dropoffLocation, emit pickup->return from/to endpoints, set a location string (G1/G2/G3). - geocode endpoints (stations/stops/terminals/rental desks) on confirm via Nominatim; mapper now emits coordless named endpoints and confirm persists only the geocoded ones (G6). - assign every dated booking to the nearest trip day so it still shows when slightly out of range, and keep hotel accommodation from vanishing when a check date misses (G5/G10). - fix bus mislabelled as train + add bus_number metadata (G7/G8), flag malformed boats (G9), accept root start/end time for events (G11). - raise the local-LLM timeout to 300s for CPU-only Ollama.
2026-06-28 11:53:19 +02:00
Maurice a1cbc11169 fix(extract): make AI imports reliable and fast on local models
client: the import call inherited the global 8s axios timeout and aborted long LLM extractions even though the server finished it; remove the timeout. server: raise the OpenAI-compatible LLM timeout 60s->180s (a cold Ollama model can take ~45s to first token). server: cap extracted text to 8000 chars before the LLM - multi-page T&C tails (30k+ chars) overflowed the context window, truncating the relevant head and making CPU inference crawl; booking details sit at the top.
2026-06-28 11:53:19 +02:00
Maurice b859ae8b00 fix(extract): auto-run the AI fallback when the addon is enabled
Booking import only fell back to the LLM when each user flipped an 'always retry with AI' toggle, so by default files kitinerary returned nothing for just failed. Run the fallback automatically whenever the AI Parsing addon is on (fallback-on-empty); drop the now-redundant per-user toggle and its setting.
2026-06-28 11:53:19 +02:00
jubnl ae14a6c860 feat(extract): extract data using LLM 2026-06-28 11:53:19 +02:00
Maurice 41c541828f fix(setup): warn when ADMIN_EMAIL/ADMIN_PASSWORD are ignored, ship reset-admin
The first-run seeder only applies ADMIN_EMAIL/ADMIN_PASSWORD on an empty
database and then silently ignores them. People add the vars after the first
boot, or pull a fresh image without clearing ./data, restart, and cannot log
in with no hint why (#1339). The default is a generated password (not the
.env.example placeholder), printed once in the first-run box. Now: warn loudly
when the vars are set but a user already exists, and warn on a partial
(one-of-two) config instead of quietly falling back.

Also ship the reset-admin recovery script in the image -- it was never COPYed in
despite the wiki referencing it. node server/reset-admin.js resets/creates
admin@trek.local with a generated password (RESET_ADMIN_EMAIL/RESET_ADMIN_PASSWORD
overridable), picks a free username so it cannot trip UNIQUE(username), and sets
must_change_password.
2026-06-28 11:10:40 +02:00
Maurice 37f1fff367 Merge main into dev after the v3.1.3 release 2026-06-28 10:59:53 +02:00
Maurice 0c1c534435 docs(wiki): document the snap Docker + no-new-privileges startup failure 2026-06-28 10:30:37 +02:00
github-actions[bot] 0631e34a79 chore: bump version to 3.1.3 [skip ci] 2026-06-27 19:10:04 +00:00
Maurice 8a013f6fa9 fix(build): bump the client to vite 8.1.0 so the amd64 image builds
The 3.1.3 docker build failed on linux/amd64 while bundling the client
(arm64 happened to pass): vite 8.0.16 pins rolldown 1.0.3, but tsdown
pulls rolldown 1.1.2, and on amd64 npm hoists the 1.1.2 native binding so
vite's 1.0.3 rolldown loaded a mismatched one ("builtin:vite-wasm-fallback
does not match any variant of BindingBuiltinPluginName"). Moving the client
to vite 8.1.0, which expects rolldown 1.1.2, lines the bundler up with the
hoisted binding. Verified by building the client in a clean linux/amd64
node:22 container.
2026-06-27 21:09:05 +02:00
Maurice 7c3440f139 Revert "chore: bump version to 3.1.3 [skip ci]"
This reverts commit 4ceea09e31.
2026-06-27 20:49:58 +02:00
github-actions[bot] 4ceea09e31 chore: bump version to 3.1.3 [skip ci] 2026-06-27 18:15:27 +00:00
Maurice 03cdb4d276 fix(files): reject cross-trip reservation/place/assignment links
A member of one trip could point a file at a reservation, place or
day-assignment belonging to another, private trip — on upload, on a
metadata update, or through the file-link endpoint. The reservation join
in the file list and the links list then returned that trip's reservation
title, disclosing it across the trip boundary and letting an attacker
enumerate foreign reservation titles by their id.

The file already had to belong to the caller's trip; now the linked
reservation/place/assignment must too. findForeignLinkTarget checks each
supplied id against the trip (assignments via day -> trip) and the upload,
update and link handlers reject a cross-trip reference with 400 before it
is stored. Same-trip links and clearing a link are unchanged.
2026-06-27 20:14:52 +02:00
Maurice f0877a2e7d Replace the 3.0 upgrade notices with a thank-you / support modal
The 3.0 "what's new" notices have served their purpose, so swap them for a single thank-you notice that comes back once on every fresh install and version bump. It carries Buy Me a Coffee and Ko-fi buttons and only shows on desktop. Adds a per-version recurring mode (new dismissed_app_version column) plus external-link CTAs to support it; the 3.0.14 whitespace-collision admin notice stays active.
2026-06-27 20:14:52 +02:00
Maurice aa91f009ad fix(costs): freeze the FX rate so settled expenses don't reopen when rates drift (#1335)
Settle-up transfers are stored as fixed amounts, but a foreign-currency expense was re-converted with live rates on every settlement calc. When the rate drifted, the fixed transfer no longer cancelled the re-valued expense and a few-cent residual re-opened the settled position. Foreign-currency expenses now freeze the live rate at entry time into the existing budget_items.exchange_rate column, and the settlement converts with that frozen rate when working in the trip currency. Legacy rows (exchange_rate = 1) keep using live rates, so historical data is unchanged until re-edited; rate fetch failures fall back to live rates.
2026-06-27 20:14:52 +02:00
Maurice 2277f28a57 fix(airtrail): import the airline name, not the ICAO code (#1334)
AirTrail returns each airline as {icao, iata, name}, but the import reduced it to the ICAO/IATA code, so an imported flight showed e.g. 'EWG' instead of 'Eurowings'. The picker and the stored reservation now use the airline name (falling back to the code when AirTrail has none). The raw code is kept in metadata.airline_code so the writeback to AirTrail still sends a code, not a name (#1240), and the change-detection snapshot hash stays on the code so existing flights don't spuriously re-sync.
2026-06-27 20:14:52 +02:00
Maurice 1ec2d62b1c fix(reservations): keep dated bookings on their date when the trip range shifts (#1288)
Changing a trip's start date positionally re-dates the day rows (keeping their ids), so a dated booking's day_id stayed glued to a now-re-dated day and the booking visually shifted by the offset — until you re-opened and saved it. After a date-range change, non-hotel bookings are now re-anchored to the day matching their absolute reservation_time (the same derivation create/update already use). Bookings whose date falls outside the new range are left untouched; hotels and the relative positional shift of places/notes are unaffected.
2026-06-27 20:14:52 +02:00
Maurice 649735726f fix(map): pin the GL basemap label language to the UI language (#1299)
On a GL map (Mapbox Standard) the basemap labels fell back to the browser/OS locale, so place and country names showed stacked in several scripts (e.g. 'India / भारत / India') regardless of the chosen language. Pin Mapbox Standard's basemap label language to the user's UI language via the basemap 'language' config property, mapping the few TREK codes that differ (br->pt, gr->el, zh/zhTw->zh-Hans/zh-Hant). Applies to both the trip map and the journey map; classic and MapLibre styles are left unchanged.
2026-06-27 20:14:52 +02:00
Maurice 4e91fbca48 fix(places): guide single-place links to the right importer (#1304)
Pasting a single-place Google Maps share link (.../maps/place/...) into the list import failed with a cryptic 'Could not extract list ID from URL'. When the link is a single place it now returns a clear message telling the user to paste it into the place search box instead; other unrecognised URLs keep the existing list-link message.
2026-06-27 20:14:52 +02:00
Maurice 4cb9b18cc6 fix(atlas): assign border places by polygon, not just bounding box (#1331)
getCountryFromCoords picked the country with the smallest bounding box containing the point, so a place just across a border (e.g. Strasbourg, which sits inside both the FR and DE boxes) landed in the wrong, smaller-box country. When more than one country box matches, it now disambiguates with the real admin0 polygon via point-in-polygon, smallest-box-first; a micro-territory with no admin0 polygon (HK, MO, SM, VA, ...) keeps the smallest-box win, and an unmatched point falls back to the old behaviour. The common single-candidate case is unchanged.
2026-06-27 20:14:52 +02:00
Maurice f3b54166fb test(planner): cover the single-place route-tools visibility gate (#1330)
Asserts the route tools appear for one located place when a bookend accommodation exists, and stay hidden without one, guarding the #1330 visibility change.
2026-06-27 20:14:52 +02:00
Maurice 8c63235cd2 test(share): cover the translated untitled-day label (#1296)
Renders the public share page in German with a titleless day and asserts the i18n label 'Tag 1', guarding the t('dayplan.dayN') fix against a regression to a hardcoded English string.
2026-06-27 20:14:52 +02:00
Maurice 3554fde8d6 fix(dashboard): persist the currency & timezone widgets so an upgrade keeps them (#1311)
The currency and timezone widgets stored their state only in browser localStorage, so a (docker) upgrade that clears site storage reset them to defaults — unlike every other preference, which is saved server-side. Persist them through the per-user settings store (no schema change; the settings table takes arbitrary keys) and migrate any existing localStorage values on first load so users keep their picks. dashboard_timezones is left unset by default so the widget can tell 'never chosen' from an explicitly emptied list.
2026-06-27 20:14:52 +02:00
Maurice eb0ab4001d fix(planner): show the route tools for a single place when optimizing from accommodation (#1330)
The day's route tools were gated on having 2+ stops, so a day with one located place and accommodation optimization on hid them — even though the map already draws the hotel -> place -> hotel route. Treat a lone located place as routable when a bookend hotel with coordinates exists, mirroring what the map renders. Purely additive to the existing 2+ case.
2026-06-27 20:14:52 +02:00
Maurice 497d8e854f fix(map): draw the hotel-to-hotel leg on a transfer day with no activities (#1297)
On a day whose only content is checking out of one accommodation and into another, there are no waypoints for the hotel bookends to attach to, so no line was drawn. Add the A->B leg directly when both bookend hotels are real (excluding the day-1 arrival fallback per #1321) and distinct, so an ordinary same-hotel rest day still draws nothing.
2026-06-27 20:14:52 +02:00
Maurice e6fe14cac2 fix(pwa): use the self-contained app icon for the favicon so it shows on dark tabs (#1328)
icon-dark.svg is a black logo on a transparent background and is invisible on a dark browser tab strip (e.g. Edge dark mode). Point the favicon at icon.svg, which carries its own dark gradient background and reads on both light and dark chrome; icon-dark.svg keeps its in-app light-mode use.
2026-06-27 20:14:52 +02:00
Maurice 2a8caf6e7d fix(share): translate the day label on the public share page (#1296)
Untitled days on the public share page rendered as a hardcoded English 'Day N' instead of the dayplan.dayN key used everywhere else, so they stayed English regardless of the viewer's language. The key is already translated in every locale.
2026-06-27 20:14:52 +02:00
Maurice 005e0c109d fix(maps): make Overpass endpoints configurable and harden the POI search (#1309)
Builds on @Hardik-369's instance-specific User-Agent idea and reworks the rest
of the #1309 fix:

- keep the unique User-Agent (buildUserAgent) — a shared UA gets the public
  Overpass mirrors to rate-limit harder; it appends the configured instance
  URL and is applied to every Nominatim/Overpass/Wikimedia call
- add OVERPASS_URL so an operator behind locked-down egress (e.g. a Kubernetes
  cluster) can point the explore search at an internal/self-hosted Overpass
  instance instead of the public mirrors
- keep the per-endpoint timeout default at 12s but make it tunable via
  OVERPASS_TIMEOUT_MS for slow self-hosted instances; non-positive/invalid
  values fall back to the default rather than 502-ing every search at a 0ms cap
- log each endpoint's failure reason before the 502 so blocked egress is
  diagnosable instead of a bare "Overpass request failed"

Adds unit tests for the User-Agent, endpoint and timeout resolution plus the
all-mirrors-down path, and documents the two new env vars in .env.example, the
wiki and the Helm chart.
2026-06-27 20:14:52 +02:00
Hardik-369 e54ea2f17d fix: memoize User-Agent and prevent localhost leak; bump timeout to 30s
- Memoize USER_AGENT via IIFE so it's computed once, not per-request
- Only append instance URL when APP_URL or ALLOWED_ORIGINS is explicitly
  configured; skip the getAppUrl() localhost fallback
- Bump OVERPASS_TIMEOUT_MS to 30000 (above the observed 25.7s TTFB)
2026-06-27 20:14:52 +02:00
Hardik-369 544a76d2da fix(maps): increase Overpass timeout and add instance-specific User-Agent
The POI search endpoint (/api/maps/pois) returned 502 errors because:

1. OVERPASS_TIMEOUT_MS (12s) was shorter than mirror response times
   (kumi.systems takes ~25.7s to first byte). Increased to 25s to match
   the [timeout:20] query timeout.

2. The static User-Agent string was indistinguishable between instances,
   making rate-limiting and throttling more likely. The new userAgent()
   function appends the instance's APP_URL so each deployment identifies
   itself uniquely, following Overpass API best practices.
2026-06-27 20:14:52 +02:00
Maurice a5ba246cb8 test(i18n): account for the Swedish locale in SUPPORTED_LANGUAGES
The Swedish translation added 'sv' as the 21st language but left the
FE-COMP-I18N-009 length assertion at 20, so the full client suite went
red on this branch. Bump the count to 21 and add an 'sv' sample.
2026-06-27 20:14:52 +02:00
Maurice 0b2780ead2 test: make the Google Maps ftid path honest + cover the URL helper
The Places API googleMapsUri is a cid-style URL with no ftid, so the
search/getPlaceDetails fixtures had stored a fabricated ftid. Switch them
to real cid URLs and assert google_ftid is null — the precise
query_place_id link still fixes the wrong-spot bug — and document the
behaviour on googleFtidFromMapsUrl.

- add a direct googleFtidFromMapsUrl test: extracts a real /place ftid,
  returns null for a cid URL, rejects malformed/hostile values
- add placeGoogleMaps.test.ts covering the whole fallback chain
  (ftid -> place_id -> details URL -> coords) and the hostile-ftid rejection
- PlaceInspector: use a freshly-fetched ftid when the place hasn't stored one
2026-06-27 20:14:52 +02:00
Azalea 91fcaa50f6 Use Google Maps feature IDs for place map links 2026-06-27 20:14:52 +02:00
Azalea 9669642c62 feat(maps): add MapLibre OpenFreeMap support (#1317)
Adds MapLibre GL with OpenFreeMap as a tokenless third map provider
alongside Leaflet and Mapbox: a provider abstraction with style presets,
CSP + service-worker entries for tiles.openfreemap.org, and the
map_provider allow-list entry. Mapbox-only APIs stay gated behind the
mapbox provider, and existing Mapbox/Leaflet users are unaffected.

Maintainer review follow-ups folded in: the new map-settings strings are
translated across all locales; the GL engine is lazy-loaded so
Leaflet-only installs don't download it; MapLibre gets its own
maplibre_style slot so switching providers no longer overwrites a custom
Mapbox style; and the MapLibre render path plus the OpenFreeMap
style-guards are covered by tests.
2026-06-27 20:14:52 +02:00
Maurice 7531badbe8 i18n(sv): add the settings.distance key (Avståndsenhet)
Keeps the Swedish locale in parity with the rest after the distance-unit
setting (#1300) landed on the release branch.
2026-06-27 20:14:52 +02:00
Andreas Olsson 424018fc66 feat: swedis translation 2026-06-27 20:14:52 +02:00
Maurice 9d8af4b357 i18n(nl): fix doubled "In" typo in packing.importTitle
"InInpaklijst importeren" → "Inpaklijst importeren".
2026-06-27 20:14:52 +02:00
eindpunt 5b3f77f11d Added new Dutch translations and some corrections 2026-06-27 20:14:52 +02:00
Azalea e04cf85bef feat(planner): seek places sidebar on map selection 2026-06-27 20:14:52 +02:00
Maurice 3d65bb0c12 fix: address review feedback on the distance unit setting
- server: allow distance_unit as an admin default (+ value validation) so the
  Admin "Default User Settings" toggle persists instead of returning 400
- i18n: add settings.distance to all 20 locales and translate the labels
  through t() instead of hardcoding "Distance"
- route legs: include the unit in the OSRM cache key and recompute on a unit
  switch, so map and sidebar distances refresh and never mix units
- keep wind speed tied to the temperature unit — a distance setting must not
  silently flip existing Fahrenheit users from mph to km/h
- restore the sub-1km metres reading for metric, convert GPX elevation to feet
  for imperial, and format distances with a '.' decimal in every locale
- add units.test.ts
2026-06-27 20:14:52 +02:00
Matt Van Horn 94dca8cad7 feat: add distance unit (metric/imperial) display setting
Mirrors the existing temperature_unit pattern. Adds distance_unit to Settings,
a Display settings control, admin default, and a formatDistance helper applied
at distance render sites. Backward compatible (default metric). Closes #1300.
2026-06-27 20:14:52 +02:00
Maurice b1145e7e0a fix(map): drop the hotel leg to/from a transport endpoint on arrival days (#1321)
On the first day of a trip the morning hotel is only a check-in fallback
— you arrive from home, you didn't sleep there — so bookending the route
from that hotel to the flight/train departure point drew a phantom
hotel → departure leg, both on the map and in the day sidebar. The same
backwards leg showed up on a multi-day transport's arrival day, and its
mirror departure → hotel on an evening departure.

getDayBookendHotels now also reports whether the morning hotel is one you
actually slept in and whether you sleep in the evening hotel tonight. The
map and sidebar only draw a hotel↔transport bookend when that holds; a
hotel↔place leg is always kept, so the home-base loop and onward-travel
legs are unaffected. The optimizer keeps using the hotel values as before.
2026-06-27 20:14:52 +02:00
Sheroy Cooper 382ec37142 docs: dedupe development environment guide (#1320) 2026-06-26 16:10:46 +02:00
jubnl 92e3ebb4d5 chore(wiki): ensure correctness for kitinerary installation 2026-06-25 08:41:44 +02:00
jubnl 49fb2fded2 chore(wiki): make sure that all environement variables are properly documented 2026-06-24 14:03:39 +02:00
990 changed files with 44009 additions and 3742 deletions
-1
View File
@@ -30,7 +30,6 @@ Thumbs.db
sonar-project.properties
server/tests/
server/vitest.config.ts
server/reset-admin.js
**/*.test.ts
**/*.spec.ts
wiki/
+3 -1
View File
@@ -65,4 +65,6 @@ coverage
test-data
.run
.full-review
.full-review
# Wiki offline snapshot is baked in at build, not committed (duplicates wiki/)
server/assets/wiki/
+5 -15
View File
@@ -46,23 +46,11 @@ COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
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 && \
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 wget ca-certificates python3 build-essential \
libkitinerary-bin && \
npm ci --workspace=server --omit=dev && \
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.2.tgz && \
echo "ba5cfb4a2353157c8f54cbeaea0097c5bf2c3a810e0342f63d6e524826176628 /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 && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
apt-get purge -y python3 build-essential && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
@@ -89,6 +77,8 @@ COPY server/tsconfig.json ./server/
# 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=client-builder /app/client/dist ./server/public
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
+2
View File
@@ -40,6 +40,8 @@ See `values.yaml` for more options.
## Notes
- 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.
- To use your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`. The other values for that volume (size, storageClassName, annotations) are then ignored.
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
- `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.
- 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
name: trek
version: 3.1.2
version: 3.1.3
description: Minimal Helm chart for TREK app
appVersion: "3.1.2"
appVersion: "3.1.3"
+6
View File
@@ -21,3 +21,9 @@
4. Only one method should be used at a time. If both `generateEncryptionKey` and `existingSecret` are
set, `existingSecret` takes precedence. Ensure the referenced secret and key exist in the namespace.
5. Persistence:
- To bind your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`.
The other values for that volume (size, storageClassName, annotations) are then ignored.
- With `persistence.enabled=false` the volumes use an emptyDir — storage is ephemeral and is lost
when the pod restarts. Use only for testing.
+6
View File
@@ -70,3 +70,9 @@ data:
{{- if .Values.env.MCP_RATE_LIMIT }}
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
{{- 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 }}
+10 -2
View File
@@ -82,8 +82,16 @@ spec:
periodSeconds: 10
volumes:
- name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "trek.fullname" . }}-data
claimName: {{ default (printf "%s-data" (include "trek.fullname" .)) .Values.persistence.data.existingClaim }}
{{- else }}
emptyDir: {}
{{- end }}
- name: uploads
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "trek.fullname" . }}-uploads
claimName: {{ default (printf "%s-uploads" (include "trek.fullname" .)) .Values.persistence.uploads.existingClaim }}
{{- else }}
emptyDir: {}
{{- end }}
+3 -1
View File
@@ -1,4 +1,4 @@
{{- if .Values.persistence.enabled }}
{{- if and .Values.persistence.enabled (not .Values.persistence.data.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
@@ -18,7 +18,9 @@ spec:
resources:
requests:
storage: {{ .Values.persistence.data.size }}
{{- end }}
---
{{- if and .Values.persistence.enabled (not .Values.persistence.uploads.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
+11
View File
@@ -67,6 +67,12 @@ env:
# Max MCP API requests per user per minute. Defaults to 300.
# MCP_MAX_SESSION_PER_USER: "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.
@@ -95,15 +101,20 @@ existingSecret: ""
existingSecretKey: ENCRYPTION_KEY
persistence:
# When disabled, volumes fall back to an ephemeral emptyDir (data lost on pod restart).
enabled: true
data:
size: 1Gi
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
storageClassName: ""
# Bind an existing PVC. The other values (size, storageClassName, annotations) are then ignored.
existingClaim: ""
annotations: {}
uploads:
size: 1Gi
storageClassName: ""
# Specify an existing PVC to bind. The other values are then ignored.
existingClaim: ""
annotations: {}
resources:
+5 -1
View File
@@ -5,6 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<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 -->
<meta name="theme-color" content="#09090b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
@@ -13,7 +17,7 @@
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
+7 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "3.1.2",
"version": "3.1.3",
"private": true,
"type": "module",
"scripts": {
@@ -17,6 +17,8 @@
"lint": "eslint .",
"lint:check": "eslint .",
"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:report": "playwright show-report",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
@@ -30,11 +32,14 @@
"@trek/shared": "*",
"axios": "^1.6.7",
"dexie": "^4.4.2",
"drag-drop-touch": "^1.3.1",
"heic-to": "^1.4.2",
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
"maplibre-gl": "^5.24.0",
"marked": "^18.0.0",
"plyr": "^3.8.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-dropzone": "^14.4.1",
@@ -81,7 +86,7 @@
"tailwindcss": "^3.4.1",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.16",
"vite": "8.1.0",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.9"
}
+58
View File
@@ -0,0 +1,58 @@
/*
* 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
@@ -0,0 +1,73 @@
#!/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);
}
+50 -20
View File
@@ -2,6 +2,7 @@ import React, { useEffect, ReactNode } from 'react'
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { applyAppearance } from './theme/applyAppearance'
import { useAddonStore } from './store/addonStore'
import LoginPage from './pages/LoginPage'
import ForgotPasswordPage from './pages/ForgotPasswordPage'
@@ -12,14 +13,18 @@ import FilesPage from './pages/FilesPage'
import AdminPage from './pages/AdminPage'
import SettingsPage from './pages/SettingsPage'
import VacayPage from './pages/VacayPage'
import HelpPage from './pages/HelpPage'
import AtlasPage from './pages/AtlasPage'
import JourneyPage from './pages/JourneyPage'
import JourneyDetailPage from './pages/JourneyDetailPage'
import CollectionsPage from './pages/CollectionsPage'
import JourneyPublicPage from './pages/JourneyPublicPage'
import SharedTripPage from './pages/SharedTripPage'
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
import { ToastContainer } from './components/shared/Toast'
import SaveToCollectionModal from './components/Collections/SaveToCollectionModal'
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget'
import BottomNav from './components/Layout/BottomNav'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client'
@@ -174,30 +179,21 @@ export default function App() {
const isSharedPage = location.pathname.startsWith('/shared/')
useEffect(() => {
// Shared page always forces light mode
if (isSharedPage) {
document.documentElement.classList.remove('dark')
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', '#ffffff')
return
}
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 run = () =>
applyAppearance({
darkMode: settings.dark_mode,
appearance: settings.appearance,
isSharedPage,
})
run()
// Re-resolve on OS theme change while in auto mode.
if (!isSharedPage && settings.dark_mode === 'auto') {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
applyDark(mq.matches)
const handler = (e: MediaQueryListEvent) => applyDark(e.matches)
const handler = () => run()
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}
applyDark(mode === true || mode === 'dark')
}, [settings.dark_mode, isSharedPage])
}, [settings.dark_mode, settings.appearance, isSharedPage])
const isAuthPage = location.pathname.startsWith('/login')
|| location.pathname.startsWith('/register')
@@ -208,6 +204,8 @@ export default function App() {
<TranslationProvider>
{!isAuthPage && <SystemNoticeHost />}
<ToastContainer />
{!isAuthPage && <BackgroundTasksWidget />}
{!isAuthPage && <SaveToCollectionModal />}
<OfflineBanner />
<Routes>
<Route path="/" element={<RootRedirect />} />
@@ -227,6 +225,22 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/help"
element={
<ProtectedRoute>
<HelpPage />
</ProtectedRoute>
}
/>
<Route
path="/help/:slug"
element={
<ProtectedRoute>
<HelpPage />
</ProtectedRoute>
}
/>
<Route
path="/trips/:id"
element={
@@ -291,6 +305,22 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/collections"
element={
<ProtectedRoute addonId="collections">
<CollectionsPage />
</ProtectedRoute>
}
/>
<Route
path="/collections/:id"
element={
<ProtectedRoute addonId="collections">
<CollectionsPage />
</ProtectedRoute>
}
/>
<Route
path="/notifications"
element={
+102 -16
View File
@@ -15,7 +15,8 @@ import {
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
type ResetPasswordRequest, type ChangePasswordRequest,
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
type TripAddMemberRequest, type AssignmentReorderRequest,
type TripAddMemberRequest, type TripTransferOwnershipRequest,
type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest,
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest,
@@ -23,10 +24,11 @@ import {
type ReservationCreateRequest, type ReservationUpdateRequest,
type AccommodationCreateRequest, type AccommodationUpdateRequest,
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
type PackingCreateItemRequest, type PackingUpdateItemRequest,
type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest,
type TodoCreateItemRequest, type TodoUpdateItemRequest,
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
type PlaceBulkDeleteRequest,
type PlaceBulkUpdateRequest,
type DayNoteCreateRequest, type DayNoteUpdateRequest,
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
type PackingCategoryAssigneesRequest,
@@ -41,9 +43,10 @@ import {
type BookingImportPreviewItem,
type BookingImportPreviewResponse,
type BookingImportConfirmResponse,
type BookingImportMode,
} from '@trek/shared'
import { getSocketId } from './websocket'
import { isReachable, probeNow } from '../sync/connectivity'
import { probeNow } from '../sync/connectivity'
/**
* Validate a response payload against its @trek/shared Zod schema — but only in
@@ -100,6 +103,7 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
uk: 'Занадто багато спроб. Спробуйте пізніше.',
sv: 'För många försök. Prova igen senare.',
}
function translateRateLimit(): string {
@@ -174,13 +178,17 @@ apiClient.interceptors.response.use(
// 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.
if (!error.response && navigator.onLine) {
await probeNow()
// Both the original request and the health probe failed while the device
// has a network interface. This matches the proxy-auth-challenge pattern
// (CF Access / Pangolin intercept all requests and CORS-block XHR).
// Guard with sessionStorage to prevent reload loops (server genuinely
// down would also land here, but only reloads once).
if (!isReachable()) {
// Only an actual edge-proxy auth wall warrants tearing down the SW to
// reauth: a reachable proxy (CF Access / Pangolin) that intercepts /api
// with a cross-origin redirect or an HTML login page. A genuine offline
// boot ALSO lands here — navigator.onLine reflects a network interface,
// not reachability, and is routinely true on mobile while offline. So
// gate strictly on a positive proxy signal; on plain offline do nothing
// and let the request reject so the cached shell + IndexedDB serve the
// 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
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
sessionStorage.setItem('proxy_reauth_attempted', '1')
@@ -327,11 +335,16 @@ export const tripsApi = {
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),
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),
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),
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier } satisfies TripAddMemberRequest).then(r => r.data),
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
transferOwnership: (id: number | string, newOwnerId: number) => apiClient.post(`/trips/${id}/transfer`, { newOwnerId } satisfies TripTransferOwnershipRequest).then(r => r.data),
createGuest: (id: number | string, name: string) => apiClient.post(`/trips/${id}/guests`, { name } satisfies TripCreateGuestRequest).then(r => r.data),
renameGuest: (id: number | string, userId: number, name: string) => apiClient.put(`/trips/${id}/guests/${userId}`, { name } satisfies TripRenameGuestRequest).then(r => r.data),
deleteGuest: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/guests/${userId}`).then(r => r.data),
copy: (id: number | string, data?: TripCopyRequest) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
}
@@ -372,6 +385,8 @@ export const placesApi = {
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) =>
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
bulkUpdate: (tripId: number | string, ids: number[], data: Omit<PlaceBulkUpdateRequest, 'ids'>) =>
apiClient.post(`/trips/${tripId}/places/bulk-update`, { ids, ...data } satisfies PlaceBulkUpdateRequest).then(r => r.data),
}
export const assignmentsApi = {
@@ -393,6 +408,10 @@ export const packingApi = {
update: (tripId: number | string, id: number, data: PackingUpdateItemRequest) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
setSharing: (tripId: number | string, id: number, data: PackingSetSharingRequest) => apiClient.put(`/trips/${tripId}/packing/${id}/sharing`, data).then(r => r.data),
clone: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/packing/${id}/clone`).then(r => r.data),
addContributor: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/packing/${id}/contributors`).then(r => r.data),
removeContributor: (tripId: number | string, id: number, userId: number) => apiClient.delete(`/trips/${tripId}/packing/${id}/contributors/${userId}`).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),
listTemplates: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/templates`).then(r => r.data),
@@ -441,6 +460,41 @@ export const adminApi = {
updateOidc: (data: Record<string, unknown>) => apiClient.put('/admin/oidc', data).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),
// 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),
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
@@ -537,9 +591,16 @@ export const journeyApi = {
onUploadProgress: opts?.onUploadProgress,
signal: opts?.signal,
}).then(r => r.data),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
uploadGalleryVideo: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
apiClient.post(`/journeys/${journeyId}/gallery/video`, formData, {
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
timeout: 0,
onUploadProgress: opts?.onUploadProgress,
signal: opts?.signal,
}).then(r => r.data),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) }).then(r => r.data),
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
@@ -624,17 +685,31 @@ export const reservationsApi = {
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),
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[]): Promise<BookingImportPreviewResponse> => {
importBookingPreview: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<BookingImportPreviewResponse> => {
const fd = new FormData()
for (const f of files) fd.append('files', f)
return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
fd.append('mode', mode)
// 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> =>
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 = {
features: (): Promise<{ bookingImport: boolean }> => apiClient.get('/health/features').then(r => r.data),
features: (): Promise<{ bookingImport: boolean; aiParsing: boolean }> => apiClient.get('/health/features').then(r => r.data),
}
export const weatherApi = {
@@ -647,6 +722,17 @@ export const configApi = {
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 = {
get: () => apiClient.get('/settings').then(r => r.data),
set: (key: string, value: unknown) => {
@@ -753,4 +839,4 @@ export const inAppNotificationsApi = {
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
}
export default apiClient
export default apiClient
+98
View File
@@ -0,0 +1,98 @@
import apiClient from './client'
import type { AxiosResponse } from 'axios'
import type {
CollectionListResponse,
CollectionDetailResponse,
CollectionSaveResult,
CollectionMembership,
CollectionCreateRequest,
CollectionUpdateRequest,
CollectionSavePlaceRequest,
CollectionSaveFromTripRequest,
CollectionPlaceUpdateRequest,
CollectionCopyToTripRequest,
CollectionInviteRequest,
CollectionRole,
CollectionInviteActionRequest,
CollectionInviteCancelRequest,
CollectionStatus,
Collection,
CollectionPlace,
} from '@trek/shared'
const ax = apiClient
const base = '/addons/collections'
/** Query for the library-wide "is this place already saved?" lookup. */
export interface MembershipQuery {
google_place_id?: string
google_ftid?: string
name?: string
lat?: number
lng?: number
}
export interface CopyToTripResult {
copied: number
skipped: { id: number; name: string }[]
}
/**
* Axios calls for the Collections addon (/api/addons/collections). Mirrors the
* vacayStore api shape — each method returns the unwrapped response body and
* uses `satisfies` on the request payloads so the shared Zod request types stay
* the single source of truth.
*/
export const collectionsApi = {
list: (): Promise<CollectionListResponse> =>
ax.get(base).then((r: AxiosResponse) => r.data),
get: (id: number): Promise<CollectionDetailResponse> =>
ax.get(`${base}/${id}`).then((r: AxiosResponse) => r.data),
create: (body: CollectionCreateRequest): Promise<{ collection: Collection }> =>
ax.post(base, body satisfies CollectionCreateRequest).then((r: AxiosResponse) => r.data),
update: (id: number, body: CollectionUpdateRequest): Promise<{ collection: Collection }> =>
ax.patch(`${base}/${id}`, body satisfies CollectionUpdateRequest).then((r: AxiosResponse) => r.data),
uploadCover: (id: number, formData: FormData): Promise<Collection> =>
ax.post(`${base}/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then((r: AxiosResponse) => r.data),
remove: (id: number): Promise<unknown> =>
ax.delete(`${base}/${id}`).then((r: AxiosResponse) => r.data),
reorder: (orderedIds: number[]): Promise<unknown> =>
ax.post(`${base}/reorder`, { orderedIds }).then((r: AxiosResponse) => r.data),
savePlace: (body: CollectionSavePlaceRequest): Promise<CollectionSaveResult> =>
ax.post(`${base}/places`, body satisfies CollectionSavePlaceRequest).then((r: AxiosResponse) => r.data),
saveFromTrip: (body: CollectionSaveFromTripRequest): Promise<CollectionSaveResult> =>
ax.post(`${base}/places/from-trip`, body satisfies CollectionSaveFromTripRequest).then((r: AxiosResponse) => r.data),
saveFromTripMany: (collectionId: number, tripId: number, placeIds: number[], force?: boolean): Promise<{ copied: number; skipped: { id: number; name: string }[] }> =>
ax.post(`${base}/places/from-trip-many`, { collection_id: collectionId, source_trip_id: tripId, source_place_ids: placeIds, force }).then((r: AxiosResponse) => r.data),
updatePlace: (pid: number, body: CollectionPlaceUpdateRequest): Promise<CollectionPlace> =>
ax.patch(`${base}/places/${pid}`, body satisfies CollectionPlaceUpdateRequest).then((r: AxiosResponse) => r.data),
setStatus: (pid: number, status: CollectionStatus): Promise<CollectionPlace> =>
ax.post(`${base}/places/${pid}/status`, { status }).then((r: AxiosResponse) => r.data),
deletePlace: (pid: number): Promise<unknown> =>
ax.delete(`${base}/places/${pid}`).then((r: AxiosResponse) => r.data),
deleteMany: (ids: number[]): Promise<unknown> =>
ax.post(`${base}/places/delete-many`, { ids }).then((r: AxiosResponse) => r.data),
copyToTrip: (body: CollectionCopyToTripRequest): Promise<CopyToTripResult> =>
ax.post(`${base}/copy-to-trip`, body satisfies CollectionCopyToTripRequest).then((r: AxiosResponse) => r.data),
membership: (params: MembershipQuery): Promise<CollectionMembership> =>
ax.get(`${base}/membership`, { params }).then((r: AxiosResponse) => r.data),
invite: (collectionId: number, userId: number, role?: CollectionRole): Promise<unknown> =>
ax.post(`${base}/invite`, { collection_id: collectionId, user_id: userId, role } satisfies CollectionInviteRequest).then((r: AxiosResponse) => r.data),
setMemberRole: (collectionId: number, userId: number, role: CollectionRole): Promise<unknown> =>
ax.post(`${base}/members/role`, { collection_id: collectionId, user_id: userId, role }).then((r: AxiosResponse) => r.data),
acceptInvite: (collectionId: number): Promise<unknown> =>
ax.post(`${base}/invite/accept`, { collection_id: collectionId } satisfies CollectionInviteActionRequest).then((r: AxiosResponse) => r.data),
declineInvite: (collectionId: number): Promise<unknown> =>
ax.post(`${base}/invite/decline`, { collection_id: collectionId } satisfies CollectionInviteActionRequest).then((r: AxiosResponse) => r.data),
cancelInvite: (collectionId: number, userId: number): Promise<unknown> =>
ax.post(`${base}/invite/cancel`, { collection_id: collectionId, user_id: userId } satisfies CollectionInviteCancelRequest).then((r: AxiosResponse) => r.data),
leave: (collectionId: number): Promise<unknown> =>
ax.post(`${base}/leave`, { collection_id: collectionId }).then((r: AxiosResponse) => r.data),
removeMember: (collectionId: number, userId: number): Promise<unknown> =>
ax.post(`${base}/members/remove`, { collection_id: collectionId, user_id: userId }).then((r: AxiosResponse) => r.data),
availableUsers: (id: number): Promise<{ users: { id: number; username: string }[] }> =>
ax.get(`${base}/${id}/available-users`).then((r: AxiosResponse) => r.data),
}
+228 -3
View File
@@ -4,10 +4,11 @@ import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
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, Plane, Bookmark,
}
function ImmichIcon({ size = 14 }: { size?: number }) {
@@ -298,7 +299,12 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
</span>
</div>
{integrationAddons.map(addon => (
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'llm_parsing' && addon.enabled && (
<LlmParsingConfig addon={addon} />
)}
</div>
))}
</div>
)}
@@ -309,6 +315,225 @@ 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 {
addon: Addon
onToggle: (addon: Addon) => void
+6 -6
View File
@@ -473,10 +473,10 @@ export default function BackupPanel() {
<AlertTriangle size={20} className="text-white" />
</div>
<div>
<h3 className="text-white" style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>
<h3 className="text-white" style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}>
{t('backup.restoreConfirmTitle')}
</h3>
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 12 }}>
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
{restoreConfirm.filename}
</p>
</div>
@@ -484,11 +484,11 @@ export default function BackupPanel() {
{/* Body */}
<div style={{ padding: '20px 24px' }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, margin: 0 }}>
{t('backup.restoreWarning')}
</p>
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))', 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"
>
{t('backup.restoreTip')}
@@ -500,14 +500,14 @@ export default function BackupPanel() {
<button
onClick={() => setRestoreConfirm(null)}
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
<button
onClick={executeRestore}
className="bg-[#dc2626] text-white"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
>
@@ -7,7 +7,16 @@ import Section from '../Settings/Section'
import CustomSelect from '../shared/CustomSelect'
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 = [
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
@@ -19,6 +28,7 @@ const MAP_PRESETS = [
type Defaults = {
temperature_unit?: string
distance_unit?: DistanceUnit
dark_mode?: string | boolean
time_format?: string
default_currency?: string
@@ -27,18 +37,22 @@ type Defaults = {
map_provider?: string
mapbox_access_token?: string
mapbox_style?: string
maplibre_style?: string
mapbox_3d_enabled?: boolean
mapbox_quality_mode?: boolean
}
const MAPBOX_STYLE_PRESETS = [
{ name: 'Standard', url: 'mapbox://styles/mapbox/standard' },
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12' },
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12' },
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
]
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({
label,
@@ -75,7 +89,7 @@ function OptionButton({
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
@@ -98,10 +112,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
useEffect(() => {
adminApi.getDefaultUserSettings().then((data: Defaults) => {
const provider = normalizeProvider(data.map_provider)
setDefaults(data)
setMapTileUrl(data.map_tile_url || '')
setMapboxToken(data.mapbox_access_token || '')
setMapboxStyle(data.mapbox_style || '')
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
setLoaded(true)
}).catch(() => setLoaded(true))
}, [])
@@ -122,7 +137,10 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
setDefaults(updated)
if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'mapbox_style') setMapboxStyle('')
if (key === 'mapbox_style' || key === 'maplibre_style') {
const provider = normalizeProvider(defaults.map_provider)
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
}
toast.success(t('admin.defaultSettings.reset'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error'))
@@ -168,10 +186,24 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
}], [])
if (!loaded) {
return <p className="text-content-faint" style={{ fontSize: 12, fontStyle: 'italic', padding: 16 }}>Loading</p>
return <p className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontStyle: 'italic', padding: 16 }}>Loading</p>
}
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 (
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
@@ -212,6 +244,22 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
))}
</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 */}
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
{([
@@ -316,19 +364,21 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{([
{ 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={(defaults.map_provider || 'leaflet') === opt.value}
onClick={() => save({ map_provider: opt.value })}
active={mapProvider === opt.value}
onClick={() => saveMapProvider(opt.value)}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{defaults.map_provider === 'mapbox-gl' && (
{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')}
@@ -346,17 +396,18 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
/>
<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="mapbox_style" />
<ResetButton field={styleKey} />
</label>
<CustomSelect
value={mapboxStyle}
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ mapbox_style: value }) } }}
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ [styleKey]: value }) } }}
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
options={MAPBOX_STYLE_PRESETS.map(p => ({ value: p.url, label: p.name }))}
options={glStylePresets.map(p => ({ value: p.url, label: p.name }))}
size="sm"
style={{ marginBottom: 8 }}
/>
@@ -364,12 +415,18 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
type="text"
value={mapboxStyle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
onBlur={() => save({ mapbox_style: mapboxStyle })}
placeholder="mapbox://styles/mapbox/standard"
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' },
@@ -391,6 +448,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</OptionButton>
))}
</OptionRow>
</>
)}
</div>
)}
</div>
@@ -0,0 +1,163 @@
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' }}>
<Calculator size={28} color="#6b7280" />
</div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
<p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
<h2 style={{ fontSize: 'calc(20px * var(--fs-scale-title, 1))', 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>
{canEdit && (
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
placeholder={t('budget.emptyPlaceholder')}
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 }} />
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 }} />
<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 }}>
<Plus size={16} />
@@ -65,7 +65,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
padding: '14px 16px 14px 22px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap',
}}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
<h2 style={{ margin: 0, fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
{t('budget.title')}
</h2>
<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)}
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
placeholder={t('budget.categoryName')}
style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
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)' }}
/>
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
title={t('budget.addCategory')}
style={{
appearance: 'none', border: 'none', cursor: newCategoryName.trim() ? 'pointer' : 'default', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
opacity: newCategoryName.trim() ? 1 : 0.4,
transition: 'opacity 0.15s ease',
@@ -105,7 +105,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
transition: 'opacity 0.15s ease',
}}
@@ -23,7 +23,7 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
setTimeout(() => nameRef.current?.focus(), 50)
}
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)' }
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)' }
return (
<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()}
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
</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: 12, textAlign: 'center' }}>-</td>
<td className="hidden lg: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: 'calc(12px * var(--fs-scale-body, 1))', 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 sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
<div style={{ maxWidth: 90, margin: '0 auto' }}>
<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 })}
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) }}
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%' }}
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%' }}
/>
) : (
<>
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
<span style={{ fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{cat}</span>
{canEdit && (
<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 }}
@@ -119,7 +119,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
{canEdit && (
<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 }}
@@ -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 />
</div>
) : (
<span style={{ fontSize: 11, color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span>
)}
</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}
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) } }}
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 }}
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 }}
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}
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',
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', ...style }}
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
{display || placeholder || '-'}
@@ -7,6 +7,7 @@ export interface TripMember {
id: number
username: string
avatar_url?: string | null
is_guest?: boolean
}
// ── Chip with custom tooltip ─────────────────────────────────────────────────
@@ -56,13 +57,13 @@ export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }:
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
display: 'flex', alignItems: 'center', gap: 5,
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
}}>
{label}
{paid && (
<span style={{
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, padding: '1px 5px', borderRadius: 4,
background: 'rgba(34,197,94,0.15)', color: '#16a34a',
textTransform: 'uppercase', letterSpacing: '0.03em',
}}>Paid</span>
@@ -151,14 +152,14 @@ export default function BudgetMemberChips({ members = [], tripMembers = [], onSe
<button key={tm.id} onClick={() => toggleMember(tm.id)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', textAlign: 'left',
fontFamily: 'inherit', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-primary)', textAlign: 'left',
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }}
>
<div style={{
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 700,
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
}}>
{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' }}>
<RingAvatar userId={p.user_id} username={p.username} avatarUrl={p.avatar_url} size={34} innerBg={theme.centerBg} textColor={theme.text} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text }}>{p.username}</div>
<div style={{ fontSize: 11, color: theme.faint, marginTop: 1 }}>{percent}%</div>
<div style={{ fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', 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>
<div style={{ fontSize: 13.5, fontWeight: 600, color: theme.text, letterSpacing: '-0.01em' }}>{fmt(p.total_assigned)}</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>
)
})}
@@ -46,7 +46,7 @@ export default function PieChart({ segments, size = 200, totalLabel }: PieChartP
boxShadow: 'inset 0 0 12px rgba(0,0,0,0.04)',
}}>
<Wallet size={18} color="var(--text-faint)" style={{ marginBottom: 2 }} />
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span>
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span>
</div>
</div>
)
@@ -47,7 +47,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
<Wallet size={20} strokeWidth={2} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</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, '']
return (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}>
<span style={{ fontSize: 38, fontWeight: 700 }}>{integerPart}</span>
{decimalPart && <span style={{ fontSize: 22, fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>}
<span style={{ fontSize: 22, fontWeight: 500, color: theme.sub, marginLeft: 2 }}>{SYMBOLS[currency] || currency}</span>
<span style={{ fontSize: 'calc(38px * var(--fs-scale-title, 1))', fontWeight: 700 }}>{integerPart}</span>
{decimalPart && <span style={{ fontSize: 'calc(22px * var(--fs-scale-title, 1))', 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>
</div>
)
})()}
<div style={{ color: theme.faint, fontSize: 12, marginTop: 8, fontWeight: 500, letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 6 }}>
<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 }}>
<span>{currency}</span>
</div>
@@ -78,7 +78,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
<button onClick={() => setSettlementOpen(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
color: theme.sub, fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
color: theme.sub, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, letterSpacing: 0.5,
}}>
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{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,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
fontSize: 11, fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
}}>
{t('budget.settlementInfo')}
</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} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}>
{fmt(flow.amount, currency)}
</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' }}>
@@ -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 && (
<div style={{ marginTop: 8, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}>
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}>
{t('budget.netBalances')}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
@@ -140,13 +140,13 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
return (
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 0' }}>
<RingAvatar userId={b.user_id} username={b.username} avatarUrl={b.avatar_url} size={26} innerBg={theme.centerBg} textColor={theme.text} />
<span style={{ flex: 1, fontSize: 13, color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<span style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{b.username}
</span>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '4px 10px', borderRadius: 8,
fontSize: 12, fontWeight: 700, letterSpacing: '-0.01em',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, letterSpacing: '-0.01em',
background: positive ? 'rgba(16,185,129,0.13)' : 'rgba(239,68,68,0.13)',
color: positive ? '#10b981' : '#ef4444',
}}>
@@ -192,7 +192,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
<PieChartIcon size={18} strokeWidth={2} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div>
</div>
</div>
@@ -226,12 +226,12 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
})}
</svg>
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, pointerEvents: 'none' }}>
<div style={{ fontSize: 10.5, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{t('budget.total')}</div>
<div style={{ fontSize: 22, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
<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: 'calc(22px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
<span>{totalInt}</span>
{totalDec && <span style={{ fontSize: 13, fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
{totalDec && <span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
</div>
<div style={{ fontSize: 10.5, color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div>
<div style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div>
</div>
</div>
@@ -256,13 +256,13 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
boxShadow: `0 0 12px ${seg.color}80`,
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{seg.name}</div>
<div style={{ fontSize: 11.5, color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div>
<div 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: 'calc(11.5px * var(--fs-scale-caption, 1))', color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div>
</div>
<span style={{
flexShrink: 0,
padding: '4px 9px', borderRadius: 7,
fontSize: 11, fontWeight: 700, letterSpacing: '-0.01em',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, letterSpacing: '-0.01em',
background: `${seg.color}26`,
border: `1px solid ${seg.color}40`,
color: chipColor,
@@ -91,7 +91,7 @@ describe('CostsPanel — settlements in the ledger', () => {
expect(screen.getByText('Dinner')).toBeInTheDocument()
})
it('auto-splits the total across participants and rebalances a pinned amount on save', async () => {
it('supports custom split amounts on save', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
@@ -108,18 +108,22 @@ describe('CostsPanel — settlements in the ledger', () => {
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'))
await user.type(nums()[0], '100') // total = 100
await user.click(screen.getByRole('button', { name: /Custom/i }))
const customInputs = screen.getAllByPlaceholderText('50.00')
await user.type(customInputs[0], '30')
await user.type(customInputs[1], '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(posted!.payers).toEqual([
expect.objectContaining({ amount: 100 })
])
expect(posted!.members).toEqual(expect.arrayContaining([
expect.objectContaining({ user_id: 1, amount: 30 }),
expect.objectContaining({ user_id: 2, amount: 70 }),
]))
@@ -194,4 +198,60 @@ describe('CostsPanel — settlements in the ledger', () => {
expect(posted!.member_ids).toEqual([])
expect(posted!.payers).toEqual([])
})
it('supports itemized receipt ticket manual entry and split assignment', 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: 10 } })
}),
)
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')
await user.click(screen.getByRole('button', { name: 'Ticket' }))
const addBtn = screen.getByRole('button', { name: /Add item/i })
await user.click(addBtn)
await user.click(addBtn)
await user.click(addBtn)
const itemNames = screen.getAllByPlaceholderText('Item name')
const itemPrices = screen.getAllByPlaceholderText('0.00')
await user.type(itemNames[0], 'Apples')
await user.type(itemPrices[1], '10')
await user.type(itemNames[1], 'chocolate cake')
await user.type(itemPrices[2], '50')
const bobButtons = screen.getAllByRole('button', { name: /bob/i })
await user.click(bobButtons[1])
await user.type(itemNames[2], 'Milk')
await user.type(itemPrices[3], '40')
expect(screen.getByDisplayValue('100.00')).toBeDisabled()
expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument()
expect(screen.getByText(/75\.00/)).toBeInTheDocument()
expect(screen.getByText(/25\.00/)).toBeInTheDocument()
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1])
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(100)
expect(posted!.members).toEqual(expect.arrayContaining([
expect.objectContaining({ user_id: 1, amount: 75 }),
expect.objectContaining({ user_id: 2, amount: 25 }),
]))
expect(posted!.note).toContain('TICKETJSON:')
})
})
+454 -161
View File
@@ -18,6 +18,69 @@ import { SYMBOLS, CURRENCIES, SPLIT_COLORS } from './BudgetPanel.constants'
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
import type { BudgetItem } from '../../types'
import type { TripMember } from './BudgetPanelMemberChips'
import GuestBadge from '../shared/GuestBadge'
export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record<number, number> {
const n = members.length
if (n === 0) return {}
const totalCents = Math.round(total * 100)
const baseCents = Math.floor(totalCents / n)
const remainder = totalCents % n
const shares: Record<number, number> = {}
const sortedMembers = [...members].sort((a, b) => a.user_id - b.user_id)
const startIndex = itemId % n
for (let i = 0; i < n; i++) {
const member = sortedMembers[i]
const hasExtraCent = ((i - startIndex + n) % n) < remainder
shares[member.user_id] = (baseCents + (hasExtraCent ? 1 : 0)) / 100
}
return shares
}
export interface TicketItem {
id: string
name: string
price: string
participants: Set<number>
}
export function calculateTicketShares(items: TicketItem[]): { shares: Record<number, number>; total: number } {
const shares: Record<number, number> = {}
let totalCents = 0
for (const item of items) {
const priceNum = parseFloat(item.price) || 0
const priceCents = Math.round(priceNum * 100)
totalCents += priceCents
const partIds = [...item.participants]
const n = partIds.length
if (n === 0) continue
const baseCents = Math.floor(priceCents / n)
const remainder = priceCents % n
const sortedPartIds = [...partIds].sort((a, b) => a - b)
for (let i = 0; i < n; i++) {
const id = sortedPartIds[i]
const hasExtraCent = i < remainder
const shareCents = baseCents + (hasExtraCent ? 1 : 0)
shares[id] = (shares[id] || 0) + shareCents
}
}
const finalShares: Record<number, number> = {}
for (const id of Object.keys(shares)) {
finalShares[Number(id)] = shares[Number(id)] / 100
}
return { shares: finalShares, total: totalCents / 100 }
}
interface CostsPanelProps {
tripId: number
@@ -105,9 +168,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e))
const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0)
const myShareOf = (e: BudgetItem) => {
const n = (e.members || []).length
if (!n || !(e.members || []).some(m => m.user_id === me)) return 0
return baseTotal(e) / n
const myMember = (e.members || []).find(m => m.user_id === me)
if (!myMember) return 0
if (myMember.amount !== null && myMember.amount !== undefined) {
return convert(myMember.amount, curOf(e))
}
const shares = splitEqualShares(e.total_price || 0, e.members || [], e.id)
const myShare = shares[me] || 0
return convert(myShare, curOf(e))
}
const totals = useMemo(() => {
@@ -223,17 +291,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', gap: 10, flexWrap: 'wrap' }}>
{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: 13, 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: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, whiteSpace: 'nowrap' }}>
{dateMeta.range} · <b className="text-content">{t('costs.daysCount', { count: dateMeta.days })}</b>
</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: 13, 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: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>
<span style={{ display: 'inline-flex' }}>
{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
return p.avatar_url
? <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: 9, 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: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>
})}
</span>
<b className="text-content">{t('costs.travelers', { count: people.length })}</b>
@@ -243,12 +311,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={settleAll} disabled={!(settlement?.flows || []).length}
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: 14, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
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' }}>
<Check size={16} /> {t('costs.settleUp')}
</button>
<button onClick={() => { setEditing(null); setModalOpen(true) }}
className="bg-[var(--text-primary)] text-[var(--bg-primary)]"
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 18px', borderRadius: 12, fontSize: 14, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
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' }}>
<Plus size={16} /> {t('costs.addExpense')}
</button>
</div>
@@ -277,20 +345,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{/* expenses */}
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, gap: 12, flexWrap: 'wrap' }}>
<h3 className="text-content" style={{ fontSize: 24, fontWeight: 600, letterSpacing: '-0.025em', margin: 0 }}>
<h3 className="text-content" style={{ fontSize: 'calc(24px * var(--fs-scale-title, 1))', fontWeight: 600, letterSpacing: '-0.025em', margin: 0 }}>
{t('costs.expenses')}
</h3>
<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 }}>
<Search size={15} 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: 13, width: 150, fontFamily: 'inherit' }} />
className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', width: 150, fontFamily: 'inherit' }} />
</div>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
{(['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={{ padding: '6px 11px', fontSize: 12, borderRadius: 7, fontWeight: 500, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
style={{ padding: '6px 11px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 7, fontWeight: 500, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.filter.' + f)}
</button>
))}
@@ -307,7 +375,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
return (
<div key={g.day} style={{ marginBottom: 22 }}>
<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: 12 }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
{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>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{g.entries.map(en => en.kind === 'expense'
@@ -328,7 +396,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{canEdit && (
<button onClick={() => setAddingPayment(true)}
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' }}>
<Plus size={13} /> {t('costs.addPayment')}
</button>
)}
@@ -407,8 +475,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
if (flows.length === 0) return (
<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 className="text-content" style={{ fontSize: 14.5, fontWeight: 600 }}>{t('costs.everyoneSquare')}</div>
<div className="text-content-faint" style={{ fontSize: 12, marginTop: 2 }}>{t('costs.nothingOutstanding')}</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-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 2 }}>{t('costs.nothingOutstanding')}</div>
</div>
)
return (
@@ -419,8 +487,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} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<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: 12, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>{t('costs.settle')}</button>}
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', 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>}
</div>
</div>
))}
@@ -434,14 +502,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, paddingTop: 8 }}>
{/* 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)' }}>
<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: 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: 12, color: 'rgba(255,255,255,0.6)', flexWrap: 'wrap' }}>
<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: '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={{ display: 'flex', gap: 18, marginTop: 12, fontSize: 'calc(12px * var(--fs-scale-body, 1))', 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.youPaid')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myPaid)}</b></span>
</div>
{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: 14, 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: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={17} /> {t('costs.addExpense')}
</button>
)}
@@ -451,24 +519,24 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<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 className="text-content" style={{ fontSize: 12.5, fontWeight: 600 }}>{t('costs.youOwe')}</div>
<div className="text-content-faint" style={{ fontSize: 10.5 }}>{t('costs.youOweSub')}</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 className="text-content" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', 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 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>
<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 className="text-content" style={{ fontSize: 12.5, fontWeight: 600 }}>{t('costs.youreOwed')}</div>
<div className="text-content-faint" style={{ fontSize: 10.5 }}>{t('costs.youreOwedSub')}</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 className="text-content" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', 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 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>
</div>
{/* Settle up */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
<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>
<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>
{canEdit && (
<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: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</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>
<SettleFlows />
@@ -476,23 +544,23 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{/* Expenses */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>{t('costs.expenses')}</div>
<div className="text-content" style={{ fontSize: 'calc(19px * var(--fs-scale-subtitle, 1))', 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 }}>
<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: 14, 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: 'calc(14px * var(--fs-scale-body, 1))', width: '100%', fontFamily: 'inherit' }} />
</div>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 11, padding: 3, gap: 2 }}>
{(['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: 12.5, 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: '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>
))}
</div>
{dayGroups.length === 0
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 13 }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
? <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>
: dayGroups.map(g => {
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
return (
<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: 11.5 }}>{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: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.entries.map(en => en.kind === 'expense'
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}</div>
@@ -531,15 +599,15 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<span style={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}>
<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: 12, fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
<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={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
<span className="text-content" style={{ fontSize: 15, fontWeight: 600 }}>{e.name}</span>
<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: 11, fontWeight: 700, flexShrink: 0 }}>
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 10, fontWeight: 800 }}>!</span>
<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>
)}
@@ -547,7 +615,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{payers.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
{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: 11.5 }}>
<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))' }}>
<Avatar id={p.user_id} size={18} />
<span className="text-content" style={{ fontWeight: 700 }}>{fmt(convert(p.amount, cur))}</span>
</span>
@@ -555,16 +623,16 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
)}
{!isMobile && (
<div className="text-content-faint" style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{t(c.labelKey)}{cur !== base ? ` · ${fmt(e.total_price, cur)}${fmt(baseTotal(e))}` : ''}
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<div className="text-content" style={{ fontSize: 18, fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
<div className="text-content" style={{ fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
{!isUnfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && (
<div style={{ fontSize: 12, marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', 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) })}
</div>
)}
@@ -587,14 +655,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<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: 15, fontWeight: 600, marginBottom: 6 }}>{t('costs.payment')}</div>
<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: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{personName(s.from_user_id)} {personName(s.to_user_id)}</span>
<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: 18, fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(s.amount)}</div>
<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>
@@ -618,14 +686,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div key={r.user_id} style={{ display: 'grid', gridTemplateColumns: '28px 1fr auto', gap: 10, alignItems: 'center' }}>
<Avatar id={r.user_id} size={28} />
<div>
<div className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{personName(r.user_id)}</div>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{personName(r.user_id)}</div>
<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)' }} />
{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 }} />}
</div>
</div>
<div style={{ fontSize: 13, fontWeight: 600, textAlign: 'right', color: pos ? '#16a34a' : neg ? '#dc2626' : 'var(--text-faint)' }}>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, textAlign: 'right', color: pos ? '#16a34a' : neg ? '#dc2626' : 'var(--text-faint)' }}>
{pos ? '+' + fmt(r.balance) : neg ? '' + fmt(-r.balance) : fmt(0)}
</div>
</div>
@@ -639,7 +707,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const tot: Record<string, number> = {}
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + 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))
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>{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))
@@ -650,8 +718,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
return (
<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 className="text-content" style={{ fontSize: 13, fontWeight: 500 }}>{t(c.labelKey)}</span>
<span className="text-content-muted" style={{ fontSize: 13, fontWeight: 600 }}>{fmt0(v)}</span>
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', 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>
<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 }} />
</div>
@@ -682,16 +750,16 @@ function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }:
<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>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }} className={total ? '' : 'text-content'}>{label}</div>
<div style={{ fontSize: 12, opacity: total ? 0.6 : 1 }} className={total ? '' : 'text-content-faint'}>{sub}</div>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', 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>
</div>
<div style={{ fontSize: 46, fontWeight: 600, letterSpacing: '-0.035em', lineHeight: 1, marginTop: 20, display: 'flex', alignItems: 'baseline', color: total ? '#fff' : accent }}>
<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 }}>
{parts
? parts.map((p, i) => <span key={i} style={big(p) ? undefined : { fontSize: 26, fontWeight: 500, color: muted }}>{p.value}</span>)
? 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>)
: <span>{formatMoney(amount, currency, locale)}</span>}
</div>
<div style={{ marginTop: 16, fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', opacity: total ? 0.85 : 1 }}>{foot}</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>
)
}
@@ -702,7 +770,7 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span className="text-content-faint">{lead}</span>
{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: 12, 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: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}>
<Avatar id={id} size={18} />{name(id)}
</span>
))}
@@ -746,8 +814,8 @@ function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
<Modal isOpen onClose={onClose} title={editing ? t('costs.editPayment') : t('costs.addPayment')} size="md"
footer={
<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: 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: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addPayment')}</button>
<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 }}>
@@ -762,7 +830,7 @@ function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
<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: 14, outline: 'none', fontWeight: 600 }} />
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>
</Modal>
@@ -790,11 +858,6 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
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.
// 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)
@@ -802,89 +865,192 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
})
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>>(() => {
// Payer state: 0 represents "Nobody (planning entry)"
const [payerId, setPayerId] = useState<number>(() => {
const existingPayer = (editing?.payers || []).find(p => p.amount > 0)
return existingPayer ? existingPayer.user_id : me
})
const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => {
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
return 'ticket'
}
if (editing && editing.members && editing.members.length > 0) {
const hasCustom = editing.members.some(m => m.amount !== null && m.amount !== undefined)
return hasCustom ? 'custom' : 'equally'
}
return 'equally'
})
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => {
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
try {
const parsed = JSON.parse(editing.note.slice(11))
return (parsed.items || []).map((item: any) => ({
id: String(Math.random()),
name: item.name,
price: String(item.price),
participants: new Set(item.parts || [])
}))
} catch {
return []
}
}
return []
})
const [customAmounts, setCustomAmounts] = useState<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)
if (editing && editing.members) {
for (const member of editing.members) {
if (member.amount !== null && member.amount !== undefined) {
m[member.user_id] = String(member.amount)
}
}
}
return m
})
// Amounts the user pinned by typing — kept out of the auto-rebalance. Existing
// payer amounts load as pinned so opening an expense never reshuffles them.
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 totalNum = parseFloat(total) || 0
const paidSum = round2([...participants].reduce((a, id) => a + (parseFloat(paid[id]) || 0), 0))
const paidEntered = paidSum > 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)
const isTicketMode = splitMode === 'ticket'
// 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 ticketInfo = useMemo(() => {
return calculateTicketShares(ticketItems)
}, [ticketItems])
const totalNum = isTicketMode ? ticketInfo.total : (parseFloat(total) || 0)
const splitSum = [...participants].reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0)
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
const each = participants.size > 0 ? totalNum / participants.size : 0
const equalShares = useMemo(() => {
return splitEqualShares(totalNum, [...participants].map(id => ({ user_id: id })), editing?.id || 0)
}, [totalNum, participants, editing])
const placeholderShares = useMemo(() => {
const emptyParts = [...participants].filter(id => !customAmounts[id])
if (emptyParts.length === 0) return {}
const enteredSum = [...participants]
.filter(id => customAmounts[id])
.reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0)
const remaining = Math.max(0, totalNum - enteredSum)
return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0)
}, [totalNum, participants, customAmounts, editing])
const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (parseFloat(item.price) || 0) > 0 && item.participants.size > 0)
const valid = name.trim().length > 0 && (
isTicketMode
? ticketValid
: totalNum > 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced)
)
const onTotalChange = (v: string) => {
v = v.replace(',', '.')
setTotal(v)
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
setTotal(v.replace(',', '.'))
}
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 handleCustomAmountChange = (id: number, val: string) => {
val = val.replace(',', '.')
if (/^\d*\.?\d{0,2}$/.test(val) || val === '') {
setCustomAmounts(prev => ({ ...prev, [id]: val }))
}
}
const handleAddEmptyItem = () => {
setTicketItems(prev => [
...prev,
{
id: String(Date.now() + Math.random()),
name: '',
price: '',
participants: new Set(people.map(p => p.id))
}
])
}
const handleUpdateItemName = (id: string, name: string) => {
setTicketItems(prev => prev.map(item => item.id === id ? { ...item, name } : item))
}
const handleUpdateItemPrice = (id: string, price: string) => {
price = price.replace(',', '.')
if (/^\d*\.?\d{0,2}$/.test(price) || price === '') {
setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item))
}
}
const handleRemoveItem = (id: string) => {
setTicketItems(prev => prev.filter(item => item.id !== id))
}
const handleToggleItemParticipant = (itemId: string, userId: number) => {
setTicketItems(prev => prev.map(item => {
if (item.id === itemId) {
const nextParts = new Set(item.participants)
if (nextParts.has(userId)) nextParts.delete(userId)
else nextParts.add(userId)
return { ...item, participants: nextParts }
}
return item
}))
}
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 nextParts = new Set(participants)
if (nextParts.has(id)) {
nextParts.delete(id)
setCustomAmounts(prev => {
const copy = { ...prev }
delete copy[id]
return copy
})
} else {
nextParts.add(id)
}
setParticipants(nextParts)
}
const save = async () => {
if (!valid) return
setSaving(true)
const payerList = [...participants]
.map(id => ({ user_id: id, amount: parseFloat(paid[id]) || 0 }))
.filter(p => p.amount > 0)
const payerList = (payerId > 0 && participants.size > 0) ? [{ user_id: payerId, amount: totalNum }] : []
const memberList = [...participants].map(id => ({
user_id: id,
amount: splitMode === 'custom'
? (parseFloat(customAmounts[id]) || 0)
: splitMode === 'ticket'
? (ticketInfo.shares[id] || 0)
: null
}))
const data = {
name: name.trim(), category: cat,
// Store the actual currency the amounts were entered in; conversion to the
// viewer's display currency happens live (real rates), no manual rate.
name: name.trim(),
category: cat,
currency,
payers: payerList, member_ids: [...participants],
payers: payerList,
members: memberList,
member_ids: [...participants],
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).
note: splitMode === 'ticket' ? 'TICKETJSON:' + JSON.stringify({
items: ticketItems.map(item => ({
name: item.name,
price: item.price,
parts: [...item.participants]
}))
}) : null,
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
}
try {
if (editing) await updateBudgetItem(tripId, editing.id, data)
else await addBudgetItem(tripId, data)
onSaved()
} catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
} catch {
toast.error(t('common.unknownError'))
} finally {
setSaving(false)
}
}
const inputCls = 'w-full bg-surface-input border border-edge text-content'
@@ -894,23 +1060,24 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<Modal isOpen onClose={onClose} title={editing ? t('costs.editExpense') : t('costs.addExpense')} size="2xl"
footer={
<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: 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: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addExpense')}</button>
<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.addExpense')}</button>
</div>
}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<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: 14, outline: 'none' }} />
<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' }} />
</div>
<div>
<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' }}>
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
<input type="text" inputMode="decimal" placeholder="0.00" value={total}
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px', opacity: isTicketMode ? 0.6 : 1 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))' }}>{sym(currency)}</span>
<input type="text" inputMode="decimal" placeholder="0.00" value={isTicketMode ? ticketInfo.total.toFixed(2) : total}
onChange={e => onTotalChange(e.target.value)}
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
disabled={isTicketMode}
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 style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
@@ -927,7 +1094,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div>
{currency !== base && totalNum > 0 && (
<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' }}>
<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' }}>
<span>{formatMoney(totalNum, currency, locale)}</span>
<span className="text-content-faint"></span>
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(totalNum, currency), base, locale)}</span>
@@ -943,7 +1110,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
return (
<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'}
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 }}>
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 }}>
<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)}
</button>
@@ -954,39 +1121,165 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<div>
<label className={labelCls}>{t('costs.whoPaid')}</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{people.map((p, idx) => {
const on = participants.has(p.id)
return (
<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 }}>
<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' }}>
{p.avatar_url
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
<span className="text-content" style={{ fontSize: 14, 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: 13 }}>{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: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
<CustomSelect value={String(payerId)} onChange={v => setPayerId(Number(v))}
options={[
{ value: '0', label: t('costs.noOnePaid') || 'Nobody (planning entry)' },
...people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
]}
style={{ width: '100%' }} />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<label className={labelCls}>{t('costs.split') || 'Split'}</label>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2 }}>
<button type="button" onClick={() => setSplitMode('equally')}
className={splitMode === 'equally' ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.splitEqually') || 'Equally'}
</button>
<button type="button" onClick={() => setSplitMode('custom')}
className={splitMode === 'custom' ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.splitCustom') || 'Custom'}
</button>
<button type="button" onClick={() => setSplitMode('ticket')}
className={splitMode === 'ticket' ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.splitTicket') || 'Ticket'}
</button>
</div>
</div>
{splitMode === 'ticket' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{ticketItems.map((item, itemIdx) => (
<div key={item.id} className="bg-surface-secondary border border-edge" style={{ padding: 10, borderRadius: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="text"
placeholder="Item name"
value={item.name}
onChange={e => handleUpdateItemName(item.id, e.target.value)}
className="bg-surface-input border border-edge text-content"
style={{ flex: 2, padding: '6px 10px', borderRadius: 8, fontSize: 13, border: '1px solid var(--border-color)', outline: 'none' }}
/>
<div className="bg-surface-input border border-edge" style={{ flex: 1, display: 'flex', alignItems: 'center', padding: '0 8px', borderRadius: 8 }}>
<span className="text-content-faint" style={{ fontSize: 12 }}>{sym(currency)}</span>
<input
type="text"
inputMode="decimal"
placeholder="0.00"
value={item.price}
onChange={e => handleUpdateItemPrice(item.id, e.target.value)}
className="text-content"
style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 13, fontWeight: 600, textAlign: 'right', padding: '6px 0' }}
/>
</div>
<button type="button" onClick={() => handleRemoveItem(item.id)} className="text-content-muted" style={{ background: 'none', border: 0, cursor: 'pointer', padding: 4 }}>
<Trash2 size={15} />
</button>
</div>
) : (
<button onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, textAlign: 'right' }}>{t('costs.tapToInclude')}</button>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
<span className="text-content-faint" style={{ fontSize: 10.5, fontWeight: 600, textTransform: 'uppercase', marginRight: 4 }}>Splitting:</span>
{people.map((p, pIdx) => {
const active = item.participants.has(p.id)
return (
<button
type="button"
key={p.id}
onClick={() => handleToggleItemParticipant(item.id, p.id)}
className={active ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-muted border border-edge'}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 999, fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', border: active ? '1px solid var(--text-primary)' : undefined }}
>
{p.avatar_url
? <img src={p.avatar_url} alt="" style={{ width: 14, height: 14, borderRadius: '50%', objectFit: 'cover' }} />
: <span style={{ width: 14, height: 14, borderRadius: '50%', background: SPLIT_COLORS[pIdx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 7, fontWeight: 700 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
<span>{p.id === me ? t('costs.you') : p.username}</span>
</button>
)
})}
</div>
</div>
))}
</div>
<button type="button" onClick={handleAddEmptyItem} className="border border-dashed border-edge text-content-muted" style={{ padding: '8px 12px', borderRadius: 10, background: 'none', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<Plus size={14} /> Add item
</button>
{ticketItems.length > 0 && (
<div className="bg-surface-secondary border border-edge" style={{ padding: 12, borderRadius: 10 }}>
<div className="text-content" style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>Individual Shares Summary</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{people.map(p => {
const share = ticketInfo.shares[p.id] || 0
return (
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
<span className="text-content-muted">{p.id === me ? t('costs.you') : p.username}</span>
<span className="text-content" style={{ fontWeight: 600 }}>{sym(currency)}{share.toFixed(2)}</span>
</div>
)
})}
</div>
</div>
)
})}
</div>
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
<span className="text-content-faint">
{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 style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{people.map((p, idx) => {
const on = participants.has(p.id)
return (
<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 }}>
<button type="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' }}>
{p.avatar_url
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
<span className="text-content" style={{ fontSize: 14, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span>
{p.is_guest && <GuestBadge size="xs" />}
</button>
{splitMode === 'equally' ? (
on ? (
<span className="text-content" style={{ fontSize: 14, fontWeight: 600, textAlign: 'right', paddingRight: 10 }}>
{sym(currency)}{(equalShares[p.id] || 0).toFixed(2)}
</span>
) : (
<span className="text-content-faint" style={{ fontSize: 12, textAlign: 'right', paddingRight: 10 }}>Excluded</span>
)
) : (
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: 13 }}>{sym(currency)}</span>
<input type="text" inputMode="decimal" placeholder={(placeholderShares[p.id] || 0).toFixed(2)} value={customAmounts[p.id] || ''}
onChange={e => handleCustomAmountChange(p.id, e.target.value)}
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
</div>
) : (
<button type="button" onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, textAlign: 'right' }}>{t('costs.tapToInclude')}</button>
)
)}
</div>
)
})}
</div>
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
{splitMode === 'equally' ? (
<span className="text-content-faint">
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
</span>
) : (
<span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}>
{customBalanced
? 'Split matches total'
: `Sum of splits: ${sym(currency)}${splitSum.toFixed(2)} of ${sym(currency)}${totalNum.toFixed(2)} (${(totalNum - splitSum) > 0 ? 'under by' : 'over by'} ${sym(currency)}${Math.abs(totalNum - splitSum).toFixed(2)})`}
</span>
)}
</div>
</>
)}
</div>
</div>
</Modal>
@@ -647,7 +647,7 @@ describe('CollabChat', () => {
let foundBigEmoji = false;
while (el) {
const styleAttr = el.getAttribute('style');
if (styleAttr && styleAttr.includes('font-size: 40px')) {
if (styleAttr && styleAttr.includes('font-size: calc(40px')) {
foundBigEmoji = true;
break;
}
+2 -2
View File
@@ -33,7 +33,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
<div style={{
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8,
padding: '6px 10px', borderRadius: 10, background: 'var(--bg-secondary)',
borderLeft: '3px solid #007AFF', fontSize: 12, color: 'var(--text-muted)',
borderLeft: '3px solid #007AFF', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)',
}}>
<Reply size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
@@ -67,7 +67,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
disabled={!canEdit}
style={{
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
padding: '8px 14px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', lineHeight: 1.4, fontFamily: 'inherit',
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
maxHeight: 100, overflowY: 'hidden',
opacity: canEdit ? 1 : 0.5,
@@ -49,7 +49,7 @@ export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: Emoj
<button key={c} onClick={() => setCat(c)} style={{
flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer',
background: cat === c ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-primary)', fontSize: 10, fontWeight: 600, fontFamily: 'inherit',
color: 'var(--text-primary)', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: 'inherit',
}}>
{c}
</button>
@@ -45,17 +45,17 @@ export function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
)}
<div style={{ padding: '8px 10px' }}>
{domain && (
<div style={{ fontSize: 10, fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}>
<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 }}>
{data.site_name || domain}
</div>
)}
{data.title && (
<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' }}>
<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' }}>
{data.title}
</div>
)}
{data.description && (
<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' }}>
<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' }}>
{data.description}
</div>
)}
@@ -14,8 +14,8 @@ export function ChatMessages(props: any) {
{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' }}>
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
<span style={{ fontSize: 12, opacity: 0.6, fontFamily: 'var(--font-subtext)' }}>{t('collab.chat.emptyDesc') || ''}</span>
<span style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', 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>
</div>
) : (
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
@@ -25,7 +25,7 @@ export function ChatMessages(props: any) {
{hasMore && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}>
<button onClick={handleLoadMore} disabled={loadingMore} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600,
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
}}>
@@ -51,13 +51,13 @@ export function ChatMessages(props: any) {
<React.Fragment key={msg.id}>
{showDate && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
<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' }}>
{formatDateSeparator(msg.created_at, t)}
</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontStyle: 'italic' }}>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontStyle: 'italic' }}>
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)}
</span>
</div>
@@ -76,7 +76,7 @@ export function ChatMessages(props: any) {
{showDate && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
<span style={{
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
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',
}}>
@@ -103,7 +103,7 @@ export function ChatMessages(props: any) {
<div style={{
width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11, fontWeight: 700, color: 'var(--text-muted)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)',
}}>
{(msg.username || '?')[0].toUpperCase()}
</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 }}>
{/* Username for others at group start */}
{!own && isNewGroup && (
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
{msg.username}
</span>
)}
@@ -138,7 +138,7 @@ export function ChatMessages(props: any) {
}}
>
{bigEmoji ? (
<div style={{ fontSize: 40, lineHeight: 1.2, padding: '2px 0' }}>
<div style={{ fontSize: 'calc(40px * var(--fs-scale-title, 1))', lineHeight: 1.2, padding: '2px 0' }}>
{msg.text}
</div>
) : (
@@ -146,16 +146,16 @@ export function ChatMessages(props: any) {
background: own ? '#007AFF' : 'var(--bg-secondary)',
color: own ? '#fff' : 'var(--text-primary)',
borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
fontSize: 14, lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
fontSize: 'calc(14px * var(--fs-scale-body, 1))', lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
}}>
{/* Inline reply quote */}
{hasReply && (
<div style={{
padding: '5px 10px', marginBottom: 4, borderRadius: 12,
background: own ? 'rgba(255,255,255,0.15)' : 'var(--bg-tertiary)',
fontSize: 12, lineHeight: 1.3,
fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.3,
}}>
<div style={{ fontWeight: 600, fontSize: 11, opacity: 0.7, marginBottom: 1 }}>
<div style={{ fontWeight: 600, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', opacity: 0.7, marginBottom: 1 }}>
{msg.reply_username || ''}
</div>
<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 */}
{isLastInGroup && (
<span style={{ fontSize: 9, color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
{formatTime(msg.created_at, is12h)}
</span>
)}
@@ -34,14 +34,14 @@ export function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadg
}}
>
<TwemojiImg emoji={reaction.emoji} size={16} />
{reaction.count > 1 && <span style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>}
{reaction.count > 1 && <span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>}
</button>
{hover && names && ReactDOM.createPortal(
<div style={{
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
}}>
{names}
+13 -13
View File
@@ -243,7 +243,7 @@ function CollabNotesLoading({ t }: NotesState) {
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0, fontFamily: FONT }}>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0, fontFamily: FONT }}>
{t('collab.notes.title')}
</h3>
</div>
@@ -263,7 +263,7 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
<h3 style={{
fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', margin: 0, fontFamily: FONT,
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', margin: 0, fontFamily: FONT,
letterSpacing: 0.3, textTransform: 'uppercase', display: 'flex', alignItems: 'center', gap: 7,
}}>
<StickyNote size={14} color="var(--text-faint)" />
@@ -277,7 +277,7 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
<Settings size={14} />
</button>}
{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: 11, 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: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
<Plus size={12} />
{t('collab.notes.new')}
</button>}
@@ -292,7 +292,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
<button
onClick={() => setActiveCategory(null)}
style={{
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 10, fontWeight: 600, fontFamily: FONT,
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT,
border: activeCategory === null ? '1px solid var(--accent)' : '1px solid var(--border-faint)',
background: activeCategory === null ? 'var(--accent)' : 'transparent',
color: activeCategory === null ? 'var(--accent-text)' : 'var(--text-secondary)',
@@ -306,7 +306,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
key={cat}
onClick={() => setActiveCategory(prev => prev === cat ? null : cat)}
style={{
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 10, fontWeight: 600, fontFamily: FONT,
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT,
border: activeCategory === cat ? '1px solid var(--accent)' : '1px solid var(--border-faint)',
background: activeCategory === cat ? 'var(--accent)' : 'transparent',
color: activeCategory === cat ? 'var(--accent-text)' : 'var(--text-secondary)',
@@ -334,10 +334,10 @@ function CollabNotesGrid(S: NotesState) {
padding: '48px 20px', textAlign: 'center', height: '100%',
}}>
<Pencil size={36} color="var(--text-faint)" style={{ marginBottom: 12 }} />
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4, fontFamily: FONT }}>
<div style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4, fontFamily: FONT }}>
{t('collab.notes.empty')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', fontFamily: FONT }}>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontFamily: FONT }}>
{t('collab.notes.emptyDesc') || 'Create a note to get started'}
</div>
</div>
@@ -397,10 +397,10 @@ function ViewNoteModal(S: NotesState) {
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 17, fontWeight: 600, color: 'var(--text-primary)' }}>{viewingNote.title}</div>
<div style={{ fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{viewingNote.title}</div>
{viewingNote.category && (
<span style={{
display: 'inline-block', marginTop: 4, fontSize: 10, fontWeight: 600,
display: 'inline-block', marginTop: 4, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600,
color: getCategoryColor(viewingNote.category),
background: `${getCategoryColor(viewingNote.category)}18`,
padding: '2px 8px', borderRadius: 6,
@@ -422,11 +422,11 @@ function ViewNoteModal(S: NotesState) {
</button>
</div>
</div>
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
<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 }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
{(viewingNote.attachments || []).length > 0 && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
<div style={{ 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={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{(viewingNote.attachments || []).map(a => {
const isImage = a.mime_type?.startsWith('image/')
@@ -449,10 +449,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)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
<span style={{ fontSize: 10, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
<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>
</div>
)}
<span style={{ fontSize: 9, color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
<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>
</div>
)
})}
@@ -1,4 +1,5 @@
import { useState, useCallback } from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
@@ -28,7 +29,7 @@ interface NoteCardProps {
export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
const [hovered, setHovered] = useState(false)
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || avatarSrc(note.avatar) }
const color = getCategoryColor ? getCategoryColor(note.category) : (note.color || '#6366f1')
const handleTogglePin = useCallback(() => {
@@ -63,11 +64,11 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
}}>
{!!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={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{note.title}
</span>
{note.category && (
<span style={{ fontSize: 8, fontWeight: 600, color, background: `${color}18`, padding: '2px 6px', borderRadius: 99, flexShrink: 0, letterSpacing: '0.02em', textTransform: 'uppercase' }}>
<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' }}>
{note.category}
</span>
)}
@@ -115,7 +116,7 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
marginBottom: 6, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.12s',
whiteSpace: 'nowrap', zIndex: 10,
background: 'var(--bg-card)', color: 'var(--text-primary)',
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)',
}}>
{author.username}
@@ -137,7 +138,7 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
<div style={{ flex: 1, minWidth: 0 }}>
{note.content && (
<div className="collab-note-md" style={{
fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, margin: 0,
fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)', lineHeight: 1.5, margin: 0,
maxHeight: '4.5em', overflow: 'hidden',
wordBreak: 'break-word', fontFamily: FONT,
}}>
@@ -151,14 +152,14 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
{/* Website */}
{note.website && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>Link</span>
<span style={{ fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>Link</span>
<WebsiteThumbnail url={note.website} tripId={tripId} color={color} />
</div>
)}
{/* Files */}
{(note.attachments || []).length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>{t('files.title')}</span>
<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>
<div style={{ display: 'flex', gap: 4 }}>
{(note.attachments || []).slice(0, note.website ? 1 : 2).map(a => {
const isImage = a.mime_type?.startsWith('image/')
@@ -179,12 +180,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)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
<span style={{ fontSize: 9, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
<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>
</div>
)
})}
{(note.attachments?.length || 0) > (note.website ? 1 : 2) && (
<span style={{ fontSize: 8, color: 'var(--text-faint)', textAlign: 'center' }}>+{(note.attachments?.length || 0) - (note.website ? 1 : 2)}</span>
<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>
)}
</div>
</div>
@@ -71,7 +71,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
}} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>
{t('collab.notes.categorySettings') || 'Category Settings'}
</h3>
<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 */}
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{allCats.length === 0 && (
<p style={{ fontSize: 12, color: 'var(--text-faint)', textAlign: 'center', padding: 16 }}>
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', textAlign: 'center', padding: 16 }}>
{t('collab.notes.noCategoriesYet') || 'No categories yet'}
</p>
)}
@@ -119,7 +119,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
placeholder={t('collab.notes.newCategory')}
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',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
}} />
<button onClick={handleAddCategory} disabled={!newCatName.trim()} style={{
background: newCatName.trim() ? 'var(--accent)' : 'var(--border-primary)', color: 'var(--accent-text)',
@@ -133,7 +133,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
{/* Save */}
<button onClick={handleSave} style={{
width: '100%', borderRadius: 99, padding: '9px 14px', background: 'var(--accent)', color: 'var(--accent-text)',
fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', marginTop: 8,
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', marginTop: 8,
}}>
{t('collab.notes.save')}
</button>
@@ -21,12 +21,12 @@ export function EditableCatName({ name, onRename }: EditableCatNameProps) {
if (editing) {
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) } }}
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' }} />
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' }} />
}
return (
<span onClick={() => { setValue(name); setEditing(true) }}
style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }}
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }}
title="Click to rename">
{name}
</span>
@@ -37,7 +37,7 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
: <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' }}>
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
<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>
<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={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 */
<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 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
<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>
<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: 11, 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: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
</div>
</div>
{(isPdf || isTxt) ? (
<object data={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)' }}>
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download</button>
<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>
</p>
</object>
) : (
<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: 14, padding: 0 }}>Download {file.original_name}</button>
<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>
</div>
)}
</div>
@@ -118,7 +118,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
borderBottom: '1px solid var(--border-faint)',
}}>
<h3 style={{
fontSize: 14,
fontSize: 'calc(14px * var(--fs-scale-body, 1))',
fontWeight: 700,
color: 'var(--text-primary)',
margin: 0,
@@ -153,7 +153,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
{/* Title */}
<div>
<div style={{
fontSize: 9,
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
fontWeight: 600,
color: 'var(--text-faint)',
textTransform: 'uppercase',
@@ -173,7 +173,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
border: '1px solid var(--border-primary)',
borderRadius: 10,
padding: '8px 12px',
fontSize: 13,
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
background: 'var(--bg-input)',
color: 'var(--text-primary)',
fontFamily: 'inherit',
@@ -186,7 +186,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
{/* Content */}
<div>
<div style={{
fontSize: 9,
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
fontWeight: 600,
color: 'var(--text-faint)',
textTransform: 'uppercase',
@@ -205,7 +205,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
border: '1px solid var(--border-primary)',
borderRadius: 10,
padding: '8px 12px',
fontSize: 13,
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
background: 'var(--bg-input)',
color: 'var(--text-primary)',
fontFamily: 'inherit',
@@ -220,7 +220,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
{/* Category pills */}
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6, fontFamily: FONT }}>
<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 }}>
{t('collab.notes.category')}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
@@ -229,7 +229,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
const active = category === cat
return (
<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: 11, 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: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: FONT }}>
{cat}
</button>
)
@@ -239,17 +239,17 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
{/* Website */}
<div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
<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 }}>
{t('collab.notes.website')}
</div>
<input value={website} onChange={e => setWebsite(e.target.value)}
placeholder={t('collab.notes.websitePlaceholder')}
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' }} />
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' }} />
</div>
{/* File attachments */}
{canUploadFiles && <div>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
<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 }}>
{t('collab.notes.attachFiles')}
</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 = '' }} />
@@ -258,7 +258,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
{existingAttachments.map(a => {
const isImage = a.mime_type?.startsWith('image/')
return (
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
<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)' }}>
{isImage && <AuthedImg src={a.url} style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 3 }} />}
{(a.original_name || '').length > 20 ? a.original_name.slice(0, 17) + '...' : a.original_name}
<button type="button" onClick={() => handleDeleteAttachment(a.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: 0, display: 'flex' }}>
@@ -269,7 +269,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
})}
{/* New pending files */}
{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: 11, 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: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)' }}>
{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' }}>
<X size={10} />
@@ -277,7 +277,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
</div>
))}
<button type="button" onClick={() => fileRef.current?.click()}
style={{ padding: '4px 10px', borderRadius: 8, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 11, fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
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 }}>
<Plus size={11} /> {t('files.attach') || 'Add'}
</button>
</div>
@@ -293,7 +293,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
padding: '7px 14px',
background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)',
fontSize: 12,
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
fontWeight: 600,
fontFamily: FONT,
border: 'none',
@@ -37,7 +37,7 @@ export function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps)
) : (
<>
<ExternalLink size={14} color="var(--text-muted)" />
<span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-muted)', maxWidth: 42, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}>
<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' }}>
{domain}
</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',
background: active ? 'var(--accent)' : 'transparent',
color: active ? 'var(--accent-text)' : 'var(--text-muted)',
fontSize: 11, fontWeight: 600, fontFamily: 'inherit',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: 'inherit',
transition: 'all 0.15s',
}}>
{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}>
<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)' }}>
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.polls.new')}</h3>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', 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>
</div>
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* 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: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
<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>
<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' }} />
</div>
{/* 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={{ 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={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{options.map((opt, i) => (
<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}`}
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' }} />
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' }} />
{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>
)}
</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: 12, 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: 'calc(12px * var(--fs-scale-body, 1))', fontFamily: FONT }}>
<Plus size={12} /> {t('collab.polls.addOption')}
</button>
</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>
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontFamily: FONT }}>{t('collab.polls.multiChoice')}</span>
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', fontFamily: FONT }}>{t('collab.polls.multiChoice')}</span>
</label>
{/* Submit */}
<button type="submit" disabled={!canSubmit} style={{
width: '100%', borderRadius: 99, padding: '9px 14px', background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)', fontSize: 13, fontWeight: 600, border: 'none', cursor: canSubmit ? 'pointer' : 'default', fontFamily: FONT,
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,
}}>
{submitting ? '...' : t('collab.polls.create')}
</button>
@@ -168,7 +168,7 @@ function VoterChip({ voter, offset }: VoterChipProps) {
style={{
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 7, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden',
fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden',
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()}
@@ -178,7 +178,7 @@ function VoterChip({ voter, offset }: VoterChipProps) {
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
background: 'var(--bg-card)', color: 'var(--text-primary)',
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px', borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)',
}}>
{voter.username}
@@ -217,26 +217,26 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
background: isClosed ? 'var(--bg-secondary)' : 'transparent',
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}>
{poll.question}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
{isClosed && (
<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 }}>
<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 }}>
<Lock size={8} /> {t('collab.polls.closed')}
</span>
)}
{remaining && !isClosed && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, fontWeight: 600, color: '#f59e0b', background: '#f59e0b18', padding: '2px 7px', borderRadius: 99 }}>
<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 }}>
<Clock size={8} /> {remaining}
</span>
)}
{poll.multi_choice && (
<span style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
<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 }}>
{t('collab.polls.multiChoice')}
</span>
)}
<span style={{ fontSize: 9, color: 'var(--text-faint)' }}>
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>
{total} {total === 1 ? 'vote' : 'votes'}
</span>
</div>
@@ -303,7 +303,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
{/* Label */}
<span style={{
flex: 1, fontSize: 13, fontWeight: myVote || isWinner ? 600 : 400,
flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: myVote || isWinner ? 600 : 400,
color: 'var(--text-primary)', position: 'relative', zIndex: 1,
}}>
{typeof opt === 'string' ? opt : opt.text}
@@ -321,7 +321,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
{/* Percentage */}
{(hasVoted || isClosed) && (
<span style={{
fontSize: 12, fontWeight: 700, color: myVote ? '#007AFF' : 'var(--text-muted)',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: myVote ? '#007AFF' : 'var(--text-muted)',
position: 'relative', zIndex: 1, minWidth: 32, textAlign: 'right',
}}>
{pct}%
@@ -443,14 +443,14 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
<h3 style={{ margin: 0, fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase' }}>
<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' }}>
<BarChart3 size={14} color="var(--text-faint)" />
{t('collab.polls.title')}
</h3>
{canEdit && (
<button onClick={() => setShowForm(true)} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
fontFamily: FONT, border: 'none', cursor: 'pointer',
}}>
<Plus size={12} /> {t('collab.polls.new')}
@@ -463,8 +463,8 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
{polls.length === 0 ? (
<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 }} />
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.polls.empty')}</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.polls.emptyHint')}</div>
<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: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}>{t('collab.polls.emptyHint')}</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
@@ -474,7 +474,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
{closedPolls.length > 0 && (
<>
{activePolls.length > 0 && (
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, padding: '8px 0 2px' }}>
<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' }}>
{t('collab.polls.closedSection') || 'Closed'}
</div>
)}
@@ -1,4 +1,5 @@
import React, { useMemo } from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
@@ -91,7 +92,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 7, flexShrink: 0,
}}>
<Sparkles size={14} color="var(--text-faint)" />
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.3, textTransform: 'uppercase' }}>
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.3, textTransform: 'uppercase' }}>
{t('collab.whatsNext.title') || "What's Next"}
</span>
</div>
@@ -101,8 +102,8 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
{upcoming.length === 0 ? (
<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 }} />
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.whatsNext.empty')}</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div>
<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: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
@@ -114,7 +115,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
<React.Fragment key={item.id}>
{showDayHeader && (
<div style={{
fontSize: 10, fontWeight: 500, color: 'var(--text-faint)',
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, color: 'var(--text-faint)',
textTransform: 'uppercase', letterSpacing: 0.5,
padding: idx === 0 ? '0 4px 4px' : '8px 4px 4px',
}}>
@@ -132,15 +133,15 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
>
{/* Time column */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 44, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
{item.time ? formatTime(item.time, is12h) : 'TBD'}
</span>
{item.endTime && (
<>
<span style={{ fontSize: 7, color: 'var(--text-faint)', fontWeight: 600, letterSpacing: 0.3, margin: '2px 0', textTransform: 'uppercase' }}>
<span style={{ fontSize: 'calc(7px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontWeight: 600, letterSpacing: 0.3, margin: '2px 0', textTransform: 'uppercase' }}>
{t('collab.whatsNext.until') || 'bis'}
</span>
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
{formatTime(item.endTime, is12h)}
</span>
</>
@@ -152,13 +153,13 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
{/* Details */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<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' }}>
{item.name}
</div>
{item.address && (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 2 }}>
<MapPin size={9} color="var(--text-faint)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.address}
</span>
</div>
@@ -175,15 +176,15 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
<div style={{
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-secondary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 7, fontWeight: 700, color: 'var(--text-muted)',
fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)',
overflow: 'hidden', flexShrink: 0,
}}>
{p.avatar
? <img src={`/uploads/avatars/${p.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
? <img src={avatarSrc(p.avatar)!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: p.username?.[0]?.toUpperCase()
}
</div>
<span style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)' }}>{p.username}</span>
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, color: 'var(--text-muted)' }}>{p.username}</span>
</div>
))}
</div>
@@ -0,0 +1,241 @@
import React, { useEffect, useRef, useState } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { Search, MapPin, Plus, Loader2, Link2, Trash2, Check, X } from 'lucide-react'
import Modal from '../shared/Modal'
import MarkdownToolbar from '../Journey/MarkdownToolbar'
import { mapsApi } from '../../api/client'
import { collectionsApi } from '../../api/collections'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { getApiErrorMessage } from '../../types'
import { normalizeLinkUrl, STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
import type { Category, TranslationFn } from '../../types'
import type { CollectionLink, CollectionStatus } from '@trek/shared'
type MapsPlace = Record<string, unknown>
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : typeof v === 'string' && v !== '' ? Number(v) : undefined)
interface AddPlaceToCollectionModalProps {
isOpen: boolean
collectionId: number
collectionName: string
categories: Category[]
onClose: () => void
onAdded: () => void
t: TranslationFn
}
/**
* Add a place to the current list everything in one view: a search field that
* fills in the location when a result is picked, plus name / category / status /
* markdown description / links, all editable together before saving. Stays open
* after each add so several places can be added in a row.
*/
export default function AddPlaceToCollectionModal({ isOpen, collectionId, collectionName, categories, onClose, onAdded, t }: AddPlaceToCollectionModalProps): React.ReactElement {
const { language } = useTranslation()
const toast = useToast()
const [query, setQuery] = useState('')
const [results, setResults] = useState<MapsPlace[]>([])
const [searching, setSearching] = useState(false)
// The picked location (address/coords/ids) plus the editable fields.
const [picked, setPicked] = useState<MapsPlace | null>(null)
const [name, setName] = useState('')
const [categoryId, setCategoryId] = useState<number | null>(null)
const [description, setDescription] = useState('')
const [links, setLinks] = useState<CollectionLink[]>([])
const [status, setStatus] = useState<CollectionStatus>('idea')
const [saving, setSaving] = useState(false)
const descRef = useRef<HTMLTextAreaElement>(null)
const reset = () => { setQuery(''); setResults([]); setPicked(null); setName(''); setCategoryId(null); setDescription(''); setLinks([]); setStatus('idea') }
useEffect(() => { if (!isOpen) reset() }, [isOpen])
const search = async () => {
if (!query.trim()) return
setSearching(true)
try {
const res = await mapsApi.search(query, language)
setResults((res.places as MapsPlace[]) || [])
} catch (err) {
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally {
setSearching(false)
}
}
const pick = (r: MapsPlace) => { setPicked(r); setName(str(r.name) ?? ''); setResults([]); setQuery(str(r.name) ?? query) }
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
const save = async () => {
const cleanName = name.trim()
if (!cleanName) return
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
setSaving(true)
try {
const res = await collectionsApi.savePlace({
collection_id: collectionId,
name: cleanName,
address: (picked && str(picked.address)) ?? null,
lat: (picked && num(picked.lat)) ?? null,
lng: (picked && num(picked.lng)) ?? null,
google_place_id: (picked && str(picked.google_place_id)) ?? null,
google_ftid: (picked && str(picked.google_ftid)) ?? null,
osm_id: (picked && str(picked.osm_id)) ?? null,
website: (picked && str(picked.website)) ?? null,
phone: (picked && str(picked.phone)) ?? null,
category_id: categoryId,
description: description.trim() || null,
links: cleanLinks,
status,
force: true,
})
if (res.duplicate) toast.info(t('collections.duplicateWarning'))
else { toast.success(t('collections.addedToList', { name: collectionName })); onAdded() }
reset()
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setSaving(false)
}
}
const address = picked ? str(picked.address) : undefined
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={t('collections.addPlace')}
size="md"
footer={
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">{t('common.cancel')}</button>
<button type="button" onClick={save} disabled={saving || !name.trim()} className="px-3 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-1.5">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {t('common.add')}
</button>
</div>
}
>
<div className="flex flex-col gap-4">
{/* Search — picking a result fills the location below */}
<div className="relative">
<div className="flex gap-2">
<div className="relative flex-1">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" />
<input
autoFocus
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); search() } }}
placeholder={t('collections.addPlaceSearch')}
className="w-full pl-9 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent"
/>
</div>
<button type="button" onClick={search} disabled={!query.trim() || searching} className="px-4 py-2 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-2">
{searching ? <Loader2 size={15} className="animate-spin" /> : <Search size={15} />}
{t('common.search')}
</button>
</div>
{results.length > 0 && (
<div className="absolute z-20 left-0 right-0 mt-1.5 max-h-[280px] overflow-y-auto rounded-xl border border-edge bg-surface-card shadow-lg p-1.5 flex flex-col gap-1">
<div className="flex items-center justify-between px-2 py-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-content-faint">{t('common.search')}</span>
<button type="button" onClick={() => setResults([])} className="p-1 rounded-md text-content-faint hover:text-content hover:bg-surface-hover" aria-label={t('common.close')}><X size={13} /></button>
</div>
{results.map((r, i) => (
<button key={i} type="button" onClick={() => pick(r)} className="flex items-center gap-3 px-2.5 py-2 rounded-lg text-left hover:bg-surface-hover transition-colors">
<div className="w-8 h-8 min-w-[32px] rounded-lg bg-surface-secondary flex items-center justify-center text-content-faint shrink-0"><MapPin size={15} /></div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[13px] font-semibold text-content truncate">{str(r.name)}</span>
{str(r.address) && <span className="text-[11.5px] text-content-faint truncate">{str(r.address)}</span>}
</div>
</button>
))}
</div>
)}
</div>
{/* Name */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('common.name')}</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder={t('common.name')} className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent" />
{address && <div className="flex items-center gap-1.5 mt-1.5 text-[12px] text-content-faint"><MapPin size={12} /> {address}</div>}
</div>
{/* Status */}
<div>
<div className="flex flex-wrap gap-1.5">
{STATUS_ORDER.map(s => {
const Icon = STATUS_META[s].icon
const on = status === s
return (
<button key={s} type="button" onClick={() => setStatus(s)} className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-semibold border transition-colors ${on ? 'bg-inverse text-inverse-text border-transparent' : 'bg-surface-card text-content-secondary border-edge hover:bg-surface-hover'}`}>
<Icon size={13} style={{ color: on ? undefined : STATUS_META[s].color }} /> {t(STATUS_META[s].labelKey)}
</button>
)
})}
</div>
</div>
{/* Category */}
{categories.length > 0 && (
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.category')}</label>
<div className="flex flex-wrap gap-1.5">
<button type="button" onClick={() => setCategoryId(null)} className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-colors ${categoryId == null ? 'bg-inverse text-inverse-text border-transparent' : 'bg-surface-card text-content-secondary border-edge hover:bg-surface-hover'}`}>
{t('collections.noCategory')}
</button>
{categories.map(cat => {
const Icon = getCategoryIcon(cat.icon ?? undefined)
const on = categoryId === cat.id
const col = cat.color || '#6366f1'
return (
<button
key={cat.id}
type="button"
onClick={() => setCategoryId(cat.id)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-medium border transition-colors bg-surface-card border-edge hover:bg-surface-hover"
style={on ? { color: col, background: `color-mix(in oklch, ${col} 15%, transparent)`, borderColor: `color-mix(in oklch, ${col} 40%, transparent)` } : undefined}
>
<Icon size={13} /> {cat.name}
</button>
)
})}
</div>
</div>
)}
{/* Description */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.description')}</label>
<MarkdownToolbar textareaRef={descRef} onUpdate={setDescription} />
<textarea ref={descRef} value={description} onChange={e => setDescription(e.target.value)} rows={3} placeholder={t('collections.descriptionPlaceholder')} className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent resize-y" />
{description.trim() && (
<div className="collab-note-md mt-2 text-[13px] text-content-secondary"><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{description}</Markdown></div>
)}
</div>
{/* Links */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.links')}</label>
<div className="flex flex-col gap-2">
{links.map((l, i) => (
<div key={i} className="flex items-center gap-2">
<input value={l.label ?? ''} onChange={e => setLink(i, { label: e.target.value })} placeholder={t('collections.linkLabel')} className="w-28 shrink-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent" />
<input value={l.url} onChange={e => setLink(i, { url: e.target.value })} placeholder="https://…" className="flex-1 min-w-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent" />
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} className="p-1.5 rounded-md text-content-faint hover:text-danger hover:bg-danger-soft" aria-label={t('common.delete')}><Trash2 size={14} /></button>
</div>
))}
<button type="button" onClick={() => setLinks([...links, { url: '' }])} className="inline-flex items-center gap-1.5 self-start px-2.5 py-1.5 rounded-lg border border-dashed border-edge text-content-secondary text-[12.5px] font-medium hover:bg-surface-hover">
<Plus size={14} /> <Link2 size={13} /> {t('collections.addLink')}
</button>
</div>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,122 @@
// FE-COMP-COLFILTERBAR-001 to FE-COMP-COLFILTERBAR-008
import React from 'react';
import { render, screen, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { resetAllStores } from '../../../tests/helpers/store';
import { useTranslation } from '../../i18n/TranslationContext';
import type { CategoryOption } from '../../pages/collections/collectionsModel';
import type { StatusFilter } from '../../store/collectionStore';
import CollectionFilterBar from './CollectionFilterBar';
// The component takes `t` as a prop; pull the REAL translation fn from context so
// visible labels are actual English strings (All / Idea / Want to go / Visited / Select).
type HarnessProps = Omit<React.ComponentProps<typeof CollectionFilterBar>, 't'>;
function Harness(props: HarnessProps): React.ReactElement {
const { t } = useTranslation();
return <CollectionFilterBar {...props} t={t} />;
}
const CATEGORY_OPTIONS: CategoryOption[] = [
{ id: 1, name: 'Food', color: '#f00', icon: null, count: 2 },
];
function makeProps(overrides: Partial<HarnessProps> = {}): HarnessProps {
return {
statusFilter: 'all' as StatusFilter,
counts: { all: 3, idea: 1, want: 1, visited: 1 },
categoryFilter: 'all',
categoryOptions: CATEGORY_OPTIONS,
onStatusFilter: vi.fn(),
onCategoryFilter: vi.fn(),
showSelect: true,
selectMode: false,
onToggleSelect: vi.fn(),
...overrides,
};
}
beforeEach(() => {
resetAllStores();
});
describe('CollectionFilterBar', () => {
it('FE-COMP-COLFILTERBAR-001: renders the status dropdown showing the current "All" filter', () => {
render(<Harness {...makeProps()} />);
// Both dropdown triggers currently read "All" (status=all, category=all).
// With a category present there are exactly two "All" triggers: status + category.
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(2);
});
it('FE-COMP-COLFILTERBAR-002: opening the status dropdown reveals the status options', async () => {
const user = userEvent.setup();
render(<Harness {...makeProps()} />);
// First "All" trigger is the status dropdown (rendered before the category one).
const statusTrigger = screen.getAllByRole('button', { name: 'All' })[0];
expect(statusTrigger).toHaveAttribute('aria-expanded', 'false');
await user.click(statusTrigger);
const listbox = screen.getByRole('listbox');
expect(within(listbox).getByRole('option', { name: /Idea/i })).toBeInTheDocument();
expect(within(listbox).getByRole('option', { name: /Want to go/i })).toBeInTheDocument();
expect(within(listbox).getByRole('option', { name: /Visited/i })).toBeInTheDocument();
});
it('FE-COMP-COLFILTERBAR-003: clicking a status option calls onStatusFilter with that status', async () => {
const user = userEvent.setup();
const onStatusFilter = vi.fn();
render(<Harness {...makeProps({ onStatusFilter })} />);
await user.click(screen.getAllByRole('button', { name: 'All' })[0]);
const listbox = screen.getByRole('listbox');
await user.click(within(listbox).getByRole('option', { name: /Want to go/i }));
expect(onStatusFilter).toHaveBeenCalledTimes(1);
expect(onStatusFilter).toHaveBeenCalledWith('want');
});
it('FE-COMP-COLFILTERBAR-004: the category dropdown is present when categoryOptions is non-empty', () => {
render(<Harness {...makeProps()} />);
// Two dropdown triggers = status + category.
const triggers = screen.getAllByRole('button', { name: 'All' });
expect(triggers).toHaveLength(2);
});
it('FE-COMP-COLFILTERBAR-005: the category dropdown is hidden when categoryOptions is empty', () => {
render(<Harness {...makeProps({ categoryOptions: [] })} />);
// Only the status dropdown remains.
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(1);
});
it('FE-COMP-COLFILTERBAR-006: clicking a category option calls onCategoryFilter with the category id', async () => {
const user = userEvent.setup();
const onCategoryFilter = vi.fn();
render(<Harness {...makeProps({ onCategoryFilter })} />);
// Second "All" trigger is the category dropdown.
await user.click(screen.getAllByRole('button', { name: 'All' })[1]);
const listbox = screen.getByRole('listbox');
await user.click(within(listbox).getByRole('option', { name: /Food/i }));
expect(onCategoryFilter).toHaveBeenCalledTimes(1);
expect(onCategoryFilter).toHaveBeenCalledWith(1);
});
it('FE-COMP-COLFILTERBAR-007: clicking the Select button calls onToggleSelect', async () => {
const user = userEvent.setup();
const onToggleSelect = vi.fn();
render(<Harness {...makeProps({ onToggleSelect })} />);
const selectBtn = screen.getByRole('button', { name: 'Select' });
expect(selectBtn).toHaveAttribute('aria-pressed', 'false');
await user.click(selectBtn);
expect(onToggleSelect).toHaveBeenCalledTimes(1);
});
it('FE-COMP-COLFILTERBAR-008: showSelect=false hides the Select button', () => {
render(<Harness {...makeProps({ showSelect: false })} />);
expect(screen.queryByRole('button', { name: 'Select' })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,117 @@
import React, { useEffect, useRef, useState } from 'react'
import { ChevronDown, Check, Layers, Tag, CheckSquare } from 'lucide-react'
import type { StatusFilter } from '../../store/collectionStore'
import type { TranslationFn } from '../../types'
import { getCategoryIcon } from '../shared/categoryIcons'
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
import type { CategoryOption } from '../../pages/collections/collectionsModel'
interface Opt {
key: string | number
label: string
icon?: React.ReactNode
count?: number
}
/** Small custom dropdown — compact trigger + click-away popover. */
function Dropdown({ current, options, onSelect, lead }: {
current: string | number
options: Opt[]
onSelect: (key: string | number) => void
lead: React.ReactNode
}): React.ReactElement {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onDoc)
document.addEventListener('keydown', onKey)
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey) }
}, [open])
const cur = options.find(o => o.key === current) ?? options[0]
return (
<div className="col-filter" ref={ref}>
<button type="button" className={`col-filter-btn${open ? ' open' : ''}`} onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}>
{cur.icon ?? lead}
<span className="col-filter-lbl">{cur.label}</span>
<ChevronDown size={14} className="col-filter-chev" />
</button>
{open && (
<div className="col-filter-pop" role="listbox">
{options.map(o => (
<button
key={o.key}
type="button"
role="option"
aria-selected={o.key === current}
className={`col-filter-opt${o.key === current ? ' on' : ''}`}
onClick={() => { onSelect(o.key); setOpen(false) }}
>
{o.icon ?? <span className="col-filter-dot ghost" />}
<span className="col-filter-lbl">{o.label}</span>
{o.count != null && <span className="col-filter-count">{o.count}</span>}
{o.key === current && <Check size={13} className="col-filter-check" />}
</button>
))}
</div>
)}
</div>
)
}
interface CollectionFilterBarProps {
statusFilter: StatusFilter
counts: Record<StatusFilter, number>
categoryFilter: number | 'all'
categoryOptions: CategoryOption[]
onStatusFilter: (f: StatusFilter) => void
onCategoryFilter: (f: number | 'all') => void
showSelect: boolean
selectMode: boolean
onToggleSelect: () => void
t: TranslationFn
}
/**
* Filter row above the places a status dropdown (All / Idea / Want / Visited
* with counts) and, when the list has categorised places, a category dropdown.
* Custom compact dropdowns so they barely take any space.
*/
export default function CollectionFilterBar({
statusFilter, counts, categoryFilter, categoryOptions, onStatusFilter, onCategoryFilter,
showSelect, selectMode, onToggleSelect, t,
}: CollectionFilterBarProps): React.ReactElement {
const statusOpts: Opt[] = [
{ key: 'all', label: t('common.all'), count: counts.all },
...STATUS_ORDER.map(s => {
const Icon = STATUS_META[s].icon
return { key: s, label: t(STATUS_META[s].labelKey), icon: <Icon size={13} style={{ color: STATUS_META[s].color }} />, count: counts[s] }
}),
]
const catTotal = categoryOptions.reduce((n, c) => n + c.count, 0)
const catOpts: Opt[] = [
{ key: 'all', label: t('common.all'), count: catTotal },
...categoryOptions.map(c => {
const Icon = getCategoryIcon(c.icon ?? undefined)
return { key: c.id, label: c.name, icon: <Icon size={13} style={{ color: c.color ?? undefined }} />, count: c.count }
}),
]
return (
<div className="col-filterbar">
<Dropdown current={statusFilter} options={statusOpts} onSelect={k => onStatusFilter(k as StatusFilter)} lead={<Layers size={13} />} />
{categoryOptions.length > 0 && (
<Dropdown current={categoryFilter} options={catOpts} onSelect={k => onCategoryFilter(k as number | 'all')} lead={<Tag size={13} />} />
)}
{showSelect && (
<button type="button" onClick={onToggleSelect} className={`col-filter-btn col-filter-select${selectMode ? ' open' : ''}`} aria-pressed={selectMode}>
<CheckSquare size={14} /> <span className="col-filter-lbl">{t('collections.select')}</span>
</button>
)}
</div>
)
}
@@ -0,0 +1,117 @@
import React from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { Share2, Users, Link2, Pencil } from 'lucide-react'
import type { CollectionMember, CollectionLink } from '@trek/shared'
import type { TranslationFn } from '../../types'
const AV_COLORS = ['#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#3b82f6', '#ef4444', '#22c55e']
function initials(name: string): string {
return name.trim().split(/\s+/).slice(0, 2).map(w => w[0]?.toUpperCase() ?? '').join('') || '?'
}
interface CollectionHeroProps {
eyebrow: string
title: string
/** List colour — drives the gradient wash (or tints the cover image). */
color: string
coverImage?: string | null
description?: string | null
links?: CollectionLink[]
/** Accepted members (owner first) — shown as an avatar stack when shared. */
members: CollectionMember[]
canShare: boolean
isOwner: boolean
canEdit: boolean
onEdit: () => void
shareMemberCount: number
onShare: () => void
t: TranslationFn
}
/**
* The page header a colour-washed (or cover-image) glass hero that gives the
* active list an identity: an eyebrow with the sharing state + member avatars,
* the big list name, an optional description + link chips, and a Share action
* top-right. Filtering lives in the toolbar above the places, not here.
* Modelled on the dashboard hero-trip.
*/
function linkHost(url: string): string {
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
}
export default function CollectionHero({
eyebrow, title, color, coverImage, description, links,
members, canShare, isOwner, canEdit, onEdit, shareMemberCount, onShare, t,
}: CollectionHeroProps): React.ReactElement {
const accepted = members.filter(m => m.status === 'accepted' || m.is_owner)
const showAvatars = accepted.length > 1
const shown = accepted.slice(0, 5)
const extra = accepted.length - shown.length
return (
<header className="col-hero" style={{ ['--hero-color' as string]: color }}>
{coverImage ? (
<>
<img className="col-hero-img" src={coverImage} alt="" />
<div className="col-hero-tint" />
</>
) : (
<div className="col-hero-bg" />
)}
<div className="col-hero-scrim" />
<div className="col-hero-content">
<div className="col-hero-eyebrow">
<span>{eyebrow}</span>
{showAvatars && (
<span className="members">
{shown.map(m => (
m.avatar
? <img key={m.user_id} className="col-av" src={avatarSrc(m.avatar)!} alt={m.username} />
: <span key={m.user_id} className="col-av" style={{ background: AV_COLORS[m.user_id % AV_COLORS.length] }}>{initials(m.username)}</span>
))}
{extra > 0 && <span className="col-av" style={{ background: 'rgba(255,255,255,.28)' }}>+{extra}</span>}
</span>
)}
{links && links.length > 0 && (
<span className="col-hero-links">
{links.map((l, i) => (
<a key={i} href={l.url} target="_blank" rel="noopener noreferrer" className="col-hero-link" onClick={e => e.stopPropagation()}>
<Link2 size={12} /> {l.label || linkHost(l.url)}
</a>
))}
</span>
)}
</div>
<div className="col-hero-titlerow">
<h1 className="col-hero-title">{title}</h1>
<div className="col-hero-actions">
{canEdit && (
<button type="button" onClick={onEdit} aria-label={t('common.edit')} title={t('common.edit')} className="col-glass-btn">
<Pencil size={15} />
<span className="txt">{t('common.edit')}</span>
</button>
)}
{canShare && (
<button
type="button"
onClick={onShare}
aria-label={isOwner ? t('collections.share.button') : t('collections.shared')}
title={isOwner ? t('collections.share.button') : t('collections.shared')}
className={`col-glass-btn${isOwner && shareMemberCount > 0 ? ' has-count' : ''}`}
>
{isOwner ? <Share2 size={15} /> : <Users size={15} />}
<span className="txt">{isOwner ? t('collections.share.button') : t('collections.shared')}</span>
{isOwner && shareMemberCount > 0 && <span className="cnt">{shareMemberCount}</span>}
</button>
)}
</div>
</div>
{description && <p className="col-hero-desc">{description}</p>}
</div>
</header>
)
}
@@ -0,0 +1,159 @@
// FE-COMP-COLLIST-001 to FE-COMP-COLLIST-010
import { render, screen } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { CollectionPlace } from '@trek/shared';
import { useAuthStore } from '../../store/authStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import { useTranslation } from '../../i18n/TranslationContext';
import CollectionList from './CollectionList';
// A saved-place row calls PlaceAvatar, which reads placesPhotosEnabled from the
// auth store. Seeding it false (and leaving image_url unset) keeps the avatar a
// plain category icon — no photoService fetch, no network in the test.
// Two inline CollectionPlace literals: one categorised (idea), one bare (want).
const cafe = {
id: 101,
collection_id: 1,
name: 'Blue Bottle Coffee',
address: '123 Market St, San Francisco',
status: 'idea',
category: { id: 5, name: 'Cafe', color: '#f59e0b', icon: 'Coffee' },
image_url: null,
} as unknown as CollectionPlace;
const bridge = {
id: 202,
collection_id: 1,
name: 'Golden Gate Bridge',
address: 'Golden Gate, San Francisco',
status: 'want',
image_url: null,
} as unknown as CollectionPlace;
const places = [cafe, bridge];
// Grab the real English translation fn so status labels match visible strings.
function TFnProbe({ onReady }: { onReady: (t: ReturnType<typeof useTranslation>['t']) => void }) {
const { t } = useTranslation();
onReady(t);
return null;
}
let t: ReturnType<typeof useTranslation>['t'];
render(<TFnProbe onReady={fn => { t = fn; }} />);
interface Handlers {
onOpenPlace: ReturnType<typeof vi.fn>;
onStatusChange: ReturnType<typeof vi.fn>;
onToggleSelect: ReturnType<typeof vi.fn>;
}
function renderList(over: Partial<{
selectMode: boolean;
selectedIds: number[];
selectedPlaceId: number | null;
onStatusChange: ((placeId: number, status: string) => void) | undefined;
}> = {}, handlers?: Handlers) {
const h = handlers ?? {
onOpenPlace: vi.fn(),
onStatusChange: vi.fn(),
onToggleSelect: vi.fn(),
};
const onStatusChange = 'onStatusChange' in over ? over.onStatusChange : h.onStatusChange;
render(
<CollectionList
places={places}
selectedPlaceId={over.selectedPlaceId ?? null}
selectMode={over.selectMode ?? false}
selectedIds={over.selectedIds ?? []}
onOpenPlace={h.onOpenPlace as (id: number) => void}
onStatusChange={onStatusChange as never}
onToggleSelect={h.onToggleSelect as (id: number) => void}
t={t}
/>,
);
return h;
}
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), placesPhotosEnabled: false });
});
describe('CollectionList', () => {
it('FE-COMP-COLLIST-001: renders both place names', () => {
renderList();
expect(screen.getByText('Blue Bottle Coffee')).toBeInTheDocument();
expect(screen.getByText('Golden Gate Bridge')).toBeInTheDocument();
});
it('FE-COMP-COLLIST-002: renders both place addresses', () => {
renderList();
expect(screen.getByText('123 Market St, San Francisco')).toBeInTheDocument();
expect(screen.getByText('Golden Gate, San Francisco')).toBeInTheDocument();
});
it('FE-COMP-COLLIST-003: renders the category name for the categorised place', () => {
renderList();
expect(screen.getByText('Cafe')).toBeInTheDocument();
});
it('FE-COMP-COLLIST-004: clicking a row calls onOpenPlace with the place id', async () => {
const user = userEvent.setup();
const h = renderList();
const row = screen.getByText('Blue Bottle Coffee').closest('.col-lrow') as HTMLElement;
await user.click(row);
expect(h.onOpenPlace).toHaveBeenCalledWith(101);
expect(h.onToggleSelect).not.toHaveBeenCalled();
});
it('FE-COMP-COLLIST-005: in select mode a row click calls onToggleSelect instead of onOpenPlace', async () => {
const user = userEvent.setup();
const h = renderList({ selectMode: true });
const row = screen.getByText('Golden Gate Bridge').closest('.col-lrow') as HTMLElement;
await user.click(row);
expect(h.onToggleSelect).toHaveBeenCalledWith(202);
expect(h.onOpenPlace).not.toHaveBeenCalled();
});
it('FE-COMP-COLLIST-006: the status badge is an interactive button when onStatusChange is provided', () => {
renderList();
// idea → label "Idea"; the badge is a role=button span with aria-label = label.
expect(screen.getByRole('button', { name: 'Idea' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Want to go' })).toBeInTheDocument();
});
it('FE-COMP-COLLIST-007: clicking the interactive badge cycles the status without opening the place', async () => {
const user = userEvent.setup();
const h = renderList();
await user.click(screen.getByRole('button', { name: 'Idea' }));
// idea → want, and the badge stops propagation so the row does not open.
expect(h.onStatusChange).toHaveBeenCalledWith(101, 'want');
expect(h.onOpenPlace).not.toHaveBeenCalled();
});
it('FE-COMP-COLLIST-008: the status badge is read-only (not a button) when onStatusChange is undefined', () => {
renderList({ onStatusChange: undefined });
// Label text still renders...
expect(screen.getByText('Idea')).toBeInTheDocument();
// ...but there is no interactive status button for it.
expect(screen.queryByRole('button', { name: 'Idea' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Want to go' })).not.toBeInTheDocument();
});
it('FE-COMP-COLLIST-009: in select mode the status badge is read-only even with onStatusChange provided', () => {
renderList({ selectMode: true });
expect(screen.getByText('Idea')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Idea' })).not.toBeInTheDocument();
});
it('FE-COMP-COLLIST-010: renders one clickable row per place', () => {
renderList();
// Each place contributes a role=button row; badges add their own buttons too,
// but every place name must sit inside a .col-lrow row element.
expect(screen.getByText('Blue Bottle Coffee').closest('.col-lrow')).toBeInTheDocument();
expect(screen.getByText('Golden Gate Bridge').closest('.col-lrow')).toBeInTheDocument();
});
});
@@ -0,0 +1,82 @@
import React, { useEffect, useRef } from 'react'
import { Check, MapPin } from 'lucide-react'
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
import type { TranslationFn } from '../../types'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import StatusBadge from './StatusBadge'
interface CollectionListProps {
places: CollectionPlace[]
selectedPlaceId: number | null
selectMode: boolean
selectedIds: number[]
onOpenPlace: (id: number) => void
onStatusChange?: (placeId: number, status: CollectionStatus) => void
onToggleSelect: (id: number) => void
t: TranslationFn
}
/**
* List view one glass row per saved place with a photo avatar, name +
* category/address, and a one-tap status cycle on the badge. Click the row to
* open the place (or toggle it in select mode).
*/
export default function CollectionList({
places, selectedPlaceId, selectMode, selectedIds, onOpenPlace, onStatusChange, onToggleSelect, t,
}: CollectionListProps): React.ReactElement {
// Bring the selected row into view — e.g. when it was picked from the map.
const selectedRef = useRef<HTMLDivElement>(null)
useEffect(() => {
selectedRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, [selectedPlaceId])
return (
<div className="col-listview">
{places.map(place => {
const selected = selectedIds.includes(place.id)
const active = selectedPlaceId === place.id
return (
<div
key={place.id}
ref={active ? selectedRef : undefined}
role="button"
tabIndex={0}
onClick={() => (selectMode ? onToggleSelect(place.id) : onOpenPlace(place.id))}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (selectMode) onToggleSelect(place.id); else onOpenPlace(place.id) } }}
className={`col-lrow${active || selected ? ' sel' : ''}`}
>
{selectMode ? (
<span className={`col-lcheck${selected ? ' on' : ''}`}>{selected && <Check size={14} strokeWidth={3} />}</span>
) : (
<PlaceAvatar place={place} size={40} category={place.category ? { color: place.category.color ?? undefined, icon: place.category.icon ?? undefined } : null} />
)}
<div className="li">
<div className="t">{place.name}</div>
{place.address && (
<div className="s">
<MapPin size={11} />
<span>{place.address}</span>
</div>
)}
</div>
<div className="col-lrow-end">
{place.category?.name && (() => {
const CatIcon = getCategoryIcon(place.category.icon ?? undefined)
return (
<>
<span className="col-lrow-cat" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
<CatIcon size={11} /> {place.category.name}
</span>
<span className="col-lrow-div" aria-hidden />
</>
)
})()}
<StatusBadge status={place.status} onChange={selectMode || !onStatusChange ? undefined : next => onStatusChange(place.id, next)} t={t} />
</div>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,45 @@
import React from 'react'
import { MapViewAuto } from '../Map/MapViewAuto'
import type { CollectionPlace } from '@trek/shared'
import { mappablePlaces } from '../../pages/collections/collectionsModel'
interface CollectionMapProps {
places: CollectionPlace[]
selectedPlaceId: number | null
onOpenPlace: (id: number) => void
/** Clicking the map background clears the selection. */
onDeselect?: () => void
dark: boolean
}
/**
* Map view reuses the trip map stack (MapViewAuto Leaflet / GL with marker
* clustering). One of the three list views; clicking a marker selects the place.
* The parent `.col-mapwrap` supplies the rounded, bordered box + height, so this
* just fills it.
*/
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark }: CollectionMapProps): React.ReactElement {
const pts = mappablePlaces(places)
const center: [number, number] = pts.length > 0
? [pts[0].lat as number, pts[0].lng as number]
: [48.8566, 2.3522]
const tileUrl = dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
return (
<div style={{ width: '100%', height: '100%' }}>
<MapViewAuto
places={pts}
selectedPlaceId={selectedPlaceId}
hoverDisabled
onMarkerClick={onOpenPlace}
onMapClick={onDeselect ? () => onDeselect() : undefined}
center={center}
zoom={pts.length > 0 ? 6 : 3}
tileUrl={tileUrl}
fitKey={pts.length}
/>
</div>
)
}
@@ -0,0 +1,72 @@
import React from 'react'
import { PanelLeftClose, PanelLeftOpen, Search, Plus } from 'lucide-react'
import type { CollectionPlace } from '@trek/shared'
import type { TranslationFn } from '../../types'
import CollectionMap from './CollectionMap'
interface CollectionMapPanelProps {
places: CollectionPlace[]
selectedPlaceId: number | null
onSelect: (id: number) => void
onDeselect: () => void
dark: boolean
/** Render the floating map controls (desktop). Mobile drives view from the toolbar. */
overlay: boolean
/** 'list' = split (map can be expanded); 'map' = full (list collapsed). */
view: 'list' | 'map'
onToggleView: () => void
/** Show a "+" to add a place to the current list (real lists only). */
canAddPlace: boolean
onAddPlace: () => void
search: string
onSearch: (v: string) => void
t: TranslationFn
}
/**
* The map surface for the collections page the map plus its floating controls:
* a top-left cluster (collapse/expand the list, toggle bulk-select) and a
* top-right search box. Used both in the desktop split and the full-map view.
*/
export default function CollectionMapPanel({
places, selectedPlaceId, onSelect, onDeselect, dark, overlay, view, onToggleView,
canAddPlace, onAddPlace, search, onSearch, t,
}: CollectionMapPanelProps): React.ReactElement {
return (
<div className="col-map-shell">
<CollectionMap
places={places}
selectedPlaceId={selectedPlaceId}
onOpenPlace={onSelect}
onDeselect={onDeselect}
dark={dark}
/>
{overlay && (
<div className="col-map-topbar">
<div className="col-map-group">
<button
type="button"
onClick={onToggleView}
className="col-map-btn"
aria-label={view === 'map' ? t('collections.showList') : t('collections.expandMap')}
title={view === 'map' ? t('collections.showList') : t('collections.expandMap')}
>
{view === 'map' ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
</button>
</div>
<div className="col-map-group right">
{canAddPlace && (
<button type="button" onClick={onAddPlace} className="col-map-btn" aria-label={t('collections.addPlace')} title={t('collections.addPlace')}>
<Plus size={17} />
</button>
)}
<div className="col-map-search">
<Search size={15} />
<input value={search} onChange={e => onSearch(e.target.value)} placeholder={t('collections.search')} />
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,198 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Search, Bookmark, Loader2, ChevronDown, Check, Layers } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { collectionsApi } from '../../api/collections'
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
import type { TranslationFn } from '../../types'
interface LocationBias {
low: { lat: number; lng: number }
high: { lat: number; lng: number }
}
interface ListMeta { id: number; name: string; color: string | null }
interface CollectionPickerProps {
/** Trip bounding box used for autocomplete sorts the saved places by
* proximity to the trip so the relevant ones surface first. */
bias?: LocationBias
/** Fills the place form from the chosen saved place (handleSelectMapsResult). */
onSelect: (place: CollectionPlace) => void
t: TranslationFn
}
function distanceTo(p: CollectionPlace, center: { lat: number; lng: number }): number {
if (p.lat == null || p.lng == null) return Number.POSITIVE_INFINITY
const dlat = p.lat - center.lat
const dlng = p.lng - center.lng
return dlat * dlat + dlng * dlng
}
interface Opt { key: string | number; label: string; icon?: React.ReactNode; count?: number }
/** Compact click-away dropdown (Tailwind — this panel lives outside .trek-dash). */
function FilterDropdown({ current, options, onSelect, lead }: {
current: string | number
options: Opt[]
onSelect: (key: string | number) => void
lead: React.ReactNode
}): React.ReactElement {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onDoc)
document.addEventListener('keydown', onKey)
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey) }
}, [open])
const cur = options.find(o => o.key === current) ?? options[0]
return (
<div className="relative min-w-0 flex-1" ref={ref}>
<button type="button" onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}
className={`w-full flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border bg-surface-input text-[12px] font-medium text-content-secondary transition-colors ${open ? 'border-accent' : 'border-edge hover:bg-surface-hover'}`}>
<span className="shrink-0 text-content-faint">{cur.icon ?? lead}</span>
<span className="flex-1 min-w-0 truncate text-left">{cur.label}</span>
<ChevronDown size={13} className="shrink-0 text-content-faint" />
</button>
{open && (
<div role="listbox" className="absolute z-30 left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
{options.map(o => (
<button key={o.key} type="button" role="option" aria-selected={o.key === current} onClick={() => { onSelect(o.key); setOpen(false) }}
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg text-[12.5px] text-left transition-colors hover:bg-surface-hover ${o.key === current ? 'text-content font-semibold' : 'text-content-secondary'}`}>
<span className="shrink-0 text-content-faint">{o.icon}</span>
<span className="flex-1 min-w-0 truncate">{o.label}</span>
{o.count != null && <span className="shrink-0 text-[11px] text-content-faint tabular-nums">{o.count}</span>}
{o.key === current && <Check size={13} className="shrink-0 text-accent" />}
</button>
))}
</div>
)}
</div>
)
}
/**
* Right-hand column of the desktop add-place modal: the user's saved collection
* places, searchable, filterable by list + status, and proximity-sorted, so a
* place saved on an earlier trip can be dropped straight into the form.
* Desktop only gated by the caller.
*/
export default function CollectionPicker({ bias, onSelect, t }: CollectionPickerProps): React.ReactElement {
const [places, setPlaces] = useState<CollectionPlace[]>([])
const [lists, setLists] = useState<ListMeta[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [listFilter, setListFilter] = useState<number | 'all'>('all')
const [statusFilter, setStatusFilter] = useState<CollectionStatus | 'all'>('all')
useEffect(() => {
let cancelled = false
setLoading(true)
collectionsApi.list()
.then(async (res) => {
const detail = await Promise.all(res.collections.map(c => collectionsApi.get(c.id).catch(() => null)))
if (cancelled) return
const merged: CollectionPlace[] = []
for (const d of detail) {
if (!d) continue
for (const p of d.places) merged.push(p)
}
setLists(res.collections.map(c => ({ id: c.id, name: c.name, color: c.color ?? null })))
setPlaces(merged)
})
.catch(() => { if (!cancelled) { setPlaces([]); setLists([]) } })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [])
const center = useMemo(
() => (bias ? { lat: (bias.low.lat + bias.high.lat) / 2, lng: (bias.low.lng + bias.high.lng) / 2 } : null),
[bias],
)
const visible = useMemo(() => {
const q = search.trim().toLowerCase()
const list = places.filter(p => {
if (listFilter !== 'all' && p.collection_id !== listFilter) return false
if (statusFilter !== 'all' && p.status !== statusFilter) return false
if (!q) return true
return p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q)
})
if (center) list.sort((a, b) => distanceTo(a, center) - distanceTo(b, center))
else list.sort((a, b) => a.name.localeCompare(b.name))
return list
}, [places, search, center, listFilter, statusFilter])
const listOpts: Opt[] = [
{ key: 'all', label: t('collections.picker.allLists'), icon: <Layers size={13} />, count: places.length },
...lists.map(l => ({
key: l.id,
label: l.name,
icon: <span className="w-2.5 h-2.5 rounded-full inline-block" style={{ background: l.color || '#6366f1' }} />,
count: places.filter(p => p.collection_id === l.id).length,
})),
]
const statusOpts: Opt[] = [
{ key: 'all', label: t('common.all') },
...STATUS_ORDER.map(s => {
const Icon = STATUS_META[s].icon
return { key: s, label: t(STATUS_META[s].labelKey), icon: <Icon size={13} style={{ color: STATUS_META[s].color }} /> }
}),
]
return (
<aside className="w-full sm:w-64 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
<Bookmark size={15} className="text-accent" />
<span className="text-[13px] font-semibold text-content">{t('collections.picker.title')}</span>
</div>
<div className="p-2.5 flex flex-col gap-2 shrink-0">
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.picker.search')}
className="w-full pl-8 pr-3 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
</div>
{lists.length > 0 && (
<div className="flex gap-2">
<FilterDropdown current={listFilter} options={listOpts} onSelect={k => setListFilter(k as number | 'all')} lead={<Layers size={13} />} />
<FilterDropdown current={statusFilter} options={statusOpts} onSelect={k => setStatusFilter(k as CollectionStatus | 'all')} lead={<Bookmark size={13} />} />
</div>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-2 pb-2">
{loading ? (
<div className="flex items-center justify-center py-10 text-content-faint">
<Loader2 size={18} className="animate-spin" />
</div>
) : visible.length === 0 ? (
<p className="text-center text-[12px] text-content-faint py-10 px-3">{t('collections.picker.empty')}</p>
) : (
<div className="flex flex-col gap-1">
{visible.map(place => (
<button
key={place.id}
type="button"
onClick={() => onSelect(place)}
title={t('collections.picker.use')}
className="flex items-center gap-2.5 px-2 py-2 rounded-lg text-left hover:bg-surface-hover transition-colors"
>
<PlaceAvatar place={place} size={32} category={place.category ? { color: place.category.color ?? undefined, icon: place.category.icon ?? undefined } : null} />
<span className="flex flex-col min-w-0">
<span className="text-[12.5px] font-medium text-content truncate">{place.name}</span>
{place.address && <span className="text-[11px] text-content-faint truncate">{place.address}</span>}
</span>
</button>
))}
</div>
)}
</div>
</aside>
)
}
@@ -0,0 +1,141 @@
// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-010
import React from 'react';
import { render, screen } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import type { CollectionPlace } from '@trek/shared';
import { useTranslation } from '../../i18n/TranslationContext';
import CollectionPlaceDetail from './CollectionPlaceDetail';
// The component takes `t` as a PROP (not from context), so wrap it in a tiny
// consumer that feeds it the real English `t` from the TranslationProvider the
// test render helper mounts. That way we can assert on visible English strings.
type DetailProps = React.ComponentProps<typeof CollectionPlaceDetail>;
function TranslatedDetail(props: Omit<DetailProps, 't'>): React.ReactElement {
const { t } = useTranslation();
return <CollectionPlaceDetail {...props} t={t} />;
}
// Place literal per spec: no image_url / lat / lng / provider id, so the cover
// stays a gradient and status is 'idea'.
const place: CollectionPlace = {
id: 1,
collection_id: 10,
name: 'Test Cafe',
status: 'idea',
description: 'Nice spot',
address: 'Somewhere',
links: [{ url: 'https://x.com' }],
category: { id: 1, name: 'Food', color: '#f00', icon: null },
};
function renderDetail(overrides: Partial<Omit<DetailProps, 't'>> = {}) {
const props = {
place,
canEdit: true,
canDelete: true,
categories: [],
anchorRect: null,
onClose: vi.fn(),
onSetStatus: vi.fn(),
onSave: vi.fn().mockResolvedValue(undefined),
onCopyToTrip: vi.fn(),
onRemove: vi.fn(),
...overrides,
} as Omit<DetailProps, 't'>;
render(<TranslatedDetail {...props} />);
return props;
}
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), placesPhotosEnabled: false });
// The detail sheet asks the maps provider for a cover photo on mount when a
// place carries no image of its own — stub it so nothing hits the network.
server.use(
http.get('/api/maps/place-photo/:id', () =>
HttpResponse.json({ photoUrl: null, attribution: null }),
),
);
});
describe('CollectionPlaceDetail', () => {
it('FE-COMP-COLDETAIL-001: renders the place name, address and description', async () => {
renderDetail();
expect(await screen.findByRole('heading', { name: 'Test Cafe' })).toBeInTheDocument();
expect(screen.getByText('Somewhere')).toBeInTheDocument();
expect(screen.getByText('Nice spot')).toBeInTheDocument();
});
// ── Editor / admin (canEdit + canDelete) ────────────────────────────────────
it('FE-COMP-COLDETAIL-002: shows Edit and Remove buttons when canEdit && canDelete', async () => {
renderDetail({ canEdit: true, canDelete: true });
expect(await screen.findByRole('button', { name: 'Edit' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove from list' })).toBeInTheDocument();
});
it('FE-COMP-COLDETAIL-003: clicking a status option calls onSetStatus when canEdit', async () => {
const user = userEvent.setup();
const props = renderDetail({ canEdit: true, canDelete: true });
const visited = await screen.findByRole('button', { name: 'Visited' });
await user.click(visited);
expect(props.onSetStatus).toHaveBeenCalledTimes(1);
expect(props.onSetStatus).toHaveBeenCalledWith('visited');
});
it('FE-COMP-COLDETAIL-004: current status option is pressed, others are not', async () => {
renderDetail({ canEdit: true, canDelete: true });
expect(await screen.findByRole('button', { name: 'Idea' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Want to go' })).toHaveAttribute('aria-pressed', 'false');
});
it('FE-COMP-COLDETAIL-005: entering edit mode reveals name input + Save', async () => {
const user = userEvent.setup();
renderDetail({ canEdit: true });
await user.click(await screen.findByRole('button', { name: 'Edit' }));
expect(screen.getByDisplayValue('Test Cafe')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save/i })).toBeInTheDocument();
});
// ── Viewer (no edit / no delete) ────────────────────────────────────────────
it('FE-COMP-COLDETAIL-006: hides Edit and Remove buttons when canEdit=false && canDelete=false', async () => {
renderDetail({ canEdit: false, canDelete: false });
// Wait for the async photo effect to settle before asserting absence.
expect(await screen.findByRole('button', { name: 'Copy to trip' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Remove from list' })).not.toBeInTheDocument();
});
it('FE-COMP-COLDETAIL-007: clicking a status option does NOT call onSetStatus when canEdit=false', async () => {
const user = userEvent.setup();
const props = renderDetail({ canEdit: false, canDelete: false });
const visited = await screen.findByRole('button', { name: 'Visited' });
await user.click(visited);
expect(props.onSetStatus).not.toHaveBeenCalled();
});
it('FE-COMP-COLDETAIL-008: status segment still renders (read-only) for viewers', async () => {
renderDetail({ canEdit: false, canDelete: false });
expect(await screen.findByRole('button', { name: 'Idea' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Visited' })).toBeInTheDocument();
});
// ── Copy-to-trip is available in read mode regardless of permissions ─────────
it('FE-COMP-COLDETAIL-009: Copy to trip button fires onCopyToTrip (editor)', async () => {
const user = userEvent.setup();
const props = renderDetail({ canEdit: true, canDelete: true });
await user.click(await screen.findByRole('button', { name: 'Copy to trip' }));
expect(props.onCopyToTrip).toHaveBeenCalledTimes(1);
});
it('FE-COMP-COLDETAIL-010: Copy to trip button fires onCopyToTrip (viewer)', async () => {
const user = userEvent.setup();
const props = renderDetail({ canEdit: false, canDelete: false });
await user.click(await screen.findByRole('button', { name: 'Copy to trip' }));
expect(props.onCopyToTrip).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,222 @@
import React, { useEffect, useRef, useState } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag } from 'lucide-react'
import type { CollectionPlace, CollectionStatus, CollectionLink } from '@trek/shared'
import type { Category, TranslationFn } from '../../types'
import MarkdownToolbar from '../Journey/MarkdownToolbar'
import { mapsApi } from '../../api/client'
import { entityGradient } from '../../utils/gradients'
import { getCategoryIcon } from '../shared/categoryIcons'
import { STATUS_META, STATUS_ORDER, normalizeLinkUrl } from '../../pages/collections/collectionsModel'
import { useToast } from '../shared/Toast'
import { getApiErrorMessage } from '../../types'
function linkHost(url: string): string {
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
}
interface CollectionPlaceDetailProps {
place: CollectionPlace
canEdit: boolean
canDelete: boolean
categories: Category[]
/** When set, dock the sheet over that column (desktop split) instead of centred. */
anchorRect?: { left: number; width: number } | null
onClose: () => void
onSetStatus: (status: CollectionStatus) => void
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null }) => Promise<void>
onCopyToTrip: () => void
onRemove: () => void
t: TranslationFn
}
function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet: (s: CollectionStatus) => void; t: TranslationFn }): React.ReactElement {
return (
<div className="col-detail-seg" role="group">
{STATUS_ORDER.map(s => {
const Icon = STATUS_META[s].icon
const on = status === s
return (
<button key={s} type="button" aria-pressed={on} onClick={() => onSet(s)} className={on ? 'on' : ''}>
<Icon size={14} style={{ color: on ? STATUS_META[s].color : undefined }} /> {t(STATUS_META[s].labelKey)}
</button>
)
})}
</div>
)
}
/**
* Bottom detail sheet for a saved place an opaque, clearly-sectioned card
* (cover meta status description links) docked over the list column.
* Read mode renders the description as markdown + link chips; edit mode swaps in
* name / category / markdown description / links, saving via updatePlace. Status
* is an always-live segmented control (auto-saves).
*/
export default function CollectionPlaceDetail({
place, canEdit, canDelete, categories, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
}: CollectionPlaceDetailProps): React.ReactElement {
const toast = useToast()
const [editing, setEditing] = useState(false)
const [name, setName] = useState(place.name)
const [categoryId, setCategoryId] = useState<number | null>(place.category_id ?? null)
const [description, setDescription] = useState(place.description ?? '')
const [links, setLinks] = useState<CollectionLink[]>(place.links ?? [])
const [saving, setSaving] = useState(false)
// A higher-res photo pulled from the maps provider when the place has none of
// its own — the list avatar's little thumbnail is too low-res for the cover.
const [fetchedPhoto, setFetchedPhoto] = useState<string | null>(null)
const descRef = useRef<HTMLTextAreaElement>(null)
// Reset only when a DIFFERENT place is opened (keyed on id, not on every field).
useEffect(() => {
setEditing(false)
setName(place.name)
setCategoryId(place.category_id ?? null)
setDescription(place.description ?? '')
setLinks(place.links ?? [])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [place.id])
// Fetch a cover photo when the place doesn't carry its own image.
useEffect(() => {
setFetchedPhoto(null)
if (place.image_url) return
const photoId = place.google_place_id || place.osm_id || (place.lat != null && place.lng != null ? `${place.lat},${place.lng}` : null)
if (!photoId) return
let cancelled = false
mapsApi.placePhoto(photoId, place.lat ?? undefined, place.lng ?? undefined, place.name)
.then(res => { if (!cancelled && res?.photoUrl) setFetchedPhoto(res.photoUrl) })
.catch(() => {})
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [place.id])
const banner = place.image_url || fetchedPhoto
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []) }
const save = async () => {
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
setSaving(true)
try {
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId })
setEditing(false)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setSaving(false)
}
}
const dockStyle = anchorRect ? { left: anchorRect.left, width: anchorRect.width, transform: 'none' as const } : undefined
const CatIcon = getCategoryIcon(place.category?.icon)
return (
<div className={`col-detail${anchorRect ? ' docked' : ''}`} style={dockStyle} onClick={e => e.stopPropagation()}>
<div className="col-detail-cover" style={banner ? undefined : { backgroundImage: entityGradient(place.id) }}>
{banner && <img src={banner} alt="" />}
<div className="col-detail-cover-scrim" />
{place.category?.name && (
<span className="col-detail-cover-cat" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
<CatIcon size={12} /> {place.category.name}
</span>
)}
<button type="button" className="col-detail-close" onClick={onClose} aria-label={t('common.close')}><X size={16} /></button>
<div className="col-detail-head">
{editing
? <input value={name} onChange={e => setName(e.target.value)} className="col-detail-name-input" autoFocus aria-label={t('collections.listName')} />
: <h2 className="col-detail-name">{place.name}</h2>}
</div>
</div>
<div className="col-detail-body">
{/* Meta (view only) */}
{!editing && place.address && (
<div className="col-detail-meta">
<span className="col-detail-addr"><MapPin size={12} /> {place.address}</span>
</div>
)}
{/* Status — live for editors, read-only for viewers */}
<StatusSegment status={place.status} onSet={canEdit ? onSetStatus : () => {}} t={t} />
{editing ? (
<div className="col-detail-edit">
{/* Category */}
<div className="col-detail-field">
<div className="col-detail-label"><Tag size={12} /> {t('collections.category')}</div>
<div className="col-detail-cats">
<button type="button" onClick={() => setCategoryId(null)} className={`col-detail-cat${categoryId == null ? ' on' : ''}`}>{t('collections.noCategory')}</button>
{categories.map(cat => {
const Icon = getCategoryIcon(cat.icon ?? undefined)
const on = categoryId === cat.id
return (
<button key={cat.id} type="button" onClick={() => setCategoryId(cat.id)} className={`col-detail-cat${on ? ' on' : ''}`} style={{ ['--cat' as string]: cat.color || '#6366f1' }}>
<Icon size={12} /> {cat.name}
</button>
)
})}
</div>
</div>
{/* Description */}
<div className="col-detail-field">
<div className="col-detail-label">{t('collections.description')}</div>
<MarkdownToolbar textareaRef={descRef} onUpdate={setDescription} />
<textarea ref={descRef} value={description} onChange={e => setDescription(e.target.value)} rows={4} placeholder={t('collections.descriptionPlaceholder')} className="col-detail-textarea" />
</div>
{/* Links */}
<div className="col-detail-field">
<div className="col-detail-label">{t('collections.links')}</div>
<div className="col-detail-links-edit">
{links.map((l, i) => (
<div key={i} className="col-detail-link-row">
<input value={l.label ?? ''} onChange={e => setLink(i, { label: e.target.value })} placeholder={t('collections.linkLabel')} className="col-detail-input w-28" />
<input value={l.url} onChange={e => setLink(i, { url: e.target.value })} placeholder="https://…" className="col-detail-input flex-1" />
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} className="col-detail-icon-btn" aria-label={t('common.delete')}><Trash2 size={14} /></button>
</div>
))}
<button type="button" onClick={() => setLinks([...links, { url: '' }])} className="col-detail-add-link"><Plus size={13} /> <Link2 size={12} /> {t('collections.addLink')}</button>
</div>
</div>
</div>
) : (
<>
{place.description && (
<div className="col-detail-md collab-note-md">
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description}</Markdown>
</div>
)}
{place.links && place.links.length > 0 && (
<div className="col-detail-links">
{place.links.map((l, i) => (
<a key={i} href={l.url} target="_blank" rel="noopener noreferrer" className="col-detail-link">
<ExternalLink size={13} /> {l.label || linkHost(l.url)}
</a>
))}
</div>
)}
</>
)}
</div>
<div className="col-detail-footer">
{editing ? (
<>
<button type="button" onClick={resetForm} className="col-detail-btn">{t('common.cancel')}</button>
<button type="button" onClick={save} disabled={saving} className="col-detail-btn primary"><Check size={14} /> {t('common.save')}</button>
</>
) : (
<>
{canEdit && <button type="button" onClick={() => setEditing(true)} className="col-detail-btn"><Pencil size={14} /> {t('common.edit')}</button>}
<button type="button" onClick={onCopyToTrip} className="col-detail-btn"><Copy size={14} /> {t('collections.copyToTrip')}</button>
<div className="col-detail-footer-spacer" />
{canDelete && <button type="button" onClick={onRemove} className="col-detail-btn danger"><Trash2 size={14} /> {t('collections.removeFromList')}</button>}
</>
)}
</div>
</div>
)
}
@@ -0,0 +1,148 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Search, MapPin, Loader2, Copy, CalendarDays } from 'lucide-react'
import Modal from '../shared/Modal'
import { useToast } from '../shared/Toast'
import { tripsApi } from '../../api/client'
import { getApiErrorMessage } from '../../utils/apiError'
import { formatDate } from '../../utils/formatters'
import { useTranslation } from '../../i18n'
import type { TranslationFn } from '../../types'
interface TripOption {
id: number
title: string
start_date?: string | null
end_date?: string | null
cover_image?: string | null
}
interface CopyToTripModalProps {
isOpen: boolean
onClose: () => void
/** The collection place ids to copy. */
placeIds: number[]
/** Delegates to collectionStore.copyToTrip; returns the server reconcile result. */
onCopy: (tripId: number) => Promise<{ copied: number; skipped: { id: number; name: string }[] }>
t: TranslationFn
}
/**
* Trip picker for "Copy to trip" lists the user's trips (searchable), copies
* the selected collection places into the chosen trip and reconciles the server
* dedup result into a copied / skipped-duplicates toast. Works for a single
* place (detail panel) and bulk select-mode ("Copy N to trip").
*/
export default function CopyToTripModal({ isOpen, onClose, placeIds, onCopy, t }: CopyToTripModalProps): React.ReactElement | null {
const toast = useToast()
const { language } = useTranslation()
const [trips, setTrips] = useState<TripOption[]>([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [busyTripId, setBusyTripId] = useState<number | null>(null)
useEffect(() => {
if (!isOpen) return
let cancelled = false
setLoading(true)
setSearch('')
tripsApi.list()
.then((res: { trips?: TripOption[] }) => { if (!cancelled) setTrips(res.trips ?? []) })
.catch(() => { if (!cancelled) setTrips([]) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [isOpen])
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return trips
return trips.filter(tr => (tr.title ?? '').toLowerCase().includes(q))
}, [trips, search])
const dateRange = (tr: TripOption): string => {
const s = formatDate(tr.start_date, language)
const e = formatDate(tr.end_date, language)
if (s && e) return `${s} ${e}`
return s || e || ''
}
if (!isOpen) return null
const handleCopy = async (tripId: number) => {
if (busyTripId != null || placeIds.length === 0) return
setBusyTripId(tripId)
try {
const res = await onCopy(tripId)
if (res.copied > 0) {
toast.success(t('collections.copiedCount', { count: res.copied }))
}
if (res.skipped.length > 0) {
toast.info(t('collections.skippedDuplicates', { count: res.skipped.length }))
}
if (res.copied === 0 && res.skipped.length === 0) {
toast.info(t('collections.copyNothing'))
}
onClose()
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setBusyTripId(null)
}
}
return (
<Modal
isOpen
onClose={onClose}
title={placeIds.length > 1 ? t('collections.copyN', { count: placeIds.length }) : t('collections.copyToTripTitle')}
size="sm"
>
<div className="flex flex-col gap-3">
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.copyToTripSearch')}
className="w-full pl-8 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
</div>
{loading ? (
<div className="flex items-center justify-center py-10 text-content-faint">
<Loader2 size={20} className="animate-spin" />
</div>
) : filtered.length === 0 ? (
<p className="text-center text-[13px] text-content-faint py-10">{t('collections.noTrips')}</p>
) : (
<div className="flex flex-col gap-1 max-h-[50vh] overflow-y-auto -mx-1 px-1">
{filtered.map(trip => {
const busy = busyTripId === trip.id
return (
<button
key={trip.id}
type="button"
onClick={() => handleCopy(trip.id)}
disabled={busy}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-card text-left hover:bg-surface-hover transition-colors disabled:opacity-60"
>
<span className="w-9 h-9 rounded-lg bg-surface-secondary flex items-center justify-center shrink-0 overflow-hidden text-content-faint">
{trip.cover_image ? <img src={trip.cover_image} alt="" className="w-full h-full object-cover" /> : <MapPin size={15} />}
</span>
<span className="flex-1 min-w-0">
<span className="block text-[13px] font-medium text-content truncate">{trip.title}</span>
{dateRange(trip) && (
<span className="flex items-center gap-1 text-[11.5px] text-content-faint truncate">
<CalendarDays size={11} className="shrink-0" /> {dateRange(trip)}
</span>
)}
</span>
{busy ? <Loader2 size={15} className="animate-spin text-content-faint shrink-0" /> : <Copy size={15} className="text-content-faint shrink-0" />}
</button>
)
})}
</div>
)}
</div>
</Modal>
)
}
@@ -0,0 +1,310 @@
import React, { useEffect, useRef, useState } from 'react'
import { ImagePlus, Link2, Plus, Trash2, Search, Loader2 } from 'lucide-react'
import Modal from '../shared/Modal'
import { useCollectionStore } from '../../store/collectionStore'
import { useToast } from '../shared/Toast'
import { tripsApi } from '../../api/client'
import { getApiErrorMessage } from '../../types'
import { normalizeLinkUrl } from '../../pages/collections/collectionsModel'
import type { TranslationFn } from '../../types'
import type { Collection, CollectionLink } from '@trek/shared'
interface CoverSearchPhoto {
id: string
url: string
thumb: string
description?: string | null
photographer?: string | null
}
const SWATCHES = ['#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#ef4444', '#3b82f6', '#22c55e']
interface ListEditorModalProps {
/** null = closed, 'new' = create, a Collection = edit that list. */
target: Collection | 'new' | null
onClose: () => void
onCreated: (id: number) => void
/** Owner-only: hand off to the delete-confirm flow when editing a list. */
onRequestDelete: (id: number) => void
t: TranslationFn
}
/**
* Create / edit a list name, colour, an optional cover image (tinted with the
* list colour in the hero), a description and a set of links. On create it makes
* the list then uploads the cover to the new id; on edit it patches + re-uploads.
*/
export default function ListEditorModal({ target, onClose, onCreated, onRequestDelete, t }: ListEditorModalProps): React.ReactElement | null {
const createCollection = useCollectionStore(s => s.createCollection)
const updateCollection = useCollectionStore(s => s.updateCollection)
const uploadCover = useCollectionStore(s => s.uploadCover)
const toast = useToast()
const fileRef = useRef<HTMLInputElement>(null)
const editing = target && target !== 'new' ? target : null
const [name, setName] = useState('')
const [color, setColor] = useState('#6366f1')
const [description, setDescription] = useState('')
const [links, setLinks] = useState<CollectionLink[]>([])
const [coverFile, setCoverFile] = useState<File | null>(null)
const [coverPreview, setCoverPreview] = useState<string | null>(null)
const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState<string | null>(null)
const [coverQuery, setCoverQuery] = useState('')
const [coverResults, setCoverResults] = useState<CoverSearchPhoto[]>([])
const [searchingCover, setSearchingCover] = useState(false)
const coverSeq = useRef(0)
const [saving, setSaving] = useState(false)
// Remembers a freshly created list id so a retry after a cover-upload failure
// updates it instead of creating a duplicate list.
const [createdId, setCreatedId] = useState<number | null>(null)
const objectUrl = useRef<string | null>(null)
const dropObjectUrl = () => { if (objectUrl.current) { URL.revokeObjectURL(objectUrl.current); objectUrl.current = null } }
// (Re)seed the form whenever the target changes.
useEffect(() => {
if (!target) return
setName(editing?.name ?? '')
setColor(editing?.color ?? '#6366f1')
setDescription(editing?.description ?? '')
setLinks(editing?.links ?? [])
setCoverFile(null)
dropObjectUrl()
setCoverPreview(editing?.cover_image ?? null)
setPendingUnsplashUrl(null)
setCoverQuery('')
setCoverResults([])
setCreatedId(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target])
// Revoke the last preview blob on unmount.
useEffect(() => () => dropObjectUrl(), [])
if (!target) return null
const pickCover = (file: File | undefined) => {
if (!file) return
dropObjectUrl()
const url = URL.createObjectURL(file)
objectUrl.current = url
setCoverFile(file)
setCoverPreview(url)
setPendingUnsplashUrl(null) // an uploaded file wins over a picked Unsplash photo
}
const searchCover = async () => {
const query = coverQuery.trim() || name.trim()
if (!query) return
const seq = ++coverSeq.current
setSearchingCover(true)
try {
const data = await tripsApi.searchCoverImages(query)
if (seq !== coverSeq.current) return
setCoverResults(data.photos || [])
} catch {
if (seq === coverSeq.current) setCoverResults([])
} finally {
if (seq === coverSeq.current) setSearchingCover(false)
}
}
const pickUnsplash = (photo: CoverSearchPhoto) => {
if (!photo.url) return
dropObjectUrl()
setCoverFile(null)
setPendingUnsplashUrl(photo.url)
setCoverPreview(photo.url)
}
const setLink = (i: number, patch: Partial<CollectionLink>) =>
setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
const save = async () => {
const trimmed = name.trim()
if (!trimmed) return
// Normalise + keep only links with a url; drop blank rows.
const cleanLinks = links
.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) }))
.filter(l => l.url)
const payload = { name: trimmed, color, description: description.trim() || null, links: cleanLinks, ...(pendingUnsplashUrl ? { cover_image: pendingUnsplashUrl } : {}) }
setSaving(true)
try {
// A prior create that failed at the cover step left `createdId` set — reuse
// it so a retry updates that list instead of creating a duplicate.
let id = editing?.id ?? createdId
if (id != null) {
await updateCollection(id, payload)
} else {
const created = await createCollection(payload)
id = created?.id ?? null
setCreatedId(id)
}
if (id != null && coverFile) await uploadCover(id, coverFile)
if (!editing && id != null) onCreated(id)
onClose()
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setSaving(false)
}
}
return (
<Modal
isOpen
onClose={onClose}
title={editing ? t('collections.editListTitle') : t('collections.newList')}
size="md"
footer={
<div className="flex items-center gap-2">
{editing && editing.is_owner !== false && (
<button
type="button"
onClick={() => { onClose(); onRequestDelete(editing.id) }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-danger text-[13px] font-medium hover:bg-danger-soft"
>
<Trash2 size={14} /> {t('collections.deleteList')}
</button>
)}
<div className="flex justify-end gap-2 ml-auto">
<button type="button" onClick={onClose} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">
{t('common.cancel')}
</button>
<button type="button" onClick={save} disabled={!name.trim() || saving} className="px-3 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50">
{editing ? t('common.save') : t('collections.create')}
</button>
</div>
</div>
}
>
<div className="flex flex-col gap-4">
{/* Cover */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.coverImage')}</label>
<button
type="button"
onClick={() => fileRef.current?.click()}
className="relative w-full h-32 rounded-xl overflow-hidden border border-edge bg-surface-secondary flex items-center justify-center text-content-faint hover:border-accent transition-colors"
>
{coverPreview && <img src={coverPreview} alt="" className="absolute inset-0 w-full h-full object-cover" />}
<div className="absolute inset-0" style={{ background: `linear-gradient(135deg, ${color}55, transparent 70%)` }} />
<span className="relative flex items-center gap-2 text-[13px] font-medium" style={{ color: coverPreview ? '#fff' : undefined, textShadow: coverPreview ? '0 1px 4px rgba(0,0,0,.5)' : undefined }}>
<ImagePlus size={16} /> {coverPreview ? t('collections.changeCover') : t('collections.addCover')}
</span>
</button>
<input ref={fileRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" className="hidden" onChange={e => pickCover(e.target.files?.[0])} />
{/* Unsplash cover search — same source as trip creation */}
<div className="mt-2 flex gap-2">
<input
value={coverQuery}
onChange={e => setCoverQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); searchCover() } }}
placeholder={t('dashboard.unsplashSearchPlaceholder')}
className="flex-1 min-w-0 px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
<button
type="button"
onClick={searchCover}
disabled={searchingCover || (!coverQuery.trim() && !name.trim())}
className="px-3 py-2 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover disabled:opacity-40 inline-flex items-center gap-1.5 whitespace-nowrap"
>
{searchingCover ? <Loader2 size={14} className="animate-spin" /> : <Search size={14} />}
{t('dashboard.searchUnsplash')}
</button>
</div>
{coverResults.length > 0 && (
<div className="grid grid-cols-3 gap-2 mt-2">
{coverResults.map(photo => (
<button
type="button"
key={photo.id}
onClick={() => pickUnsplash(photo)}
aria-label={photo.photographer || 'Unsplash'}
className={`relative h-20 overflow-hidden rounded-lg border transition-colors ${coverPreview === photo.url ? 'border-accent ring-2 ring-accent/25' : 'border-edge hover:border-accent'}`}
>
<img src={photo.thumb} alt={photo.description || ''} loading="lazy" className="w-full h-full object-cover" />
{photo.photographer && (
<span className="absolute inset-x-0 bottom-0 truncate bg-black/55 px-1.5 py-1 text-[10px] text-white">{photo.photographer}</span>
)}
</button>
))}
</div>
)}
</div>
{/* Name */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.listName')}</label>
<input
autoFocus
value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && name.trim()) save() }}
placeholder={t('collections.listNamePlaceholder')}
className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent"
/>
</div>
{/* Colour */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-2">{t('collections.listColor')}</label>
<div className="flex gap-2 flex-wrap">
{SWATCHES.map(col => (
<button
key={col}
type="button"
onClick={() => setColor(col)}
className="w-7 h-7 rounded-full transition-transform hover:scale-110"
style={{ background: col, outline: color === col ? '2px solid var(--accent)' : 'none', outlineOffset: 2 }}
aria-label={col}
/>
))}
</div>
</div>
{/* Description */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.description')}</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={3}
placeholder={t('collections.descriptionPlaceholder')}
className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent resize-y"
/>
</div>
{/* Links */}
<div>
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('collections.links')}</label>
<div className="flex flex-col gap-2">
{links.map((l, i) => (
<div key={i} className="flex items-center gap-2">
<input
value={l.label ?? ''}
onChange={e => setLink(i, { label: e.target.value })}
placeholder={t('collections.linkLabel')}
className="w-32 shrink-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent"
/>
<input
value={l.url}
onChange={e => setLink(i, { url: e.target.value })}
placeholder="https://…"
className="flex-1 min-w-0 px-2.5 py-1.5 rounded-lg border border-edge bg-surface-input text-content text-[12.5px] outline-none focus:border-accent"
/>
<button type="button" onClick={() => setLinks(links.filter((_, idx) => idx !== i))} className="p-1.5 rounded-md text-content-faint hover:text-danger hover:bg-danger-soft" aria-label={t('common.delete')}>
<Trash2 size={14} />
</button>
</div>
))}
<button type="button" onClick={() => setLinks([...links, { url: '' }])} className="inline-flex items-center gap-1.5 self-start px-2.5 py-1.5 rounded-lg border border-dashed border-edge text-content-secondary text-[12.5px] font-medium hover:bg-surface-hover">
<Plus size={14} /> <Link2 size={13} /> {t('collections.addLink')}
</button>
</div>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,95 @@
import React from 'react'
import { Plus, Layers, Users } from 'lucide-react'
import type { Collection } from '@trek/shared'
import type { TranslationFn } from '../../types'
import { ALL_SAVED } from '../../store/collectionStore'
import type { ActiveCollectionId, IncomingCollectionInvite } from '../../store/collectionStore'
interface ListsRailProps {
ownedLists: Collection[]
sharedLists: Collection[]
activeId: ActiveCollectionId
incomingInvites: IncomingCollectionInvite[]
onSelect: (id: ActiveCollectionId) => void
onNewList: () => void
onAcceptInvite: (id: number) => void
onDeclineInvite: (id: number) => void
t: TranslationFn
}
function ListRow({ list, active, onSelect }: { list: Collection; active: boolean; onSelect: (id: number) => void }): React.ReactElement {
return (
<div className="col-row">
<button type="button" onClick={() => onSelect(list.id)} className={`col-row-btn${active ? ' on' : ''}`}>
<span className="dot" style={{ background: list.color || '#6366f1' }} />
<span className="nm">{list.name}</span>
<span className="ct">{list.place_count ?? 0}</span>
</button>
</div>
)
}
/**
* Left rail of the user's lists: a "New list" action, the "All saved" union
* pseudo-list, owned lists (colour dot + count), a shared section, and an
* incoming-invites block. Selecting a list makes it active; editing / deleting
* happens from the Edit button in the hero of the active list.
*/
export default function ListsRail(props: ListsRailProps): React.ReactElement {
const {
ownedLists, sharedLists, activeId, incomingInvites,
onSelect, onNewList, onAcceptInvite, onDeclineInvite, t,
} = props
return (
<>
<button type="button" onClick={onNewList} className="col-rail-new">
<Plus size={16} /> {t('collections.newList')}
</button>
<div className="col-row">
<button type="button" onClick={() => onSelect(ALL_SAVED)} className={`col-row-btn${activeId === ALL_SAVED ? ' on' : ''}`}>
<span className="ico"><Layers size={16} /></span>
<span className="nm">{t('collections.allSaved')}</span>
</button>
</div>
{ownedLists.length > 0 && <div className="col-rail-sep" />}
{ownedLists.map(list => (
<ListRow key={list.id} list={list} active={activeId === list.id} onSelect={onSelect} />
))}
{sharedLists.length > 0 && (
<>
<div className="col-rail-label"><Users size={12} /> {t('collections.shared')}</div>
{sharedLists.map(list => (
<ListRow key={list.id} list={list} active={activeId === list.id} onSelect={onSelect} />
))}
</>
)}
{incomingInvites.length > 0 && (
<>
<div className="col-rail-label">
{t('collections.invites.title')}
<span className="badge">{incomingInvites.length}</span>
</div>
{incomingInvites.map(inv => (
<div key={inv.collection_id} className="col-invite">
<div className="t">{inv.name}</div>
<div className="s">{t('collections.invites.from')} {inv.from.username}</div>
<div className="col-invite-actions">
<button type="button" onClick={() => onAcceptInvite(inv.collection_id)} className="col-invite-accept">
{t('collections.invites.accept')}
</button>
<button type="button" onClick={() => onDeclineInvite(inv.collection_id)} className="col-invite-decline">
{t('collections.invites.decline')}
</button>
</div>
</div>
))}
</>
)}
</>
)
}
@@ -0,0 +1,108 @@
// FE-COMP-MOVETOLIST-001 to FE-COMP-MOVETOLIST-009
import { render, screen, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { Collection } from '@trek/shared';
import { useTranslation } from '../../i18n/TranslationContext';
import MoveToListModal from './MoveToListModal';
// The modal receives `t` as a prop rather than reading context, so a tiny harness
// pulls the REAL English translator from the provider and forwards it. That keeps
// the assertions against real strings ("Move 2 to another list") instead of keys.
function Harness(props: Omit<React.ComponentProps<typeof MoveToListModal>, 't'>) {
const { t } = useTranslation();
return <MoveToListModal {...props} t={t} />;
}
const listA: Collection = { id: 11, owner_id: 1, name: 'Weekend in Rome', color: '#ef4444', place_count: 3 };
const listB: Collection = { id: 22, owner_id: 1, name: 'Tokyo Food Tour', color: '#22c55e', place_count: 7 };
function renderModal(overrides: Partial<React.ComponentProps<typeof MoveToListModal>> = {}) {
const props = {
mode: 'move' as const,
lists: [listA, listB],
count: 2,
onPick: vi.fn().mockResolvedValue(undefined),
onClose: vi.fn(),
...overrides,
};
render(<Harness {...props} />);
return props;
}
describe('MoveToListModal', () => {
it('FE-COMP-MOVETOLIST-001: renders every candidate list name', () => {
renderModal();
expect(screen.getByText('Weekend in Rome')).toBeInTheDocument();
expect(screen.getByText('Tokyo Food Tour')).toBeInTheDocument();
});
it('FE-COMP-MOVETOLIST-002: title reflects the count in move mode', () => {
renderModal({ mode: 'move', count: 2 });
// collections.moveToListTitle = 'Move {count} to another list'
expect(screen.getByRole('heading', { name: 'Move 2 to another list' })).toBeInTheDocument();
});
it('FE-COMP-MOVETOLIST-003: title reflects the count in copy mode', () => {
renderModal({ mode: 'copy', count: 5 });
// collections.duplicateToListTitle = 'Duplicate {count} to another list'
expect(screen.getByRole('heading', { name: 'Duplicate 5 to another list' })).toBeInTheDocument();
});
it('FE-COMP-MOVETOLIST-004: shows the place count subtitle per list', () => {
renderModal();
// collections.placeCount = '{count} places'
expect(screen.getByText('3 places')).toBeInTheDocument();
expect(screen.getByText('7 places')).toBeInTheDocument();
});
it('FE-COMP-MOVETOLIST-005: clicking a row calls onPick with that list id', async () => {
const user = userEvent.setup();
const { onPick } = renderModal();
await user.click(screen.getByRole('button', { name: /Tokyo Food Tour/i }));
expect(onPick).toHaveBeenCalledTimes(1);
expect(onPick).toHaveBeenCalledWith(22);
});
it('FE-COMP-MOVETOLIST-006: empty lists shows the "no other lists" message and renders no rows', () => {
renderModal({ lists: [] });
// collections.noOtherLists = 'No other lists yet'
expect(screen.getByText('No other lists yet')).toBeInTheDocument();
// No selectable list rows exist (only the modal close button remains).
expect(screen.queryByRole('button', { name: /Weekend in Rome|Tokyo Food Tour|places/i })).not.toBeInTheDocument();
});
it('FE-COMP-MOVETOLIST-007: move mode renders a trailing arrow icon on each row', () => {
renderModal({ mode: 'move' });
const row = screen.getByRole('button', { name: /Weekend in Rome/i });
expect(row.querySelector('.lucide-arrow-right')).toBeTruthy();
expect(row.querySelector('.lucide-copy')).toBeFalsy();
});
it('FE-COMP-MOVETOLIST-008: copy mode renders a trailing copy icon on each row', () => {
renderModal({ mode: 'copy' });
const row = screen.getByRole('button', { name: /Weekend in Rome/i });
expect(row.querySelector('.lucide-copy')).toBeTruthy();
expect(row.querySelector('.lucide-arrow-right')).toBeFalsy();
});
it('FE-COMP-MOVETOLIST-009: a second click while the first pick is pending is ignored', async () => {
const user = userEvent.setup();
// Never resolves, so the modal stays "busy" after the first click.
const onPick = vi.fn().mockReturnValue(new Promise<void>(() => {}));
renderModal({ onPick });
const rome = screen.getByRole('button', { name: /Weekend in Rome/i });
const tokyo = screen.getByRole('button', { name: /Tokyo Food Tour/i });
await user.click(rome);
// Rows disable while busy; a click on another row must not fire a second pick.
await user.click(tokyo);
expect(onPick).toHaveBeenCalledTimes(1);
expect(onPick).toHaveBeenCalledWith(11);
// Confirm the busy state actually disabled the rows.
expect(within(tokyo).queryByText('Tokyo Food Tour')).toBeInTheDocument();
expect(tokyo).toBeDisabled();
});
});
@@ -0,0 +1,90 @@
import React, { useMemo, useState } from 'react'
import { Search, ArrowRight, Copy, Loader2, Bookmark } from 'lucide-react'
import Modal from '../shared/Modal'
import type { Collection } from '@trek/shared'
import type { TranslationFn } from '../../types'
interface MoveToListModalProps {
mode: 'move' | 'copy'
/** Candidate target lists (owned, excluding the current one). */
lists: Collection[]
/** Number of selected places. */
count: number
onPick: (targetId: number) => Promise<void> | void
onClose: () => void
t: TranslationFn
}
/**
* Target-list picker for moving or duplicating the selected places into another
* of the user's lists. `mode` only changes the wording + the trailing icon; the
* action itself is the parent's onPick.
*/
export default function MoveToListModal({ mode, lists, count, onPick, onClose, t }: MoveToListModalProps): React.ReactElement {
const [search, setSearch] = useState('')
const [busy, setBusy] = useState<number | null>(null)
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
return q ? lists.filter(l => l.name.toLowerCase().includes(q)) : lists
}, [lists, search])
const pick = async (id: number) => {
if (busy != null) return
setBusy(id)
try { await onPick(id) } finally { setBusy(null) }
}
const Trailing = mode === 'move' ? ArrowRight : Copy
return (
<Modal
isOpen
onClose={onClose}
title={mode === 'move' ? t('collections.moveToListTitle', { count }) : t('collections.duplicateToListTitle', { count })}
size="sm"
>
<div className="flex flex-col gap-3">
{lists.length > 3 && (
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.copyToTripSearch')}
className="w-full pl-8 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
</div>
)}
{filtered.length === 0 ? (
<p className="text-center text-[13px] text-content-faint py-8">{t('collections.noOtherLists')}</p>
) : (
<div className="flex flex-col gap-1 max-h-[50vh] overflow-y-auto -mx-1 px-1">
{filtered.map(list => {
const isBusy = busy === list.id
return (
<button
key={list.id}
type="button"
onClick={() => pick(list.id)}
disabled={busy != null}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-card text-left hover:bg-surface-hover transition-colors disabled:opacity-60"
>
<span className="w-9 h-9 min-w-[36px] rounded-lg flex items-center justify-center shrink-0 text-white" style={{ background: list.color || '#6366f1' }}>
<Bookmark size={15} />
</span>
<span className="flex-1 min-w-0">
<span className="block text-[13px] font-semibold text-content truncate">{list.name}</span>
<span className="block text-[11.5px] text-content-faint">{t('collections.placeCount', { count: list.place_count ?? 0 })}</span>
</span>
{isBusy ? <Loader2 size={15} className="animate-spin text-content-faint shrink-0" /> : <Trailing size={15} className="text-content-faint shrink-0" />}
</button>
)
})}
</div>
)}
</div>
</Modal>
)
}
@@ -0,0 +1,191 @@
import React, { useEffect, useMemo, useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Bookmark, BookmarkCheck, Check, Loader2, Plus } from 'lucide-react'
import Modal from '../shared/Modal'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { collectionsApi } from '../../api/collections'
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
import { getApiErrorMessage } from '../../utils/apiError'
import type { Collection, CollectionMembership } from '@trek/shared'
/**
* Globally-mounted list picker for the "Save to Collection" entry points
* (PlaceInspector footer button + the two trip-sidebar context menus). Reads the
* active target from saveToCollectionStore, shows every list the user owns or
* co-owns, and toggles the place in/out of each a check marks the lists that
* already hold it. Each change refreshes membership and bumps the store version
* so the inspector bookmark indicator stays in sync. One mount, no prop drilling.
*/
export default function SaveToCollectionModal(): React.ReactElement | null {
const target = useSaveToCollectionStore(s => s.target)
const close = useSaveToCollectionStore(s => s.close)
const bumpVersion = useSaveToCollectionStore(s => s.bumpVersion)
const { t } = useTranslation()
const toast = useToast()
const navigate = useNavigate()
const [lists, setLists] = useState<Collection[]>([])
const [membership, setMembership] = useState<CollectionMembership | null>(null)
const [loading, setLoading] = useState(false)
const [busyId, setBusyId] = useState<number | null>(null)
const membershipQuery = useMemo(() => {
if (!target) return null
return {
google_place_id: target.google_place_id ?? undefined,
google_ftid: target.google_ftid ?? undefined,
name: target.name,
lat: target.lat ?? undefined,
lng: target.lng ?? undefined,
}
}, [target])
const refreshMembership = useCallback(async () => {
if (!membershipQuery) return
try {
const m = await collectionsApi.membership(membershipQuery)
setMembership(m)
} catch {
setMembership({ saved: false, lists: [] })
}
}, [membershipQuery])
// Load lists + membership whenever the picker opens for a new target.
useEffect(() => {
if (!target) return
let cancelled = false
setLoading(true)
setMembership(null)
Promise.all([collectionsApi.list().catch(() => ({ collections: [], incomingInvites: [] })), membershipQuery ? collectionsApi.membership(membershipQuery).catch(() => ({ saved: false, lists: [] as CollectionMembership['lists'] })) : Promise.resolve({ saved: false, lists: [] as CollectionMembership['lists'] })])
.then(([listRes, m]) => {
if (cancelled) return
setLists(listRes.collections)
setMembership(m)
})
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target])
if (!target) return null
const savedByCollection = new Map<number, number>()
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l.place_id)
const handleToggle = async (list: Collection) => {
if (busyId != null) return
const savedPlaceId = savedByCollection.get(list.id)
setBusyId(list.id)
try {
if (savedPlaceId != null) {
await collectionsApi.deletePlace(savedPlaceId)
toast.success(t('collections.removedFromList', { name: list.name }))
} else {
await collectionsApi.savePlace({
collection_id: list.id,
source_trip_id: target.source_trip_id ?? null,
source_place_id: target.source_place_id ?? null,
name: target.name,
description: target.description ?? null,
lat: target.lat ?? null,
lng: target.lng ?? null,
address: target.address ?? null,
category_id: target.category_id ?? null,
price: target.price ?? null,
currency: target.currency ?? null,
notes: target.notes ?? null,
image_url: target.image_url ?? null,
google_place_id: target.google_place_id ?? null,
google_ftid: target.google_ftid ?? null,
osm_id: target.osm_id ?? null,
website: target.website ?? null,
phone: target.phone ?? null,
force: true,
})
toast.success(t('collections.addedToList', { name: list.name }))
}
await refreshMembership()
bumpVersion()
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setBusyId(null)
}
}
return (
<Modal
isOpen
onClose={close}
title={t('collections.pickList')}
size="sm"
footer={
<div className="flex items-center justify-between gap-2">
<button
type="button"
onClick={() => { close(); navigate('/collections') }}
className="text-[13px] font-medium text-accent hover:underline"
>
{t('collections.viewInCollection')}
</button>
<button
type="button"
onClick={close}
className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover"
>
{t('common.close')}
</button>
</div>
}
>
<div className="flex flex-col gap-2">
<p className="text-[13px] font-semibold text-content truncate">{target.name}</p>
{loading ? (
<div className="flex items-center justify-center py-8 text-content-faint">
<Loader2 size={20} className="animate-spin" />
</div>
) : lists.length === 0 ? (
<div className="flex flex-col items-center text-center py-8 px-4">
<div className="w-11 h-11 rounded-2xl bg-surface-secondary flex items-center justify-center mb-3 text-content-faint">
<Bookmark size={20} />
</div>
<p className="text-[13px] text-content-faint mb-3">{t('collections.noListsYet')}</p>
<button
type="button"
onClick={() => { close(); navigate('/collections') }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold"
>
<Plus size={14} /> {t('collections.newList')}
</button>
</div>
) : (
<div className="flex flex-col gap-1 max-h-[50vh] overflow-y-auto -mx-1 px-1">
{lists.map(list => {
const saved = savedByCollection.has(list.id)
const busy = busyId === list.id
return (
<button
key={list.id}
type="button"
onClick={() => handleToggle(list)}
disabled={busy}
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl border text-left transition-colors disabled:opacity-60 ${saved ? 'border-accent bg-accent-subtle' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
>
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: list.color || 'var(--accent)' }} />
<span className="flex-1 min-w-0 text-[13px] font-medium text-content truncate">{list.name}</span>
{list.is_owner === false && (
<span className="text-[10px] uppercase font-semibold text-content-faint">{t('collections.shared')}</span>
)}
<span className={`w-5 h-5 rounded-md flex items-center justify-center shrink-0 ${saved ? 'bg-accent text-accent-text' : 'border border-edge text-transparent'}`}>
{busy ? <Loader2 size={13} className="animate-spin text-content-faint" /> : saved ? <BookmarkCheck size={13} /> : <Check size={13} />}
</span>
</button>
)
})}
</div>
)}
</div>
</Modal>
)
}
@@ -0,0 +1,118 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Search, Bookmark, ArrowRight, Loader2, Plus } from 'lucide-react'
import Modal from '../shared/Modal'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { collectionsApi } from '../../api/collections'
import { getApiErrorMessage } from '../../utils/apiError'
import type { Collection } from '@trek/shared'
interface SaveTripPlacesToListModalProps {
isOpen: boolean
tripId: number
/** The selected trip place ids to copy into the chosen list. */
placeIds: number[]
onClose: () => void
/** Called after a successful save (e.g. to clear the trip selection). */
onDone: () => void
}
/**
* Bulk "save to collection" for the trip place list: pick one of the user's lists
* and copy every selected trip place into it at once (server dedups by name/coords).
*/
export default function SaveTripPlacesToListModal({ isOpen, tripId, placeIds, onClose, onDone }: SaveTripPlacesToListModalProps): React.ReactElement | null {
const { t } = useTranslation()
const toast = useToast()
const [lists, setLists] = useState<Collection[]>([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [busyId, setBusyId] = useState<number | null>(null)
useEffect(() => {
if (!isOpen) return
let cancelled = false
setLoading(true)
setSearch('')
collectionsApi.list()
// Only lists the user can add to (their own or an editor/admin share). The
// server still enforces this; here we drop lists that are clearly read-only.
.then(res => { if (!cancelled) setLists((res.collections ?? []).filter(c => c.is_owner !== false)) })
.catch(() => { if (!cancelled) setLists([]) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [isOpen])
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
return q ? lists.filter(l => l.name.toLowerCase().includes(q)) : lists
}, [lists, search])
if (!isOpen) return null
const pick = async (list: Collection) => {
if (busyId != null || placeIds.length === 0) return
setBusyId(list.id)
try {
const res = await collectionsApi.saveFromTripMany(list.id, tripId, placeIds)
if (res.copied > 0) toast.success(t('collections.addedNToList', { count: res.copied, name: list.name }))
if (res.skipped.length > 0) toast.info(t('collections.skippedDuplicates', { count: res.skipped.length }))
if (res.copied === 0 && res.skipped.length === 0) toast.info(t('collections.copyNothing'))
onDone()
onClose()
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setBusyId(null)
}
}
return (
<Modal isOpen onClose={onClose} title={t('collections.saveNToList', { count: placeIds.length })} size="sm">
<div className="flex flex-col gap-3">
{lists.length > 5 && (
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.copyToTripSearch')}
className="w-full pl-8 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-10 text-content-faint"><Loader2 size={20} className="animate-spin" /></div>
) : filtered.length === 0 ? (
<p className="text-center text-[13px] text-content-faint py-8">{t('collections.noOwnLists')}</p>
) : (
<div className="flex flex-col gap-1 max-h-[50vh] overflow-y-auto -mx-1 px-1">
{filtered.map(list => {
const busy = busyId === list.id
return (
<button
key={list.id}
type="button"
onClick={() => pick(list)}
disabled={busyId != null}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-card text-left hover:bg-surface-hover transition-colors disabled:opacity-60"
>
<span className="w-9 h-9 min-w-[36px] rounded-lg flex items-center justify-center shrink-0 text-white" style={{ background: list.color || '#6366f1' }}>
<Bookmark size={15} />
</span>
<span className="flex-1 min-w-0">
<span className="block text-[13px] font-semibold text-content truncate">{list.name}</span>
<span className="block text-[11.5px] text-content-faint">{t('collections.placeCount', { count: list.place_count ?? 0 })}</span>
</span>
{busy ? <Loader2 size={15} className="animate-spin text-content-faint shrink-0" /> : <ArrowRight size={15} className="text-content-faint shrink-0" />}
</button>
)
})}
</div>
)}
<p className="text-[11.5px] text-content-faint inline-flex items-center gap-1.5"><Plus size={12} /> {t('collections.saveToListHint')}</p>
</div>
</Modal>
)
}
@@ -0,0 +1,328 @@
import React, { useEffect, useMemo, useState } from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { UserPlus, UserMinus, Loader2, Clock, Crown, LogOut } from 'lucide-react'
import type { CollectionMember, CollectionRole } from '@trek/shared'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { useToast } from '../shared/Toast'
import { useCollectionStore } from '../../store/collectionStore'
import { useAuthStore } from '../../store/authStore'
import { collectionsApi } from '../../api/collections'
import { getApiErrorMessage } from '../../utils/apiError'
import type { TranslationFn } from '../../types'
interface ShareCollectionModalProps {
isOpen: boolean
onClose: () => void
collectionId: number
collectionName: string
isOwner: boolean
members: CollectionMember[]
/** Called after the current (member) user successfully leaves the list. */
onAfterLeave: () => void
t: TranslationFn
}
const ROLE_ORDER: CollectionRole[] = ['viewer', 'editor', 'admin']
function MemberAvatar({ member }: { member: CollectionMember }): React.ReactElement {
const initial = (member.username || '?').charAt(0).toUpperCase()
return (
<span className="w-8 h-8 rounded-full shrink-0 overflow-hidden flex items-center justify-center bg-surface-secondary text-content-secondary text-[12px] font-semibold">
{member.avatar ? (
<img src={avatarSrc(member.avatar)!} alt="" className="w-full h-full object-cover" />
) : (
initial
)}
</span>
)
}
/**
* Fusion-share surface for a single list (blueprint 4.4 / 4.8). The OWNER sees the
* member roster with accepted/pending status, can invite a user from
* GET /:id/available-users and cancel a pending invite. A non-owner MEMBER sees the
* roster read-only plus a "Leave shared list" action (the server blocks the owner
* from leaving). Incoming invites are accepted/declined from the lists rail, not here.
*/
export default function ShareCollectionModal({
isOpen,
onClose,
collectionId,
collectionName,
isOwner,
members,
onAfterLeave,
t,
}: ShareCollectionModalProps): React.ReactElement | null {
const toast = useToast()
const currentUserId = useAuthStore(s => s.user?.id)
const invite = useCollectionStore(s => s.invite)
const cancelInvite = useCollectionStore(s => s.cancelInvite)
const removeMember = useCollectionStore(s => s.removeMember)
const setMemberRole = useCollectionStore(s => s.setMemberRole)
const leave = useCollectionStore(s => s.leave)
const [availableUsers, setAvailableUsers] = useState<{ id: number; username: string }[]>([])
const [selectedUserId, setSelectedUserId] = useState<number | ''>('')
const [inviteRole, setInviteRole] = useState<CollectionRole>('editor')
const [settingRoleId, setSettingRoleId] = useState<number | null>(null)
const [inviting, setInviting] = useState(false)
const [cancellingId, setCancellingId] = useState<number | null>(null)
const [removingId, setRemovingId] = useState<number | null>(null)
const [confirmLeave, setConfirmLeave] = useState(false)
const [leaving, setLeaving] = useState(false)
// Load the invitable users whenever an owner opens the modal.
useEffect(() => {
if (!isOpen || !isOwner) return
let cancelled = false
collectionsApi.availableUsers(collectionId)
.then(data => { if (!cancelled) setAvailableUsers(data.users) })
.catch(() => { if (!cancelled) setAvailableUsers([]) })
return () => { cancelled = true }
}, [isOpen, isOwner, collectionId, members.length])
// Reset transient state on close.
useEffect(() => {
if (!isOpen) {
setSelectedUserId('')
setConfirmLeave(false)
}
}, [isOpen])
const sortedMembers = useMemo(() => {
// Owner first, then accepted, then pending — alphabetised within each band.
const rank = (m: CollectionMember) => (m.is_owner ? 0 : m.status === 'accepted' ? 1 : 2)
return [...members].sort((a, b) => rank(a) - rank(b) || a.username.localeCompare(b.username))
}, [members])
if (!isOpen) return null
const handleInvite = async () => {
if (selectedUserId === '' || inviting) return
setInviting(true)
try {
await invite(collectionId, Number(selectedUserId), inviteRole)
toast.success(t('collections.invite.sent'))
setSelectedUserId('')
} catch (err) {
toast.error(getApiErrorMessage(err, t('collections.invite.error')))
} finally {
setInviting(false)
}
}
const handleSetRole = async (userId: number, role: CollectionRole) => {
setSettingRoleId(userId)
try {
await setMemberRole(collectionId, userId, role)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setSettingRoleId(null)
}
}
const handleCancel = async (userId: number) => {
if (cancellingId != null) return
setCancellingId(userId)
try {
await cancelInvite(collectionId, userId)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setCancellingId(null)
}
}
const handleRemove = async (userId: number) => {
if (removingId != null) return
setRemovingId(userId)
try {
await removeMember(collectionId, userId)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setRemovingId(null)
}
}
const handleLeave = async () => {
setLeaving(true)
try {
await leave(collectionId)
toast.success(t('collections.share.left'))
onAfterLeave()
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setLeaving(false)
}
}
return (
<Modal
isOpen
onClose={onClose}
title={t('collections.share.titleNamed', { name: collectionName })}
size="xl"
>
<div className="flex flex-col gap-5">
{/* Member roster */}
<div>
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-content-faint mb-2.5 flex items-center gap-2">
{t('collections.share.members')}
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-surface-secondary text-content-secondary text-[10px] font-bold tabular-nums">{sortedMembers.length}</span>
</h3>
<div className="flex flex-col gap-1.5">
{sortedMembers.map(member => {
const isSelf = member.user_id === currentUserId
const pending = member.status === 'pending'
return (
<div
key={member.user_id}
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-secondary/50 ${pending ? 'opacity-90' : ''}`}
>
<MemberAvatar member={member} />
<div className="flex-1 min-w-0">
<p className="text-[13.5px] font-semibold text-content truncate flex items-center gap-1.5">
{member.username}
{isSelf && <span className="text-content-faint font-normal text-[12px]">({t('collections.share.you')})</span>}
</p>
{member.email && !pending && (
<p className="text-[11.5px] text-content-faint truncate">{member.email}</p>
)}
</div>
{member.is_owner ? (
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full bg-amber-500/12 text-amber-600 dark:text-amber-400 shrink-0">
<Crown size={11} /> {t('collections.share.owner')}
</span>
) : pending ? (
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full bg-amber-500/12 text-amber-600 dark:text-amber-400 shrink-0">
<Clock size={11} /> {t('collections.share.pending')}
</span>
) : isOwner ? (
<div className="w-[118px] shrink-0">
<CustomSelect
size="sm"
value={member.role ?? 'editor'}
onChange={v => handleSetRole(member.user_id, v as CollectionRole)}
options={ROLE_ORDER.map(r => ({ value: r, label: t(`collections.role.${r}`) }))}
disabled={settingRoleId === member.user_id}
/>
</div>
) : (
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full bg-surface-secondary text-content-secondary shrink-0">
{t(`collections.role.${member.role ?? 'editor'}`)}
</span>
)}
{isOwner && pending && (
<button
type="button"
onClick={() => handleCancel(member.user_id)}
disabled={cancellingId === member.user_id}
className="shrink-0 text-[11px] font-medium px-2 py-1 rounded-md text-content-faint hover:text-danger hover:bg-danger-soft transition-colors disabled:opacity-50"
>
{cancellingId === member.user_id ? <Loader2 size={12} className="animate-spin" /> : t('collections.share.cancel')}
</button>
)}
{isOwner && !member.is_owner && member.status === 'accepted' && (
<button
type="button"
onClick={() => handleRemove(member.user_id)}
disabled={removingId === member.user_id}
title={t('collections.share.remove')}
aria-label={t('collections.share.remove')}
className="shrink-0 p-1 rounded-md text-content-faint hover:text-danger hover:bg-danger-soft transition-colors disabled:opacity-50"
>
{removingId === member.user_id ? <Loader2 size={12} className="animate-spin" /> : <UserMinus size={13} />}
</button>
)}
</div>
)
})}
</div>
</div>
{isOwner ? (
/* Owner: invite UI */
<div className="pt-1 border-t border-edge-secondary">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-content-faint mt-4 mb-2">
{t('collections.share.invite')}
</h3>
<p className="text-[12px] text-content-muted mb-3">{t('collections.share.inviteHint')}</p>
{availableUsers.length === 0 ? (
<p className="text-[12px] text-content-faint text-center py-3">{t('collections.share.noUsers')}</p>
) : (
<div className="flex items-stretch gap-2">
<div className="flex-1 min-w-0">
<CustomSelect
value={selectedUserId}
onChange={v => setSelectedUserId(v === '' ? '' : Number(v))}
options={availableUsers.map(u => ({ value: u.id, label: u.username }))}
placeholder={t('collections.share.inviteUser')}
searchable
/>
</div>
<div className="w-[128px] shrink-0">
<CustomSelect
size="sm"
value={inviteRole}
onChange={v => setInviteRole(v as CollectionRole)}
options={ROLE_ORDER.map(r => ({ value: r, label: t(`collections.role.${r}`) }))}
/>
</div>
<button
type="button"
onClick={handleInvite}
disabled={selectedUserId === '' || inviting}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-accent-text text-[13px] font-semibold hover:bg-accent-hover transition-colors disabled:opacity-50"
>
{inviting ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} />}
<span className="hidden sm:inline">{t('collections.share.sendInvite')}</span>
</button>
</div>
)}
</div>
) : (
/* Member: read-only roster + leave */
<div className="pt-1 border-t border-edge-secondary">
<p className="text-[12px] text-content-muted mt-4 mb-3">{t('collections.share.memberHint')}</p>
{confirmLeave ? (
<div className="flex flex-col gap-2.5">
<p className="text-[13px] text-content-secondary">{t('collections.share.leaveConfirm')}</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmLeave(false)}
className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover transition-colors"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleLeave}
disabled={leaving}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-danger text-white text-[13px] font-semibold hover:opacity-90 transition-opacity disabled:opacity-50"
>
{leaving ? <Loader2 size={14} className="animate-spin" /> : <LogOut size={14} />}
{t('collections.share.leave')}
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setConfirmLeave(true)}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg border border-edge text-danger text-[13px] font-medium hover:bg-danger-soft transition-colors"
>
<LogOut size={14} /> {t('collections.share.leave')}
</button>
)}
</div>
)}
</div>
</Modal>
)
}
@@ -0,0 +1,126 @@
// FE-COMP-STATUSBADGE-001 to FE-COMP-STATUSBADGE-013
import { render, screen } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { CollectionStatus } from '@trek/shared';
import { resetAllStores } from '../../../tests/helpers/store';
import { useTranslation } from '../../i18n/TranslationContext';
import StatusBadge from './StatusBadge';
// StatusBadge takes a `t` prop (TranslationFn) rather than reading the context
// itself, so this harness pulls the real English `t` out of the provider that
// the render helper wraps around us and forwards it. That way assertions run
// against real translated strings, not i18n keys.
type BadgeProps = {
status: CollectionStatus;
onChange?: (next: CollectionStatus) => void;
showLabel?: boolean;
onCover?: boolean;
};
function Badge(props: BadgeProps) {
const { t } = useTranslation();
return <StatusBadge {...props} t={t} />;
}
beforeEach(() => {
resetAllStores();
});
describe('StatusBadge', () => {
// ── Current-status label ─────────────────────────────────────────────────────
it('FE-COMP-STATUSBADGE-001: renders the "Idea" label for the idea status', () => {
render(<Badge status="idea" />);
expect(screen.getByText('Idea')).toBeInTheDocument();
});
it('FE-COMP-STATUSBADGE-002: renders the "Want to go" label for the want status', () => {
render(<Badge status="want" />);
expect(screen.getByText('Want to go')).toBeInTheDocument();
});
it('FE-COMP-STATUSBADGE-003: renders the "Visited" label for the visited status', () => {
render(<Badge status="visited" />);
expect(screen.getByText('Visited')).toBeInTheDocument();
});
it('FE-COMP-STATUSBADGE-004: hides the label when showLabel is false', () => {
render(<Badge status="idea" showLabel={false} />);
expect(screen.queryByText('Idea')).not.toBeInTheDocument();
});
// ── One-tap cycle: idea → want → visited → idea ──────────────────────────────
it('FE-COMP-STATUSBADGE-005: exposes a button role when onChange is supplied', () => {
render(<Badge status="idea" onChange={vi.fn()} />);
expect(screen.getByRole('button', { name: 'Idea' })).toBeInTheDocument();
});
it('FE-COMP-STATUSBADGE-006: clicking an idea badge calls onChange with "want"', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<Badge status="idea" onChange={onChange} />);
await user.click(screen.getByRole('button', { name: 'Idea' }));
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('want');
});
it('FE-COMP-STATUSBADGE-007: clicking a want badge calls onChange with "visited"', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<Badge status="want" onChange={onChange} />);
await user.click(screen.getByRole('button', { name: 'Want to go' }));
expect(onChange).toHaveBeenCalledWith('visited');
});
it('FE-COMP-STATUSBADGE-008: clicking a visited badge wraps around to "idea"', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<Badge status="visited" onChange={onChange} />);
await user.click(screen.getByRole('button', { name: 'Visited' }));
expect(onChange).toHaveBeenCalledWith('idea');
});
it('FE-COMP-STATUSBADGE-009: pressing Enter cycles the status', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<Badge status="idea" onChange={onChange} />);
const badge = screen.getByRole('button', { name: 'Idea' });
badge.focus();
await user.keyboard('{Enter}');
expect(onChange).toHaveBeenCalledWith('want');
});
it('FE-COMP-STATUSBADGE-010: interactive badge advertises the cycle hint in its title', () => {
render(<Badge status="idea" onChange={vi.fn()} />);
// English: 'collections.status.cycleHint' = 'tap to change'
expect(screen.getByRole('button', { name: 'Idea' })).toHaveAttribute(
'title',
'Idea — tap to change',
);
});
// ── Read-only (onChange omitted) ─────────────────────────────────────────────
it('FE-COMP-STATUSBADGE-011: renders as a static badge with no button role when onChange is omitted', () => {
render(<Badge status="want" />);
expect(screen.getByText('Want to go')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('FE-COMP-STATUSBADGE-012: read-only badge uses the plain label as its title (no cycle hint)', () => {
render(<Badge status="visited" />);
const label = screen.getByText('Visited');
const pill = label.parentElement as HTMLElement;
expect(pill).toHaveAttribute('title', 'Visited');
expect(pill.getAttribute('title')).not.toMatch(/tap to change/i);
});
it('FE-COMP-STATUSBADGE-013: clicking a read-only badge does not throw', async () => {
const user = userEvent.setup();
render(<Badge status="idea" />);
// No interactive handler wired up — the click must be a harmless no-op.
await user.click(screen.getByText('Idea'));
expect(screen.getByText('Idea')).toBeInTheDocument();
});
});
@@ -0,0 +1,71 @@
import React from 'react'
import type { CollectionStatus } from '@trek/shared'
import type { TranslationFn } from '../../types'
import { STATUS_META, nextStatus } from '../../pages/collections/collectionsModel'
interface StatusBadgeProps {
status: CollectionStatus
/** One-tap cycle: idea → want → visited → idea. Omit for a read-only badge. */
onChange?: (next: CollectionStatus) => void
showLabel?: boolean
size?: number
/** Dark-glass variant for a pill sitting over a photo cover / the hero. */
onCover?: boolean
t: TranslationFn
}
/**
* Coloured per-place status pill (idea / want-to-go / visited). When `onChange`
* is supplied a single tap cycles the status optimistically; otherwise it is a
* static badge. Two skins: the default surface pill (list / inspector) and the
* `onCover` dark-glass pill that stays legible on top of a photo cover. Styled
* with utility classes only, so it works both inside and outside `.trek-dash`.
*/
export default function StatusBadge({ status, onChange, showLabel = true, size = 13, onCover = false, t }: StatusBadgeProps): React.ReactElement {
const meta = STATUS_META[status]
const Icon = meta.icon
const label = t(meta.labelKey)
const color = onCover ? meta.coverColor : meta.color
const interactive = !!onChange
const cycle = (e: React.SyntheticEvent) => {
if (!onChange) return
e.preventDefault()
e.stopPropagation()
onChange(nextStatus(status))
}
const content = (
<>
<Icon size={size} style={{ color }} strokeWidth={2.4} />
{showLabel && <span className="font-semibold" style={{ color }}>{label}</span>}
</>
)
const skin = onCover
? 'bg-black/45 border-white/25 text-white'
: 'bg-surface-card/85 border-edge text-content'
const className = `inline-flex items-center gap-1.5 rounded-full text-[11px] leading-none border backdrop-blur-md ${showLabel ? 'px-2.5 py-1' : 'p-1.5'} ${skin}`
if (!interactive) {
return <span className={className} title={label}>{content}</span>
}
// Rendered as a role=button span (not a native <button>) on purpose: inside
// the .trek-dash scope the global `.trek-dash button` reset would strip the
// pill's background/border/padding, and the pill also has to sit inside the
// grid card's own clickable element without nesting one button in another.
return (
<span
role="button"
tabIndex={0}
onClick={cycle}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') cycle(e) }}
title={`${label}${t('collections.status.cycleHint')}`}
aria-label={label}
className={`${className} transition-transform hover:scale-110 active:scale-95 cursor-pointer select-none`}
>
{content}
</span>
)
}
@@ -0,0 +1,28 @@
import type { Place } from '../../types'
import type { SaveToCollectionTarget } from '../../store/saveToCollectionStore'
/**
* Build a Save-to-Collection picker target from a trip pool place, carrying the
* provenance ids so the saved copy remembers its origin trip/place.
*/
export function placeToSaveTarget(place: Place): SaveToCollectionTarget {
return {
name: place.name,
source_trip_id: place.trip_id ?? null,
source_place_id: place.id,
description: place.description ?? null,
lat: place.lat ?? null,
lng: place.lng ?? null,
address: place.address ?? null,
category_id: place.category_id ?? null,
price: place.price ?? null,
currency: place.currency ?? null,
notes: place.notes ?? null,
image_url: place.image_url ?? null,
google_place_id: place.google_place_id ?? null,
google_ftid: place.google_ftid ?? null,
osm_id: place.osm_id ?? null,
website: place.website ?? null,
phone: place.phone ?? null,
}
}
@@ -0,0 +1,70 @@
import React, { useEffect, useState } from 'react'
import { Bookmark, ArrowRight, MapPin } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from '../../i18n'
import { collectionsApi } from '../../api/collections'
import { entityGradient } from '../../utils/gradients'
import type { Collection } from '@trek/shared'
/**
* Dashboard sidebar widget a glassy `.tool` card that surfaces the user's
* saved-place LISTS as compact colour-washed badges (a mini version of the
* collections hero): each badge shows the list's cover image (tinted with its
* colour) or a colour gradient, its name and place count, and jumps to that
* list. Fetches only list() (per-list place_count), so no N+1.
*/
export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): React.ReactElement {
const { t } = useTranslation()
const navigate = useNavigate()
const [lists, setLists] = useState<Collection[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const data = await collectionsApi.list()
if (!cancelled) setLists(data.collections)
} catch {
if (!cancelled) setLists([])
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => { cancelled = true }
}, [])
return (
<div className="tool">
<div className="tool-head">
<div className="tool-title"><Bookmark size={14} /> {t('collections.widget.title')}</div>
<button className="tool-action" aria-label={t('collections.widget.title')} onClick={onOpen}>
<ArrowRight size={14} />
</button>
</div>
{loading ? null : lists.length === 0 ? (
<div className="col-empty">{t('collections.widget.empty')}</div>
) : (
<div className="col-badges">
{lists.slice(0, 6).map(list => (
<button
key={list.id}
className="col-badge"
style={{ ['--badge-color' as string]: list.color || '#6366f1' }}
onClick={() => navigate(`/collections/${list.id}`)}
>
{list.cover_image
? <img className="col-badge-media" src={list.cover_image} alt="" />
: <div className="col-badge-media" style={{ backgroundImage: entityGradient(list.id) }} />}
<div className="col-badge-tint" />
<div className="col-badge-body">
<span className="col-badge-name">{list.name}</span>
<span className="col-badge-count"><MapPin size={11} /> {list.place_count ?? 0}</span>
</div>
</button>
))}
</div>
)}
</div>
)
}
@@ -1,4 +1,4 @@
import { FileText, FileImage, File, Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { FileText, FileImage, File, FileVideo, Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { downloadFile } from '../../utils/fileDownload'
export function isImage(mimeType?: string | null) {
@@ -6,9 +6,30 @@ export function isImage(mimeType?: string | null) {
return mimeType.startsWith('image/')
}
export function isVideo(mimeType?: string | null) {
return !!mimeType && mimeType.startsWith('video/')
}
/** Image or video — the file types that open in the media lightbox (#823). */
export function isMedia(mimeType?: string | null) {
return isImage(mimeType) || isVideo(mimeType)
}
/**
* Markdown file (#1345). Detected by EXTENSION first browsers often send an
* empty / octet-stream / text/plain MIME for .md falling back to the markdown
* MIME types.
*/
export function isMarkdown(mimeType?: string | null, name?: string | null) {
const ext = (name || '').toLowerCase().split('.').pop()
if (ext === 'md' || ext === 'markdown') return true
return !!mimeType && (mimeType === 'text/markdown' || mimeType === 'text/x-markdown')
}
export function getFileIcon(mimeType?: string | null) {
if (!mimeType) return File
if (mimeType === 'application/pdf') return FileText
if (isVideo(mimeType)) return FileVideo
if (isImage(mimeType)) return FileImage
return File
}
@@ -15,6 +15,15 @@ vi.mock('../../api/authUrl', () => ({
getAuthUrl: vi.fn().mockResolvedValue('http://localhost/signed-url'),
}));
// Markdown pipeline mocked to render its children verbatim (the unified/ESM
// pipeline is heavy in jsdom) — we only assert the markdown text reaches the modal.
vi.mock('react-markdown', () => ({
default: ({ children }: { children: string }) => <span data-testid="md">{children}</span>,
}));
vi.mock('remark-gfm', () => ({ default: () => ({}) }));
vi.mock('remark-breaks', () => ({ default: () => ({}) }));
vi.mock('rehype-sanitize', () => ({ default: () => ({}) }));
// Mock filesApi
vi.mock('../../api/client', async (importOriginal) => {
const original = (await importOriginal()) as any;
@@ -289,6 +298,21 @@ describe('FileManager', () => {
});
});
it('FE-COMP-FILEMANAGER-034: markdown file click opens an inline rendered preview (#1345)', async () => {
server.use(http.get('http://localhost/signed-url', () => HttpResponse.text('# Hello heading\n\nworld body')));
const files = [buildFile({ id: 1, mime_type: 'text/markdown', original_name: 'notes.md' })];
render(<FileManager {...defaultProps} files={files} />);
const user = userEvent.setup();
await user.click(screen.getByText('notes.md'));
await waitFor(() => {
const md = screen.getByTestId('md');
expect(md).toBeInTheDocument();
expect(md.textContent).toContain('Hello heading');
});
});
it('FE-COMP-FILEMANAGER-015: file with uploader name shows avatar chip initials', () => {
const files = [buildFile({ uploaded_by_name: 'Alice Smith' })];
render(<FileManager {...defaultProps} files={files} />);
+8 -4
View File
@@ -2,23 +2,27 @@ import { useFileManager, type FileManagerProps } from './useFileManager'
import { ImageLightbox } from './FileManagerImageLightbox'
import { AssignModal } from './FileManagerAssignModal'
import { PdfPreviewModal } from './FileManagerPdfPreviewModal'
import { MarkdownPreviewModal } from './FileManagerMarkdownPreviewModal'
import { isMarkdown } from './FileManager.helpers'
import { FileManagerToolbar } from './FileManagerToolbar'
import { TrashView } from './FileManagerTrashView'
import { FilesView } from './FileManagerFilesView'
export default function FileManager(props: FileManagerProps) {
const S = useFileManager(props)
const { lightboxIndex, setLightboxIndex, imageFiles, assignFileId, previewFile, handlePaste, showTrash } = S
const { lightboxIndex, setLightboxIndex, mediaFiles, assignFileId, previewFile, handlePaste, showTrash } = S
return (
<div className="flex flex-col h-full" style={{ fontFamily: "var(--font-system)" }} onPaste={handlePaste} tabIndex={-1}>
{/* Lightbox */}
{lightboxIndex !== null && <ImageLightbox files={imageFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
{lightboxIndex !== null && <ImageLightbox files={mediaFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
{/* Assign modal */}
{assignFileId && <AssignModal {...S} />}
{/* PDF preview modal */}
{previewFile && <PdfPreviewModal {...S} />}
{/* Document preview modal (markdown is rendered inline; everything else PDF/object) */}
{previewFile && (isMarkdown(previewFile.mime_type, previewFile.original_name)
? <MarkdownPreviewModal {...S} />
: <PdfPreviewModal {...S} />)}
{/* Toolbar */}
<FileManagerToolbar {...S} />
@@ -17,8 +17,8 @@ export function AssignModal(S: FileManagerState) {
}} 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={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('files.assignTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<div style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', 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' }}>
{files.find(f => f.id === assignFileId)?.original_name || ''}
</div>
</div>
@@ -27,7 +27,7 @@ export function AssignModal(S: FileManagerState) {
</button>
</div>
<div style={{ padding: '8px 12px 0' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '0 2px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
<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 }}>
{t('files.noteLabel') || 'Note'}
</div>
<input
@@ -43,7 +43,7 @@ export function AssignModal(S: FileManagerState) {
}}
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
style={{
width: '100%', padding: '7px 10px', fontSize: 13, borderRadius: 8,
width: '100%', padding: '7px 10px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
}}
@@ -91,7 +91,7 @@ export function AssignModal(S: FileManagerState) {
}
}} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
border: 'none', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
@@ -106,18 +106,18 @@ export function AssignModal(S: FileManagerState) {
const placesSection = places.length > 0 && (
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
<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 }}>
{t('files.assignPlace')}
</div>
{dayGroups.map(({ day, dayPlaces }) => (
<div key={day.id}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
<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' }}>
<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)
return badge ? (
<span style={{
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)',
background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 999,
}}>{badge}</span>
) : null
@@ -128,7 +128,7 @@ export function AssignModal(S: FileManagerState) {
))}
{unassigned.length > 0 && (
<div>
{dayGroups.length > 0 && <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>{t('files.unassigned')}</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>}
{unassigned.map(placeBtn)}
</div>
)}
@@ -166,7 +166,7 @@ export function AssignModal(S: FileManagerState) {
}
}} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
border: 'none', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
@@ -183,7 +183,7 @@ export function AssignModal(S: FileManagerState) {
<div style={{ flex: 1, minWidth: 0 }}>
{bookingReservations.length > 0 && (
<>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
<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 }}>
{t('files.assignBooking')}
</div>
{bookingReservations.map(reservationBtn)}
@@ -191,7 +191,7 @@ export function AssignModal(S: FileManagerState) {
)}
{transportReservations.length > 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 }}>
<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 }}>
{t('files.assignTransport')}
</div>
{transportReservations.map(reservationBtn)}
@@ -32,7 +32,7 @@ export function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avata
<div style={{
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
background: 'var(--bg-elevated)', color: 'var(--text-primary)',
fontSize: 11, fontWeight: 500, padding: '3px 8px', borderRadius: 6,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '3px 8px', borderRadius: 6,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', whiteSpace: 'nowrap', zIndex: 9999,
pointerEvents: 'none',
}}>
@@ -22,15 +22,15 @@ export function FilesView(S: FileManagerState) {
<input {...getInputProps()} />
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} />
{uploading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 'calc(13px * var(--fs-scale-body, 1))', 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' }} />
{t('files.uploading')}
</div>
) : (
<>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
<p style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 6, opacity: 0.7 }}>
<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: 'calc(11.5px * var(--fs-scale-caption, 1))', 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 }}>
{(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
</p>
</>
@@ -48,14 +48,14 @@ export function FilesView(S: FileManagerState) {
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
].map(tab => (
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-body, 1))',
fontFamily: 'inherit', transition: 'all 0.12s',
background: filterType === tab.id ? 'var(--accent)' : 'transparent',
color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)',
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>
))}
<span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--text-faint)', alignSelf: 'center' }}>
<span style={{ marginLeft: 'auto', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', alignSelf: 'center' }}>
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
</span>
</div>
@@ -65,8 +65,8 @@ export function FilesView(S: FileManagerState) {
{filteredFiles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
<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: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react'
import { ExternalLink, Download, X, ChevronLeft, ChevronRight } from 'lucide-react'
import { ExternalLink, Download, X, ChevronLeft, ChevronRight, Play } from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { TripFile } from '../../types'
import { getAuthUrl } from '../../api/authUrl'
import { openFile as openFileUrl } from '../../utils/fileDownload'
import { triggerDownload } from './FileManager.helpers'
import { triggerDownload, isVideo } from './FileManager.helpers'
import VideoPlayer from '../Journey/VideoPlayer'
// Image lightbox with gallery navigation
interface ImageLightboxProps {
@@ -20,10 +21,14 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
const [touchStart, setTouchStart] = useState<number | null>(null)
const file = files[index]
const fileIsVideo = isVideo(file?.mime_type)
useEffect(() => {
setImgSrc('')
if (file) getAuthUrl(file.url, 'download').then(setImgSrc)
}, [file?.url])
// Images use a one-shot signed URL; a video must use the plain same-origin
// URL (cookie auth) so its many Range requests all authenticate (#823).
if (file && !isVideo(file.mime_type)) getAuthUrl(file.url, 'download').then(setImgSrc)
}, [file?.url, file?.mime_type])
const goPrev = () => setIndex(i => Math.max(0, i - 1))
const goNext = () => setIndex(i => Math.min(files.length - 1, i + 1))
@@ -71,7 +76,7 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }} onClick={e => e.stopPropagation()}>
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
<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 }}>
{file.original_name}
<span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span>
</span>
@@ -98,7 +103,13 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', minHeight: 0 }}
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
{navBtn('left', goPrev, hasPrev)}
{imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} onClick={e => e.stopPropagation()} />}
{fileIsVideo ? (
<div onClick={e => e.stopPropagation()}>
<VideoPlayer src={file.url} style={{ maxWidth: '85vw', maxHeight: '80vh', borderRadius: 8 }} />
</div>
) : (
imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} onClick={e => e.stopPropagation()} />
)}
{navBtn('right', goNext, hasNext)}
</div>
@@ -115,14 +126,20 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
}
function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) {
const fileIsVideo = isVideo(file.mime_type)
const [src, setSrc] = useState('')
useEffect(() => { getAuthUrl(file.url, 'download').then(setSrc) }, [file.url])
// Videos have no stored thumbnail and can't render as an <img>; show a play
// placeholder and don't mint a download token for them (#823).
useEffect(() => { if (!fileIsVideo) getAuthUrl(file.url, 'download').then(setSrc) }, [file.url, fileIsVideo])
return (
<button onClick={onClick} style={{
width: 48, height: 48, borderRadius: 6, overflow: 'hidden', border: active ? '2px solid #fff' : '2px solid transparent',
opacity: active ? 1 : 0.5, cursor: 'pointer', padding: 0, background: '#111', flexShrink: 0, transition: 'opacity 0.15s',
display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(255,255,255,0.7)',
}}>
{src && <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />}
{fileIsVideo
? <Play size={16} fill="currentColor" />
: (src && <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />)}
</button>
)
}
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react'
import ReactDOM from 'react-dom'
import { ExternalLink, Download, X } from 'lucide-react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import rehypeSanitize from 'rehype-sanitize'
import { openFile as openFileUrl } from '../../utils/fileDownload'
import type { FileManagerState } from './useFileManager'
import { triggerDownload } from './FileManager.helpers'
/**
* Inline preview for uploaded Markdown files (#1345). Fetches the file's text via
* the signed preview URL and renders it with react-markdown. Output is sanitized
* with rehype-sanitize these are UNTRUSTED uploads, unlike collab notes and
* react-markdown v10 already drops raw HTML, so no script can execute.
*/
export function MarkdownPreviewModal(S: FileManagerState) {
const { previewFile, setPreviewFile, previewFileUrl, toast, t } = S
const [text, setText] = useState('')
const [err, setErr] = useState(false)
useEffect(() => {
if (!previewFileUrl) return
let cancelled = false
setErr(false)
setText('')
fetch(previewFileUrl, { credentials: 'include' })
.then(r => (r.ok ? r.text() : Promise.reject(new Error('load failed'))))
.then(body => { if (!cancelled) setText(body) })
.catch(() => { if (!cancelled) setErr(true) })
return () => { cancelled = true }
}, [previewFileUrl])
return ReactDOM.createPortal(
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={() => setPreviewFile(null)}
>
<div
style={{ width: '100%', maxWidth: 820, height: '94vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', 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 }}>
<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>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<button
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', padding: '4px 8px', borderRadius: 6 }}>
<ExternalLink size={13} /> {t('files.openTab')}
</button>
<button
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', padding: '4px 8px', borderRadius: 6 }}>
<Download size={13} /> {t('files.download') || 'Download'}
</button>
<button onClick={() => setPreviewFile(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6 }}>
<X size={18} />
</button>
</div>
</div>
<div className="collab-note-md" style={{ flex: 1, overflowY: 'auto', padding: '20px 28px', color: 'var(--text-primary)', lineHeight: 1.6, wordBreak: 'break-word' }}>
{err
? <p style={{ color: 'var(--text-muted)' }}>{t('files.openError')}</p>
: <Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={[rehypeSanitize]}>{text}</Markdown>}
</div>
</div>
</div>,
document.body
)
}
@@ -16,18 +16,18 @@ export function PdfPreviewModal(S: FileManagerState) {
onClick={e => e.stopPropagation()}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<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>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<button
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))}
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' }}
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' }}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
<ExternalLink size={13} /> {t('files.openTab')}
</button>
<button
onClick={() => triggerDownload(previewFile.url, previewFile.original_name)}
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' }}
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' }}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
<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'
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
<span style={{ fontSize: 9, fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</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}
<span
onClick={() => !isTrash && openFile(file)}
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }}
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' }}
>
{file.original_name}
</span>
</div>
{file.description && (
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '2px 0 0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p>
<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>
)}
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
{file.file_size && <span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', 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>
{linkedPlaces.map(p => (
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
@@ -8,7 +8,7 @@ export function SourceBadge({ icon: Icon, label }: SourceBadgeProps) {
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 10.5, color: '#4b5563',
fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', color: '#4b5563',
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
borderRadius: 6, padding: '2px 7px',
fontWeight: 500, maxWidth: '100%', overflow: 'hidden',
@@ -10,7 +10,7 @@ export function FileManagerToolbar(S: FileManagerState) {
padding: '14px 16px 14px 22px',
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
<h2 style={{ margin: 0, fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}
</h2>
@@ -40,7 +40,7 @@ export function FileManagerToolbar(S: FileManagerState) {
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
padding: '6px 12px', borderRadius: 99, fontSize: 'calc(13px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap',
background: active ? 'var(--bg-card)' : 'transparent',
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
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}
{'label' in tab && tab.label}
<span style={{
fontSize: 10, fontWeight: 600,
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600,
background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
color: 'var(--text-faint)',
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
@@ -66,7 +66,7 @@ export function FileManagerToolbar(S: FileManagerState) {
<button onClick={toggleTrash} style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)',
flexShrink: 0, marginLeft: 'auto',
opacity: showTrash ? 1 : 0.88,
@@ -10,7 +10,7 @@ export function TrashView(S: FileManagerState) {
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
<button onClick={handleEmptyTrash} style={{
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
background: '#fef2f2', color: '#dc2626', fontSize: 12, fontWeight: 500,
background: '#fef2f2', color: '#dc2626', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}>
{t('files.emptyTrash') || 'Empty Trash'}
@@ -24,7 +24,7 @@ export function TrashView(S: FileManagerState) {
) : trashFiles.length === 0 ? (
<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' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.trashEmpty') || 'Trash is empty'}</p>
<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>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
@@ -1,13 +1,13 @@
import { useState, useCallback, useEffect } from 'react'
import { useDropzone } from 'react-dropzone'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { useTranslation, translateApiError } from '../../i18n'
import { filesApi } from '../../api/client'
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { getAuthUrl } from '../../api/authUrl'
import { isImage } from './FileManager.helpers'
import { isImage, isMedia } from './FileManager.helpers'
export interface FileManagerProps {
files?: TripFile[]
@@ -119,8 +119,8 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
if (lastId && (places.length > 0 || reservations.length > 0)) {
setAssignFileId(lastId)
}
} catch {
toast.error(t('files.uploadError'))
} catch (err) {
toast.error(translateApiError(t, err, 'files.uploadError'))
} finally {
setUploading(false)
}
@@ -184,11 +184,12 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
}
}
const imageFiles = filteredFiles.filter(f => isImage(f.mime_type))
// Image OR video — both open in the lightbox; videos play there (#823).
const mediaFiles = filteredFiles.filter(f => isMedia(f.mime_type))
const openFile = (file) => {
if (isImage(file.mime_type)) {
const idx = imageFiles.findIndex(f => f.id === file.id)
if (isMedia(file.mime_type)) {
const idx = mediaFiles.findIndex(f => f.id === file.id)
setLightboxIndex(idx >= 0 ? idx : 0)
} else {
setPreviewFile(file)
@@ -202,7 +203,7 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
toggleTrash, refreshFiles, handleStar, handleRestore, handlePermanentDelete, handleEmptyTrash,
previewFile, setPreviewFile, previewFileUrl, assignFileId, setAssignFileId,
getRootProps, getInputProps, isDragActive, handlePaste, filteredFiles, handleDelete,
handleAssign, imageFiles, openFile,
handleAssign, mediaFiles, openFile,
}
}
@@ -48,7 +48,7 @@ export default function JournalBody({ text, dark }: Props) {
<pre style={{
background: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
borderRadius: 8, padding: 14, overflowX: 'auto',
fontSize: 13, fontFamily: 'monospace', margin: '12px 0',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'monospace', margin: '12px 0',
}}>
<code>{children}</code>
</pre>
@@ -1,6 +1,7 @@
import { useEffect, useState, useRef } from 'react'
import { RefreshCw, Camera, Image, Plus, X } from 'lucide-react'
import { RefreshCw, Camera, Image, Plus, X, Play } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
import { isVideoFile } from '../../utils/videoPoster'
import { useJourneyStore } from '../../store/journeyStore'
import { useTranslation } from '../../i18n'
import { journeyApi, addonsApi } from '../../api/client'
@@ -66,7 +67,11 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
if (!files?.length) return
setGalleryProgress({ done: 0, total: files.length })
try {
const normalized = await normalizeImageFiles(files)
// Videos skip HEIC normalization; only images are converted (#823).
const all = Array.from(files)
const videos = all.filter(isVideoFile)
const images = all.filter(f => !isVideoFile(f))
const normalized = [...(images.length ? await normalizeImageFiles(images) : []), ...videos]
const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(journeyId, normalized, {
onProgress: p => setGalleryProgress({ done: p.done, total: p.total }),
})
@@ -110,7 +115,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
return (
<div>
<input ref={galleryFileRef} type="file" accept="image/*" multiple onChange={handleGalleryUpload} className="hidden" />
<input ref={galleryFileRef} type="file" accept="image/*,video/*" multiple onChange={handleGalleryUpload} className="hidden" />
{/* Header */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
@@ -158,13 +163,26 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
onClick={() => onPhotoClick(allPhotos, i)}
>
<img
src={photoUrl(photo, 'thumbnail')}
alt={photo.caption || ''}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
{photo.media_type === 'video' && !photo.thumbnail_path ? (
// Poster-less video (capture failed / unsupported codec): show a
// neutral tile rather than a broken 404 thumbnail (#823).
<div className="w-full h-full bg-zinc-200 dark:bg-zinc-800" />
) : (
<img
src={photoUrl(photo, 'thumbnail')}
alt={photo.caption || ''}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
{photo.media_type === 'video' && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="w-9 h-9 rounded-full bg-black/55 backdrop-blur flex items-center justify-center text-white">
<Play size={16} className="ml-0.5" fill="currentColor" />
</span>
</div>
)}
{/* Delete button */}
<button
onClick={(e) => { e.stopPropagation(); handleDeletePhoto(photo.id) }}
@@ -205,10 +223,10 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
for (const group of groups) {
try {
if (entryId) {
const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase)
const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase, group.mediaTypes)
added += result.added || 0
} else {
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase)
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase, group.mediaTypes)
added += result.added || 0
}
} catch {
@@ -13,7 +13,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
trips: JourneyTrip[]
existingAssetIds: Set<string>
onClose: () => void
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string }>, entryId: number | null) => Promise<void>
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string; mediaTypes?: string[] }>, entryId: number | null) => Promise<void>
}) {
const { t } = useTranslation()
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
@@ -27,7 +27,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
const [searchPage, setSearchPage] = useState(1)
const [searchFrom, setSearchFrom] = useState('')
const [searchTo, setSearchTo] = useState('')
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string }>>(new Map())
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(new Map())
const [customFrom, setCustomFrom] = useState('')
const [customTo, setCustomTo] = useState('')
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
@@ -123,7 +123,8 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
if (next.has(id)) {
next.delete(id)
} else {
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase })
const mediaType = (photos as any[]).find(p => p.id === id)?.mediaType
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType })
}
return next
})
@@ -293,7 +294,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
if (allSelected) {
setSelected(new Map())
} else {
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase }])))
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType: a.mediaType }])))
}
}}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
@@ -396,13 +397,14 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
</button>
<button
onClick={() => {
const groupMap = new Map<string | undefined, string[]>()
for (const [assetId, { passphrase }] of selected.entries()) {
const list = groupMap.get(passphrase) || []
list.push(assetId)
groupMap.set(passphrase, list)
const groupMap = new Map<string | undefined, { assetIds: string[]; mediaTypes: string[] }>()
for (const [assetId, { passphrase, mediaType }] of selected.entries()) {
const g = groupMap.get(passphrase) || { assetIds: [], mediaTypes: [] }
g.assetIds.push(assetId)
g.mediaTypes.push(mediaType === 'video' ? 'video' : 'image')
groupMap.set(passphrase, g)
}
const groups = [...groupMap.entries()].map(([passphrase, assetIds]) => ({ assetIds, passphrase }))
const groups = [...groupMap.entries()].map(([passphrase, g]) => ({ assetIds: g.assetIds, mediaTypes: g.mediaTypes, passphrase }))
onAdd(groups, targetEntryId)
}}
disabled={selected.size === 0}
+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)'}`,
color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1,
}}
>+</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)'}`,
color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1,
}}
></button>
</div>
@@ -1,7 +1,11 @@
import { forwardRef, useImperativeHandle, useRef } from 'react'
import { forwardRef, lazy, Suspense, useImperativeHandle, useRef } from 'react'
import { useSettingsStore } from '../../store/settingsStore'
import JourneyMap, { type JourneyMapHandle } from './JourneyMap'
import JourneyMapGL, { type JourneyMapGLHandle } from './JourneyMapGL'
import 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.
export type JourneyMapAutoHandle = JourneyMapHandle
@@ -37,8 +41,9 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
const glRef = useRef<JourneyMapGLHandle>(null)
// Fall back to Leaflet when the user selected Mapbox GL but hasn't
// supplied a token yet — otherwise the map would just show a stub.
const useGL = provider === 'mapbox-gl' && !!token
// supplied a token yet. MapLibre/OpenFreeMap is tokenless.
const useGL = provider === 'maplibre-gl' || (provider === 'mapbox-gl' && !!token)
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' : 'mapbox-gl'
useImperativeHandle(ref, () => ({
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
@@ -47,8 +52,12 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
}), [useGL])
if (useGL) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return <JourneyMapGL ref={glRef} {...(props as any)} />
return (
<Suspense fallback={null}>
{/* 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
return <JourneyMap ref={leafletRef} {...(props as any)} />
+63 -35
View File
@@ -1,8 +1,11 @@
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
import mapboxgl from 'mapbox-gl'
import maplibregl from 'maplibre-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useSettingsStore } from '../../store/settingsStore'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
export interface JourneyMapGLHandle {
highlightMarker: (id: string | null) => void
@@ -32,6 +35,7 @@ interface Props {
onMarkerClick?: (id: string, type?: string) => void
fullScreen?: boolean
paddingBottom?: number
glProvider?: GlMapProvider
}
interface Item {
@@ -95,8 +99,10 @@ function ensureJourneyPopupStyle() {
const s = document.createElement('style')
s.id = 'trek-journey-popup-style'
s.textContent = `
.mapboxgl-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,
.maplibregl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-content {
padding: 9px 14px 10px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
@@ -108,20 +114,24 @@ function ensureJourneyPopupStyle() {
min-width: 160px;
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);
border-color: rgba(255, 255, 255, 0.08);
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-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-bottom-color: rgba(24, 24, 27, 0.88);
}
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button { display: none; }
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-close-button { display: none; }
.trek-journey-popup-title {
font-size: 13.5px;
font-weight: 600;
@@ -132,7 +142,8 @@ function ensureJourneyPopupStyle() {
overflow: hidden;
text-overflow: ellipsis;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title,
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
.trek-journey-popup-sub {
display: flex;
align-items: baseline;
@@ -143,7 +154,8 @@ function ensureJourneyPopupStyle() {
line-height: 1.35;
white-space: nowrap;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub,
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
.trek-journey-popup-place {
min-width: 0;
overflow: hidden;
@@ -194,20 +206,29 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL(
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl' },
ref
) {
const stableTrail = trail || EMPTY_TRAIL
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
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 mapRef = useRef<mapboxgl.Map | null>(null)
const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map())
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapRef = useRef<any | null>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<string, any>>(new Map())
const itemsRef = useRef<Item[]>([])
const highlightedRef = useRef<string | null>(null)
const popupRef = useRef<mapboxgl.Popup | null>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const popupRef = useRef<any | null>(null)
const onMarkerClickRef = useRef(onMarkerClick)
onMarkerClickRef.current = onMarkerClick
const darkRef = useRef(dark)
@@ -247,7 +268,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
const el = popupRef.current.getElement()
if (el) el.classList.toggle('trek-dark', !!darkRef.current)
} else {
popupRef.current = new mapboxgl.Popup({
popupRef.current = new gl.Popup({
closeButton: false,
closeOnClick: false,
closeOnMove: false,
@@ -260,7 +281,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
.setHTML(html)
.addTo(mapRef.current)
}
}, [])
}, [gl])
const hidePopup = useCallback(() => {
if (popupRef.current) {
@@ -305,11 +326,11 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
mapRef.current.flyTo({
center: marker.getLngLat(),
zoom: Math.max(mapRef.current.getZoom(), 14),
pitch: mapbox3d ? 45 : 0,
pitch: enableMapbox3d ? 45 : 0,
duration: 600,
})
} catch { /* map not yet ready */ }
}, [highlightMarker, mapbox3d])
}, [highlightMarker, enableMapbox3d])
const invalidateSize = useCallback(() => {
try { mapRef.current?.resize() } catch { /* map not yet ready */ }
@@ -320,39 +341,46 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
// 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.
useEffect(() => {
if (!containerRef.current || !mapboxToken) return
mapboxgl.accessToken = mapboxToken
if (!containerRef.current || (!isMapLibre && !mapboxToken)) return
if (!isMapLibre) mapboxgl.accessToken = mapboxToken
const items = buildItems(entries)
itemsRef.current = items
const bounds = new mapboxgl.LngLatBounds()
const bounds = new gl.LngLatBounds()
items.forEach(i => bounds.extend([i.lng, i.lat]))
stableTrail.forEach(p => bounds.extend([p.lng, p.lat]))
const hasPoints = items.length > 0 || stableTrail.length > 0
const map = new mapboxgl.Map({
const mapOptions: Record<string, unknown> = {
container: containerRef.current,
style: mapboxStyle,
style: glStyle,
center: hasPoints ? bounds.getCenter() : [0, 30],
zoom: hasPoints ? 2 : 1,
pitch: mapbox3d && fullScreen ? 45 : 0,
pitch: enableMapbox3d && fullScreen ? 45 : 0,
attributionControl: true,
antialias: mapboxQuality,
projection: mapboxQuality ? 'globe' : 'mercator',
})
}
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
const map = new gl.Map(mapOptions as any)
mapRef.current = map
map.on('load', () => {
if (mapbox3d) {
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
if (supportsCustom3d(mapboxStyle)) addCustom3dBuildings(map, !!darkRef.current)
if (enableMapbox3d) {
if (!isStandardFamily(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map)
if (supportsCustom3d(glStyle)) addCustom3dBuildings(map, !!darkRef.current)
}
// Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0)
// stay pinned to their coordinates at every zoom and pitch.
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
if (glStyle === MAPBOX_DEFAULT_STYLE) {
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
if (items.length > 1) {
@@ -383,7 +411,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
// markers
items.forEach((item) => {
const el = markerHtml(item.dayColor, item.dayLabel, false)
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
const marker = new gl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([item.lng, item.lat])
.addTo(map)
el.addEventListener('click', (ev) => {
@@ -400,7 +428,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
map.fitBounds(bounds, {
padding: { top: 50, bottom: pb, left: 50, right: 50 },
maxZoom: 16,
pitch: mapbox3d && fullScreen ? 45 : 0,
pitch: enableMapbox3d && fullScreen ? 45 : 0,
duration: 0,
})
} catch { /* empty bounds */ }
@@ -418,7 +446,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
try { map.remove() } catch { /* noop */ }
mapRef.current = null
}
}, [entries, stableTrail, mapboxStyle, mapboxToken, mapbox3d, mapboxQuality, fullScreen, paddingBottom])
}, [entries, stableTrail, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
// external activeMarkerId → highlight + flyTo
useEffect(() => {
@@ -431,15 +459,15 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
mapRef.current.flyTo({
center: marker.getLngLat(),
zoom: Math.max(mapRef.current.getZoom(), 12),
pitch: mapbox3d && fullScreen ? 45 : 0,
pitch: enableMapbox3d && fullScreen ? 45 : 0,
duration: 500,
})
} catch { /* map not ready */ }
}, 50)
return () => clearTimeout(t)
}, [activeMarkerId, highlightMarker, mapbox3d, fullScreen])
}, [activeMarkerId, highlightMarker, enableMapbox3d, fullScreen])
if (!mapboxToken) {
if (!isMapLibre && !mapboxToken) {
return (
<div
style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}
+19 -13
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
import VideoPlayer from './VideoPlayer'
interface LightboxPhoto {
id: string
@@ -8,6 +9,7 @@ interface LightboxPhoto {
provider?: string
asset_id?: string | null
owner_id?: number | null
mediaType?: string | null
}
interface Props {
@@ -81,7 +83,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
>
{/* Top bar */}
<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: 13, fontWeight: 500 }}>
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>
{idx + 1} / {photos.length}
</span>
<button onClick={onClose} style={{
@@ -107,17 +109,21 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
</button>
)}
{/* Photo */}
<img
key={photo.id}
src={photo.src}
alt={photo.caption || ''}
style={{
maxWidth: '92vw', maxHeight: '92vh',
objectFit: 'contain', borderRadius: 4,
animation: 'fadeIn 0.15s ease',
}}
/>
{/* Photo or video */}
{photo.mediaType === 'video' ? (
<VideoPlayer key={photo.id} src={photo.src} />
) : (
<img
key={photo.id}
src={photo.src}
alt={photo.caption || ''}
style={{
maxWidth: '92vw', maxHeight: '92vh',
objectFit: 'contain', borderRadius: 4,
animation: 'fadeIn 0.15s ease',
}}
/>
)}
{/* Next button */}
{hasNext && (
@@ -137,7 +143,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
{photo.caption && (
<div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 5, maxWidth: '70%', textAlign: 'center' }}>
<p style={{
fontSize: 14, fontStyle: 'italic',
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontStyle: 'italic',
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5,
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
padding: '6px 14px', borderRadius: 10,
@@ -0,0 +1,51 @@
import React, { useEffect, useRef } from 'react'
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
/**
* Video player for gallery/lightbox playback (#823), built on Plyr over a native
* <video>. Local videos stream with HTTP Range (seeking works out of the box) and
* the source carries the correct video MIME from the server. The Plyr instance is
* created once per mounted source and destroyed on unmount, so navigating away in
* the lightbox stops playback.
*/
export default function VideoPlayer({
src,
poster,
autoPlay = true,
style,
}: {
src: string
poster?: string
autoPlay?: boolean
style?: React.CSSProperties
}): React.ReactElement {
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
const el = videoRef.current
if (!el) return
const player = new Plyr(el, {
controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'fullscreen'],
autoplay: autoPlay,
// Keep playback inline so the lightbox stays in control on mobile.
clickToPlay: true,
})
return () => { try { player.destroy() } catch { /* already torn down */ } }
}, [src, autoPlay])
return (
<div
style={{
width: 'min(92vw, 1100px)',
maxHeight: '92vh',
borderRadius: 4,
overflow: 'hidden',
animation: 'fadeIn 0.15s ease',
...style,
}}
>
<video ref={videoRef} src={src} poster={poster} playsInline controls preload="metadata" />
</div>
)
}
@@ -1,4 +1,4 @@
// FE-COMP-BOTTOMNAV-001 to FE-COMP-BOTTOMNAV-006
// FE-COMP-BOTTOMNAV-001 to FE-COMP-BOTTOMNAV-010
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
@@ -30,6 +30,7 @@ const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@exampl
beforeEach(() => {
resetAllStores();
mockNavigate.mockClear();
sessionStorage.clear();
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
});
@@ -79,4 +80,37 @@ describe('BottomNav', () => {
render(<BottomNav />);
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');
});
});
+14 -10
View File
@@ -2,13 +2,14 @@ import { useNavigate, useLocation, useMatch } from 'react-router-dom'
import { useAddonStore } from '../../store/addonStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { LayoutGrid, CalendarDays, Globe, Compass, Plus } from 'lucide-react'
import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Plus } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
atlas: { icon: Globe, labelKey: 'admin.addons.catalog.atlas.name' },
journey: { icon: Compass, labelKey: 'admin.addons.catalog.journey.name' },
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
atlas: { icon: Globe, labelKey: 'admin.addons.catalog.atlas.name' },
journey: { icon: Compass, labelKey: 'admin.addons.catalog.journey.name' },
collections: { icon: Bookmark, labelKey: 'admin.addons.catalog.collections.name' },
}
interface NavItem { to: string; label: string; icon: LucideIcon }
@@ -25,12 +26,15 @@ function useCreateAction(): { label: string; run: () => void } {
const onJourneyList = useMatch('/journey')
if (inTrip) {
// On the Costs tab the "+" adds an expense; otherwise it adds a place.
const tripTab = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(`trip-tab-${inTrip.params.id}`) : null
if (tripTab === 'finanzplan') {
return { label: t('costs.addExpense'), run: () => navigate(`/trips/${inTrip.params.id}?create=expense`) }
}
return { label: t('places.addPlace'), run: () => navigate(`/trips/${inTrip.params.id}?create=place`) }
// The "+" is context-aware per active tab: Bookings → reservation,
// Transports → transport, Costs → expense. Tabs without a create modal
// (lists / files / collab) fall through to adding a place. #1349
const id = inTrip.params.id
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`) }
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) {
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 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} />
<h2 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 5 }}>
<h2 style={{ margin: 0, fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 5 }}>
{t.titleBefore}<img src="/text-dark.svg" alt="TREK" style={{ height: 18 }} />{t.titleAfter}
</h2>
</div>
<p style={{ fontSize: 13, color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}>
<p style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}>
{t.description}
</p>
@@ -303,7 +303,7 @@ export default function DemoBanner(): React.ReactElement | null {
background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '8px 10px',
}}>
<Clock size={13} style={{ flexShrink: 0, color: '#0284c7' }} />
<span style={{ fontSize: 11, color: '#0369a1', fontWeight: 600 }}>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#0369a1', fontWeight: 600 }}>
{t.resetIn} {minutesLeft} {t.minutes}
</span>
</div>
@@ -312,7 +312,7 @@ export default function DemoBanner(): React.ReactElement | null {
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '8px 10px',
}}>
<Upload size={13} style={{ flexShrink: 0, color: '#b45309' }} />
<span style={{ fontSize: 11, color: '#b45309' }}>{t.uploadNote}</span>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b45309' }}>{t.uploadNote}</span>
</div>
</div>
@@ -323,15 +323,15 @@ export default function DemoBanner(): React.ReactElement | null {
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Map size={14} style={{ color: '#111827' }} />
<span style={{ fontSize: 12, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}>
{t.whatIs}
</span>
</div>
<p style={{ fontSize: 12, color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
</div>
{/* Addons */}
<p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
<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 }}>
<Puzzle size={12} />
{t.addonsTitle}
</p>
@@ -345,16 +345,16 @@ export default function DemoBanner(): React.ReactElement | null {
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<Icon size={12} style={{ flexShrink: 0, color: '#111827' }} />
<span style={{ fontSize: 11, fontWeight: 700, color: '#111827' }}>{name}</span>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: '#111827' }}>{name}</span>
</div>
<p style={{ fontSize: 10, color: '#94a3b8', margin: 0, lineHeight: 1.3, paddingLeft: 18 }}>{desc}</p>
<p style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: '#94a3b8', margin: 0, lineHeight: 1.3, paddingLeft: 18 }}>{desc}</p>
</div>
)
})}
</div>
{/* Full version features */}
<p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
<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 }}>
<Shield size={12} />
{t.fullVersionTitle}
</p>
@@ -362,7 +362,7 @@ export default function DemoBanner(): React.ReactElement | null {
{t.features.map((text, i) => {
const Icon = featureIcons[i]
return (
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: '#4b5563', padding: '4px 0' }}>
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#4b5563', padding: '4px 0' }}>
<Icon size={13} style={{ flexShrink: 0, color: '#9ca3af' }} />
<span>{text}</span>
</div>
@@ -377,7 +377,7 @@ export default function DemoBanner(): React.ReactElement | null {
position: 'sticky', bottom: 0, background: 'white',
marginTop: 'auto',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#9ca3af' }}>
<Github size={13} />
<span>{t.selfHost}</span>
<a href="https://github.com/mauriceboe/TREK" target="_blank" rel="noopener noreferrer"
@@ -387,7 +387,7 @@ export default function DemoBanner(): React.ReactElement | null {
</div>
<button onClick={() => setDismissed(true)} style={{
background: '#111827', color: 'white', border: 'none',
borderRadius: 10, padding: '8px 20px', fontSize: 12,
borderRadius: 10, padding: '8px 20px', fontSize: 'calc(12px * var(--fs-scale-body, 1))',
fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}>
{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"
style={{
background: '#ef4444',
fontSize: 9,
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
minWidth: 14,
height: 14,
padding: '0 3px',
+12 -4
View File
@@ -5,11 +5,11 @@ import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass } from 'lucide-react'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx'
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass }
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass, Bookmark }
interface NavbarProps {
tripTitle?: string
@@ -154,7 +154,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
<Link key={tab.id} to={tab.path}
className="flex items-center gap-1.5 transition-colors"
style={{
padding: '5px 16px', borderRadius: 9, fontSize: 13.5, fontWeight: 500,
padding: '5px 16px', borderRadius: 9, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 500,
color: isActive ? 'var(--text-primary)' : 'var(--text-muted)',
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',
@@ -252,6 +252,14 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{t('nav.settings')}
</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' && (
<Link to="/admin" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
@@ -274,7 +282,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
</div>
<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' }}
@@ -7,16 +7,20 @@ vi.mock('../../sync/mutationQueue', () => ({
mutationQueue: {
pendingCount: vi.fn(),
failedCount: vi.fn(),
conflictCount: vi.fn(),
},
}))
import { mutationQueue } from '../../sync/mutationQueue'
import { _resetNetworkMode } from '../../sync/networkMode'
const pendingCount = mutationQueue.pendingCount as ReturnType<typeof vi.fn>
const failedCount = mutationQueue.failedCount as ReturnType<typeof vi.fn>
const conflictCount = mutationQueue.conflictCount as ReturnType<typeof vi.fn>
afterEach(() => {
vi.clearAllMocks()
_resetNetworkMode()
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true })
})
@@ -24,15 +28,27 @@ describe('OfflineBanner (B3 surface)', () => {
it('shows the failed pill when failedCount > 0 while online', async () => {
pendingCount.mockResolvedValue(0)
failedCount.mockResolvedValue(2)
conflictCount.mockResolvedValue(0)
render(<OfflineBanner />)
expect(await screen.findByText(/2 changes failed to sync/i)).toBeInTheDocument()
expect(await screen.findByText(/failed to sync: 2/i)).toBeInTheDocument()
})
it('stays hidden when online with nothing pending or failed', async () => {
it('shows the conflict pill when conflicts exist while online', async () => {
pendingCount.mockResolvedValue(0)
failedCount.mockResolvedValue(0)
conflictCount.mockResolvedValue(3)
render(<OfflineBanner />)
expect(await screen.findByText(/conflicts: 3/i)).toBeInTheDocument()
})
it('stays hidden when online with nothing pending, failed or conflicting', async () => {
pendingCount.mockResolvedValue(0)
failedCount.mockResolvedValue(0)
conflictCount.mockResolvedValue(0)
const { container } = render(<OfflineBanner />)
// Give the async poll a tick to resolve.
+41 -39
View File
@@ -1,49 +1,44 @@
/**
* OfflineBanner connectivity + sync state indicator.
*
* States:
* N failed red pill "N changes failed to sync" (takes priority)
* offline + N queued amber pill "Offline · N queued"
* offline + 0 queued amber pill "Offline"
* online + N pending blue pill "Syncing N…"
* online + 0 pending hidden
* Priority (highest first):
* N failed red pill "Failed to sync: N" (changes were dropped)
* N conflicts purple pill "Conflicts: N" (need resolving)
* offline amber pill "Offline" / "Offline mode" / "Offline · N queued"
* online + N blue pill "Syncing N…"
* online + 0 hidden
*
* Rendered as a small floating pill anchored to the bottom-center of the
* viewport so it never competes with top navigation or sticky modal
* headers. On mobile it hovers just above the bottom tab bar.
*/
import React, { useState, useEffect } from 'react'
import { WifiOff, RefreshCw, AlertTriangle } from 'lucide-react'
import { WifiOff, RefreshCw, AlertTriangle, GitMerge } from 'lucide-react'
import { mutationQueue } from '../../sync/mutationQueue'
import { useNetworkMode } from '../../hooks/useNetworkMode'
import { useTranslation } from '../../i18n'
const POLL_MS = 3_000
export default function OfflineBanner(): React.ReactElement | null {
const [isOnline, setIsOnline] = useState(navigator.onLine)
const { t } = useTranslation()
const { offline, forced } = useNetworkMode()
const [pendingCount, setPendingCount] = useState(0)
const [failedCount, setFailedCount] = useState(0)
useEffect(() => {
const onOnline = () => setIsOnline(true)
const onOffline = () => setIsOnline(false)
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
return () => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
}
}, [])
const [conflictCount, setConflictCount] = useState(0)
useEffect(() => {
let cancelled = false
async function poll() {
const [n, failed] = await Promise.all([
const [n, failed, conflicts] = await Promise.all([
mutationQueue.pendingCount(),
mutationQueue.failedCount(),
mutationQueue.conflictCount(),
])
if (!cancelled) {
setPendingCount(n)
setFailedCount(failed)
setConflictCount(conflicts)
}
}
poll()
@@ -51,22 +46,34 @@ export default function OfflineBanner(): React.ReactElement | null {
return () => { cancelled = true; clearInterval(id) }
}, [])
const hidden = isOnline && pendingCount === 0 && failedCount === 0
const hidden = !offline && pendingCount === 0 && failedCount === 0 && conflictCount === 0
if (hidden) return null
const offline = !isOnline
// Failed mutations are the most important signal — they mean data was dropped.
// Conflicts come next (they still need a decision), then plain offline status.
const failed = failedCount > 0
const bg = failed ? '#b91c1c' : offline ? '#92400e' : '#1e40af'
const text = '#fff'
const conflict = !failed && conflictCount > 0
const bg = failed ? '#b91c1c' : conflict ? '#6d28d9' : offline ? '#92400e' : '#1e40af'
const label = failed
? `${failedCount} change${failedCount !== 1 ? 's' : ''} failed to sync`
: offline
? pendingCount > 0
? `Offline · ${pendingCount} queued`
: 'Offline'
: `Syncing ${pendingCount}`
let label: string
let icon: React.ReactElement
if (failed) {
label = t('settings.offline.banner.failed', { count: failedCount })
icon = <AlertTriangle size={12} />
} else if (conflict) {
label = t('settings.offline.banner.conflicts', { count: conflictCount })
icon = <GitMerge size={12} />
} else if (offline) {
label = pendingCount > 0
? t('settings.offline.banner.queued', { count: pendingCount })
: forced
? t('settings.offline.banner.forced')
: t('settings.offline.banner.offline')
icon = <WifiOff size={12} />
} else {
label = t('settings.offline.banner.syncing', { count: pendingCount })
icon = <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
}
return (
<div
@@ -81,25 +88,20 @@ export default function OfflineBanner(): React.ReactElement | null {
transform: 'translateX(-50%)',
zIndex: 9999,
background: bg,
color: text,
color: '#fff',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '6px 14px',
borderRadius: 999,
boxShadow: '0 4px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.08)',
fontSize: 12,
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
fontWeight: 600,
whiteSpace: 'nowrap',
pointerEvents: 'none',
}}
>
{failed
? <AlertTriangle size={12} />
: offline
? <WifiOff size={12} />
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
}
{icon}
{label}
</div>
)
+37 -23
View File
@@ -5,6 +5,9 @@ export interface PageSidebarTab {
id: string
label: string
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 {
@@ -160,29 +163,40 @@ function SidebarInner({
</div>
)}
<nav className="flex flex-col gap-1 flex-1">
{tabs.map((tab) => {
const Icon = tab.icon
const active = tab.id === activeTab
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
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',
}}
onMouseEnter={(e) => {
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>
)
})}
{(() => {
let lastGroup: string | undefined
return tabs.map((tab) => {
const Icon = tab.icon
const active = tab.id === activeTab
const showHeader = !!tab.group && tab.group !== lastGroup
lastGroup = tab.group
return (
<React.Fragment key={tab.id}>
{showHeader && (
<div className="text-[10px] font-bold tracking-widest uppercase text-content-faint px-3 mt-3 mb-0.5 first:mt-0">
{tab.group}
</div>
)}
<button
onClick={() => onTabChange(tab.id)}
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',
}}
onMouseEnter={(e) => {
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>
{footer && (
<div

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