Compare commits

..

102 Commits

Author SHA1 Message Date
jubnl bf969ee80d feat(auth): add "Remember me" checkbox to extend session lifetime (#1189)
Adds a "Remember me" checkbox to the login form (single responsive page,
covers mobile + desktop). Unchecked (default) issues the existing
SESSION_DURATION JWT with a browser-session cookie (no maxAge); checked
issues a longer-lived JWT plus a persistent cookie sized by the new
SESSION_DURATION_REMEMBER env var (default 30d). The choice is threaded
through the MFA verify leg so it survives the step-up.

Register/demo logins keep their current persistent behaviour.
2026-06-15 12:21:05 +02:00
Maurice 2d413c99cf build(deps): bump tsx's esbuild to 0.28.1 (GHSA-gv7w-rqvm-qjhr)
The production image's last image-scan finding was esbuild 0.28.0, pulled
in transitively by tsx. Pin tsx's esbuild to 0.28.1 (within tsx's ~0.28.0
range) to clear GHSA-gv7w-rqvm-qjhr. Lockfile-only; no runtime change.
2026-06-15 10:50:15 +02:00
Maurice 58c7bd831a build(docker): rebuild gosu with a current Go toolchain
Debian's apt gosu ships an old Go stdlib that the image CVE scan flags
(1 critical + several high, all in golang/stdlib). Build gosu from source
with a current Go toolchain and copy the static binary in instead; the
runtime behaviour is unchanged — gosu still drops root to node at startup.
2026-06-15 10:38:01 +02:00
Maurice 8d1e7dded0 ci(security): only fail Docker Scout on fixable CVEs
Add only-fixed so the scan no longer fails on vulnerabilities with no
upstream fix available (e.g. base-image OS packages), and only flags
actionable, fixable findings.
2026-06-15 10:21:39 +02:00
Maurice 127a92c8f5 Merge main into dev: back-merge wiki dev-env updates before the 3.1.0 release
# Conflicts:
#	wiki/Development-environment.md
2026-06-15 10:00:15 +02:00
jubnl 1ed00b67ad fix(pwa): persist offline storage + Mapbox offline policy (H8, H9) (#1184)
H8: prefetched tiles and file blobs could be evicted under storage pressure
(worsened by opaque tile responses inflating the quota ~7MB each), blanking the
offline map right when a traveler needs it. Request persistent storage at app
init so the browser exempts our caches from eviction. We deliberately keep tile
requests no-cors (a cors switch would break self-hosted/custom tile providers
without CORS headers), so persistence is the safe mitigation rather than
de-opaquing responses.

H9: Mapbox GL users had no offline map at all — no runtimeCaching matched the
Mapbox hosts. Add a StaleWhileRevalidate rule for api.mapbox.com /
*.tiles.mapbox.com so visited areas are available offline (best-effort; full
pre-download still requires the Leaflet renderer, now documented).

- new sync/persistentStorage.ts requestPersistentStorage(), called from main.tsx
- vite.config: mapbox-tiles SW cache rule
- MapViewAuto / tilePrefetcher comments document the offline-maps policy
- tests for the persist helper (granted / already-persisted / absent / rejects)
2026-06-15 09:33:35 +02:00
jubnl 4d072b4cb8 fix(realtime): correct assignment:created echo dedup (H11) (#1183)
When X-Idempotency/X-Socket-Id let an own-echo through, the assignment:created
dedup had two bugs: it keyed on place id, so (1) a legitimate second assignment
of a place already on the day was silently dropped, and (2) the temp-version
reconciliation matched place?.id === placeId, letting undefined === undefined
collapse place-less rows onto each other.

- dedup now keys on assignment id (exact-id duplicate -> no-op)
- temp (negative-id) optimistic rows are reconciled only when a real placeId
  matches, replacing just that row; a sibling temp of another place is untouched
- everything else appends, including a genuine 2nd assignment of the same place
- tests: 2nd-of-same-place kept, correct temp picked among siblings, place-less
  rows don't collapse

Note: the broader own-echo suppression relies on X-Socket-Id being sent; this
fixes the client-side fallback when an echo slips through.
2026-06-15 09:33:12 +02:00
jubnl 028e3e0a84 fix(server): lengthen idempotency key TTL to survive multi-day offline (H6) (#1182)
The nightly cleanup deleted idempotency keys older than 24h. The TREK client
replays queued mutations with their X-Idempotency-Key on reconnect, so a device
offline longer than a day had its keys GC'd before it returned — the replayed
POST was then treated as new and created a duplicate.

- raise the TTL to 30 days (DEFAULT_IDEMPOTENCY_TTL_SECONDS), overridable via
  IDEMPOTENCY_TTL_SECONDS
- extract purgeExpiredIdempotencyKeys(now, ttl, db) (mirrors cleanupOldBackups)
  with an injectable db, and have the cron job call it
- tests: 30-day default eviction, 25-day key retained (was dropped at 24h),
  env override

H7 (exactly-once across the lost-response window) is deferred: a correct fix
must store the response in the same DB transaction as the entity write. Doing
it in the generic interceptor (reserve-before-handler) cannot store the real
response body for the crash case, which would break the client's temp->real id
remapping on replay (mutationQueue.flush relies on the entity in the body). It
needs a per-service change and is tracked separately.
2026-06-15 09:32:42 +02:00
jubnl 39b5af790e fix(sync): re-hydrate active trip store on reconnect/online (H1) (#1181)
setRefetchCallback was dead code, so on reconnect the queue flushed and Dexie
re-seeded but the open trip's Zustand store was never refreshed — a
collaborator's edits made while we were offline didn't appear until navigating
away and back.

- new tripStore.hydrateActiveTrip(): silent refresh of the active trip's
  collaborative state (days/places/packing/todo/budget/reservations/files),
  no resetTrip and no isLoading toggle so there's no splash on reconnect
- syncTriggers wires setRefetchCallback to it (WS layer awaits the flush hook
  first) and re-hydrates open trips after the online-event syncAll; cleared on
  unregister
- websocket exposes getActiveTrips() for the online-event path
- tests: refetch wiring + ordering, silent hydrate without reset/splash
2026-06-15 09:32:28 +02:00
jubnl 1eb2cb8eb2 fix(store): reset and uniformly hydrate trip-scoped slices in loadTrip (H4, H5) (#1180)
loadTrip only replaced the first slice group, so budget/reservations/files
from a previous trip stayed visible after switching trips (data exposure on a
shared screen). Those three also loaded via separate tab-gated effects, so they
never hydrated offline for an unopened tab.

- resetTrip() clears every trip-scoped slice (keeps global tags/categories) and
  runs at the top of loadTrip, so a switch can't leak the prior trip's data
- loadTrip now hydrates budget/reservations/files through their repos alongside
  the rest (non-fatal catches), making offline hydration uniform
- useTripPlanner drops the redundant loadFiles + reservations/budget effects;
  tab-gated lazy reloads stay as on-demand refresh
- tests: cross-trip no-leak, uniform hydration, resetTrip
2026-06-15 09:25:28 +02:00
jubnl bcd2c8c959 fix(repo): fall back to Dexie when a network read fails (H2) (#1179)
Repos gated reads on raw navigator.onLine and the online branch had no
try/catch, so a captive portal or connected-but-no-internet (navigator.onLine
lying "true") threw a network error instead of serving the good cached copy —
blanking the trip even though Dexie held it.

- new onlineThenCache(onlineFn, cacheFn) helper: reads the cache when offline,
  and on a network-level failure (Axios error with no HTTP response). A genuine
  HTTP error (4xx/5xx — the server responded) is rethrown so callers still set
  error state / navigate, not masked by a stale cache.
- gates only on navigator.onLine, NOT the connectivity probe: the probe is a
  coarse global flag and one failed health check would otherwise divert every
  read to the (possibly empty) cache even when the request would succeed.
- every repo list/get read path routed through it (reads only — writes still
  go through the mutation queue so failures surface)
- tests: captive-portal fallback, HTTP-error rethrow, non-Axios rethrow
2026-06-15 09:25:11 +02:00
jubnl 5a9c14fc8e fix(db): scope, evict, and cap the offline blob cache (H3) (#1178)
Blob cache previously leaked forever: clearTripData omitted it, entries had
no trip discriminator, and there was no size/count bound, so file blobs
survived trip eviction and could starve the map-tile cache for quota.

- BlobCacheEntry gains tripId + bytes; Dexie v3 adds a tripId index with a
  backfill upgrade (legacy rows -> tripId -1, bytes from blob.size)
- clearTripData purges the trip's blobs in-transaction
- enforceBlobBudget() evicts oldest-by-cachedAt past 200 entries / 100 MB
- tripSyncManager threads tripId/bytes into puts and enforces the budget
2026-06-15 09:24:52 +02:00
jubnl 5500405f2f fix(security): stop cross-user offline data leak on shared devices (#1176)
Closes BLOCKER B4 — three reinforcing paths could serve one account's
cached data to the next user on a shared device:

- The Workbox 'api-data' cache keyed trip/user-scoped GETs by URL only
  (cookie-blind). Changed to NetworkOnly; offline reads come from the
  per-user IndexedDB cache via the repo layer instead.
- IndexedDB had no per-user scoping. The Dexie connection is now scoped
  per user (trek-offline-u<id>) behind a Proxy so the ~19 importers keep a
  stable binding; login opens the user DB, logout deletes it and returns
  to the anonymous DB.
- logout() was fire-and-forget and racy: background flush/syncAll could
  re-seed the DB after the wipe. It is now async and ordered — close an
  auth gate, unregister sync triggers, disconnect, clear caches, delete
  the user DB — and flush()/syncAll() bail when the gate is closed.
2026-06-15 07:58:20 +02:00
jubnl 0a794583d7 fix(maps): make offline tiles cover real trips (cap coherence + zoom-clamp) (#1177)
Closes BLOCKER B5 — the offline map was blank for most real trips:

- The Workbox 'map-tiles' cache held only 1000 entries while the prefetcher
  budgeted ~3413, so prefetched tiles were evicted on arrival. Both caps are
  now a coherent 12288 (~180 MB), kept in sync with cross-referencing comments.
- prefetchTilesForTrip skipped a trip entirely when its all-zooms estimate
  exceeded the cap, so region/road-trip bboxes got no tiles. Removed the
  all-or-nothing guard; prefetchTiles already fills zooms low→high and stops at
  the budget, so large trips now cache the zooms that fit instead of nothing.
2026-06-15 07:53:12 +02:00
jubnl 4188f67ab7 fix(sync): remap temp ids, prevent id collisions, surface failed mutations (#1175)
Closes three offline BLOCKERs from the PWA audit:

- B1: offline edits/deletes of an offline-created entity were lost. The
  negative temp id was baked into the PUT/DELETE url and never rewritten
  after the CREATE returned a real id, so dependents 404'd and were dropped.
  Dependents now carry a {id} placeholder + tempEntityId; flush builds a
  tempId->realId map and durably rewrites still-queued dependents on CREATE
  success (survives flush boundaries / reloads).
- B2: tempId = -(Date.now()) collided within a millisecond, overwriting an
  optimistic row. Replaced with a monotonic nextTempId() minter.
- B3: any 4xx marked the mutation failed with no rollback and no signal, and
  the badge ignored failed rows. Terminal failures now roll back the phantom
  optimistic CREATE; 401/408/425/429 are treated as retryable; failedCount()
  is surfaced in OfflineBanner (red pill) and OfflineTab.
2026-06-15 07:51:52 +02:00
jubnl 8077ffab34 fix(maps): bound place-photo cache growth (Wikimedia + Google) (#1174)
The place-photo cache (uploads/photos/google) grew unbounded: a Wikimedia
geosearch path cached full-res originals despite requesting a 400px thumb,
the writer applied no size guard, nothing reclaimed orphaned files, and
backups archived the whole re-derivable cache verbatim.

- Prefer the scaled `thumburl` over the full-res `info.url` in the Commons
  geosearch fallback.
- Downscale any cached image to <=800px JPEG via the existing jimp dep,
  with a safe fallback to the original bytes on decode failure.
- Add sweepOrphans() (orphaned meta rows + stray files) wired into the
  scheduler (startup + nightly), and removeIfUnreferenced() called on
  place delete for prompt reclamation.
- Exclude the re-derivable photo/trek caches from backups; restores
  self-heal as the cache dirs are recreated at startup.
2026-06-14 23:31:02 +02:00
Maurice 3e9626fce9 feat(places): enrich list-imported places via the Places API (#886) (#1161)
* feat(places): enrich list-imported places via the Places API (#886)

Google/Naver list imports only carry a name and coordinates, so the places open
as bare pins — the Maps tab jumps to coordinates, with no photo, address or
open/closed. Add an opt-in "Enrich places via Google" toggle to the list-import
dialog, shown only when a Google Maps key is configured.

When enabled, after the (fast, unchanged) import the server runs a background
pass that re-resolves each place by name — biased to and validated against the
imported coordinates so a common-name search cannot overwrite the wrong place —
and fills the empty address/website/phone/photo columns plus the resolved
google_place_id, pushing each row over the live sync. Opening hours and the
proper Maps link then work on demand from the stored id.

Enrichment only fills empty fields, runs detached so a long list never blocks
the import, and no-ops when no key is configured.

* fix(places): use the ToggleSwitch component for the enrich toggle

Match the rest of the app — the import-enrichment opt-in used a raw checkbox;
swap it for the shared ToggleSwitch (text left, switch right) like the settings
toggles.
2026-06-14 00:54:11 +02:00
rossanorbr 3398da633b fix(planner): make route tools reachable in mobile day plan sheet (#1142)
* wiki: update dev env

* wiki: small precision in dev env

* fix(planner): make route tools reachable in mobile day plan sheet

On mobile, selecting a day closes the plan sheet immediately, so the
route tools footer (Route toggle / Optimize / routing profile) - gated
on the selected day - was never reachable. Desktop was unaffected.

- Add showRouteToolsWhenExpanded prop to DayPlanSidebar: when set,
  route tools render on any expanded day with 2+ assigned places
- Make handleOptimize accept an explicit dayId (defaulting to
  selectedDayId, preserving desktop behavior)
- Keep the distance/duration pill gated on the selected day, since
  routeInfo belongs to the selected day's calculated route
- Enable the prop on the mobile plan sheet in TripPlannerPage

* fix(planner): correct route-tools prop doc and dev-environment wiki

- Reword the showRouteToolsWhenExpanded JSDoc to list the controls the
  footer actually renders (Route toggle / Optimize / travel profile);
  there is no "Open in Google Maps" action in that block.
- Wiki: drop the non-existent server test:parity script, document the
  real shared i18n:parity checks, and fix the i18n note (the translation
  layer already lives in @trek/shared, it is not "upcoming").

---------

Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-06-13 15:24:27 +02:00
Maurice 31f99f0e4e Various fixes: 2FA autofocus, viewer-timezone times, duplicate place guard (#1159)
* fix(auth): autofocus the 2FA code input when the MFA step appears (#767)

* fix(notifications): show notification and admin times in the viewer timezone (#1149)

SQLite CURRENT_TIMESTAMP is UTC but the string has no Z, so the client parsed
it as local time. Normalize in-app notification created_at to ISO-UTC, and stop
forcing the admin user table to render in the server timezone.

* fix(places): warn before adding a duplicate place (#1152)

Manually adding a place did not check the existing pool, so the same POI could
land in Unplanned twice. Flag a likely duplicate by Google Place ID, name or
near-identical coordinates and require a confirming second click to add anyway.
2026-06-13 15:02:18 +02:00
Maurice 56655d53b4 AirTrail integration: import flights & two-way sync (#214) (#1158)
* feat(admin): register AirTrail as an integration addon

Off by default; toggle lives in Admin -> Addons with a Plane icon. The
per-user connection (URL + API key) follows in integration settings.

* feat(integrations): add per-user AirTrail connection

Settings -> Integrations gains an AirTrail section: instance URL + Bearer
API key (encrypted at rest via apiKeyCrypto), a self-signed-TLS opt-in and
a test-connection check. Served by a small Nest controller under
/api/integrations/airtrail, gated on the airtrail addon and SSRF-guarded.
The key is per-user, so it only ever returns that user's own flights.

* feat(transport): import flights from AirTrail

Adds an AirTrail Import button next to Manual Transport that lists the
user's AirTrail flights and highlights the ones inside the trip dates.
Selected flights become reservations linked to their AirTrail origin
(external_* columns), deduped against flights already in the trip, then
broadcast to every member. The mapping resolves airports, airport-local
times and flight metadata; the linkage is what the two-way sync rides on.

* feat(transport): badge AirTrail-linked flights as synced

Linked reservations show an 'AirTrail synced' badge, or 'no longer
synced' once the flight is gone from AirTrail.

* feat(transport): keep TREK and AirTrail flights in sync both ways

A scheduled poll reconciles each connected owner's flights: field edits
(detected by snapshot hash, since AirTrail has no updated_at) flow into
the linked reservation and broadcast live; a flight deleted in AirTrail
keeps the TREK row but stops syncing. Editing a linked flight in TREK
pushes back to AirTrail under the importer's credentials, preserving the
existing seat manifest; if the owner disconnected the link detaches so the
poll can't revert the local edit. Deleting in TREK never touches AirTrail.

* i18n(airtrail): add AirTrail strings across all locales

* test(airtrail): cover flight mapping, timezones and snapshot hashing

* fix(airtrail): reduce airline/aircraft objects to codes

The flight list/get response returns airline and aircraft as joined
objects ({icao, iata, name, ...}), not bare codes. Mapping them straight
through produced '[object Object]' titles and stored objects in metadata,
which crashed reservation rendering. Extract the ICAO/IATA code instead,
and title flights by their flight number.

* fix(airtrail): clear error on non-JSON responses, tolerate /api in URL

A misconfigured instance URL made AirTrail serve its SPA/login HTML, and
the raw JSON.parse failure surfaced as 'Unexpected token <'. Surface an
actionable message instead, and strip a pasted trailing /api so the base
URL still resolves.

* feat(transport): sync AirTrail edits on trip open, not just on the poll

Add a per-user on-demand sync (POST /integrations/airtrail/sync) triggered
when a connected user opens a trip, so AirTrail-side edits appear right away
instead of waiting up to a full poll cycle. Lower the background poll from 15
to 5 minutes as a safety net.

* fix(transport): refresh imported AirTrail flights without a reload

loadTrip doesn't fetch reservations, so a freshly imported flight only
appeared after a full page reload — use loadReservations instead. Also show
flight dates in the user's locale format (e.g. 13.06.2026) rather than the
raw ISO string.

* style(settings): align AirTrail connection with the photo-provider layout

Match the Immich section: stacked URL/key fields, a ToggleSwitch for
self-signed TLS, and a Save / Test-connection row with a status badge.

* feat(transport): add a seat field when editing flights

The transport editor only offered a seat field for trains; flights had
none even though imports store metadata.seat. Show and persist a seat for
flights too.

* style(transport): match the AirTrail button height to Manual Transport

* feat(transport): put the flight seat next to flight number and sync it to AirTrail

Move the seat from a standalone row to the per-leg flight details (beside
the flight number), stored per leg in metadata.legs[].seat with the first
leg mirrored to metadata.seat. On push, set the seat number on the user's
own AirTrail seat (the one with a userId), leaving co-passengers untouched;
import/poll read that same seat back.

* refactor(planner): move the AirTrail trip-open sync into useTripPlanner

Page containers must not own state/effects (lint:pages). Same logic,
relocated from the page into its data hook.

* test(db): pin the region-reconciliation test to its schema version

The test re-ran 'the last migration' assuming the reconciliation is last;
it no longer is once later migrations are appended. Pin to version 135 and
re-run from there (the appended migrations are idempotent).
2026-06-13 13:11:35 +02:00
jubnl f91721c73e fix(packing): respect per-item quantity in bulk import (#1157) 2026-06-13 03:23:37 +02:00
Maurice 0a58e3270b fix(packing): add more bag colors so sub-bags stop repeating (#1156)
The auto-assigned bag palette only had 8 colors, so the 9th bag reused the first one. Double it to 16 (keeping the existing 8 and their order) and keep the server and client lists in sync - both cycle BAG_COLORS[count % length].
2026-06-13 00:52:49 +02:00
Maurice e224befde7 Map/planner/dashboard polish and small community features (#1155)
* feat(planner): reorder days in a modal instead of a dropdown

The day-reorder control opened a small anchored dropdown; move it into the shared Modal (portal, dimmed backdrop, Esc/backdrop close) so it matches the Add activity dialog. Drag handles, up/down arrows and the day badges are unchanged.

* feat(map): explore reliability, Mapbox popups + compass, region-biased search

POI explore: clamp oversized viewports, query the Overpass mirrors in parallel (first valid response wins) with a per-request timeout and a short-lived cache, and surface a retry when every mirror fails - so it returns results at any zoom instead of timing out.

Mapbox renderer: add the place/POI hover popups (name, category, address, photo) the Leaflet map already had, plus a compass pill next to the explore pill that resets the view to north.

/api/maps/search: accept an optional locationBias to fix foreign-region bias and expose Google's place types in the result.

* feat(dashboard): list-view and mobile polish

Use the Archived status label for the filter and show Open dates for trips without dates; drop the unused settings button next to the view toggle. Desktop list view renders the date as a stat-style block separated from the counts.

Mobile list rows are stacked (slim cover banner + centred date), trip actions stay visible (touch has no hover), and the hero card's hover lift is disabled on touch; small spacing fix under the sidebar.

* feat: small community-requested options

Raise the plan-note subtitle limit to 250 characters and add more note icons. Expose is_archived and cover_image on the update_trip MCP tool. Add place coordinates to the PDF export. Allow creating a category from an existing to-do, and add a show/hide toggle on the admin password fields.

* test(shared): bump day-note subtitle limit assertion to 250

* test: align specs with the new search param order and archive label

Keep lang as the 3rd positional arg of the maps search controller so the existing unit test stays valid, and forward locationBias as the 4th. Add the now-used Popup to the MapViewGL mapbox mock, switch the dashboard archive-filter query to the Archived label, and expect the 4-arg search call.
2026-06-12 20:23:34 +02:00
Maurice f46cc8a98e Reorder whole days and insert a day (#589) (#1148)
* feat(days): reorder whole days and insert a day at a position

Adds reorderDays + insertDay to the day service and a PUT /days/reorder route
(plus an optional position on create). Day rows stay stable so a day's
assignments, notes, bookings and accommodations ride along by id; on a dated
trip the calendar dates stay pinned to their slots while the content moves
across them, and each booking's date is re-stamped onto its day's new date
(time-of-day preserved) so day_id stays consistent. Renumbering uses the
two-phase write to avoid the UNIQUE(trip_id, day_number) collision, and a move
that would invert an accommodation's check-in/out span is rejected.

* feat(planner): reorder days from a toolbar popup, and add days

A new toolbar button opens a popup listing the days; drag a row by its grip or
use the up/down arrows to reorder, and add a day from there. Reorders apply
optimistically with rollback and sync over WebSocket; the day headers are left
untouched, so the existing place drop-targets are unaffected.

* i18n: add day-reorder strings across all languages
2026-06-12 00:17:49 +02:00
Maurice 1378c95078 Explore places on the map, planner route fixes, and instance-wide Mapbox (#1147)
* feat(maps): add an OSM POI search endpoint (category within a viewport)

New /api/maps/pois queries OpenStreetMap via Overpass for places of a category
(restaurants, cafes, hotels, sights, …) inside a bounding box. OSM-only by design
— it never calls Google, even when a Google key is configured.

* feat(map): explore nearby places on the trip map (OSM category pill)

A floating, icon-only pill over the planner map lets you toggle a POI category and
see those OpenStreetMap places in the current view; clicking a marker opens the
add-place form pre-filled (name, address, website, phone). Single-select with a
'search this area' action after the map moves. Renders on both the Leaflet and
Mapbox maps, and can be turned off in settings (discussion #841).

* fix(planner): anchor timed places when optimising and route transports by location

- The day optimiser no longer reshuffles places that have a set time — they stay
  anchored to their time, like locked places.
- The route now uses a transport's departure/arrival location as a waypoint when it
  has one (e.g. a flight's airport), instead of breaking the route at every booking;
  transports without a location are ignored for routing but still show their leg's
  distance/duration under the booking.

* feat(admin): instance-wide Mapbox defaults in default user settings

Admins can set a shared Mapbox token (plus style, 3D and quality) as instance
defaults, so the whole instance can use Mapbox without each user pasting their own
key. Users without their own value inherit it via the existing admin-defaults
merge; the shared token is stored encrypted (discussion #920).
2026-06-11 23:42:16 +02:00
Maurice bb477645a3 Support multi-leg (layover) flights (#1146)
* feat(transport): support multi-leg (layover) flights in the booking form

A flight booking can now hold an ordered chain of airports (e.g. FRA -> BER ->
HND) instead of a single departure/arrival pair. The route is entered as a list
of waypoints with a '+ add stop' button; each stop carries its own arrival and
departure time plus the airline/flight number of the segment leaving it, while
the whole booking keeps one price.

Stored without a schema change: the existing reservation_endpoints rows carry the
ordered waypoints (from/stop/to by sequence) and a metadata.legs array holds the
per-leg detail. Top-level metadata (departure_airport/arrival_airport/airline/
flight_number) mirrors the first and last leg, so a single-leg flight persists
exactly as before and legacy readers keep working.

* feat(planner): show each flight leg as its own day-plan entry, ordered by time

A multi-leg flight now expands into one entry per leg (BER -> FRA, then FRA ->
HND), each on its own day with its own times, instead of a single span. Each leg
is an addressable slot (reservation id + leg index) so places and notes can be
dropped into the layover gap between legs; the per-leg position is persisted in
metadata.legs[i].day_positions and survives a reload.

Day-plan items are now ordered chronologically: anything with a time (a place's
time, a flight leg, a timed note) sorts by that time, and untimed items inherit
the time of the item before them so they stay where they were placed.

* feat(planner): show the full multi-stop route in the bookings panel

The route row now lists every waypoint (FRA -> BER -> HND) by sequence instead of
just the first and last airport.

* feat(map): draw multi-leg flights as connected legs with a marker per airport

Both the Leaflet and Mapbox overlays now render a flight over all its waypoints:
one great-circle arc per leg and a marker at every airport, with the label
showing the full route and the summed distance. A single-leg flight is unchanged.

Also drops the floating stats badge that was drawn on transport arcs.

* fix(map): centre a clicked place above the bottom inspector panel

Selecting a place panned/flew it to the dead centre of the screen, where it sat
behind the detail card. Both overlays now bias the target into the visible area
above the bottom panel (Leaflet offsets the pan by the inspector inset; Mapbox
passes the padding to flyTo).

* feat: show the full multi-stop flight route in PDF and calendar export

The PDF day list and the ICS export now render the whole route (FRA → BER → HND)
for a multi-leg flight instead of just the first and last airport, falling back to
the flat metadata for single-leg flights. The ICS keeps a single event per booking.

* feat(import): group connecting flight legs into one multi-leg booking

When a booking confirmation contains several flight legs sharing a PNR that
connect at the same airport with a short layover (under 24h), they are now
imported as a single multi-leg booking (from/stop/to endpoints + metadata.legs)
instead of one booking per leg. A round trip (same PNR, multi-day gap) stays two
separate bookings, and a single flight is unchanged.

* i18n: translate the new flight-route strings into all languages

* i18n: translate the Costs page into every language

The Budget → Costs rework left the new costs.* strings untranslated in every
non-English locale (they fell back to English). Translate them across all
supported languages.

* Revert "fix(map): centre a clicked place above the bottom inspector panel"

This reverts commit 0936103f04.
2026-06-11 22:17:14 +02:00
Maurice e65acb3de7 Fix a batch of reported bugs (#1145)
* fix(maps): fall back to OSM/Wikipedia for place photos and normalize non-standard language codes (#1137)

* fix(auth): refuse password reset for OIDC/SSO-linked accounts (#1129)

* fix(docker): ship server/assets (airports + atlas geo) in the runtime image (#1133, #1119)

* fix(unraid): point the template at a PNG icon Unraid can render (#1073)

* fix(offline): serve cached file blobs when offline or on network failure (#1046, #1069)

* fix(map): centre the selected pin in the visible map area above the bottom panel (#1125)

* fix(pdf): render persisted place-photo proxy URLs as images (#1130)

* fix(planner): show the selected place category in the edit form (#1134)

* fix(dashboard): collapse list-view trip cards to a compact row on mobile (#1132)
2026-06-11 13:31:43 +02:00
jubnl 3c040fab11 fix: miscellaneous bug fixes (#1139)
* fix(share): serve place thumbnails in shared trip links (#1100)

Google-sourced place photos are stored as image_url pointing at the
JWT-guarded /api/maps/place-photo/:placeId/bytes endpoint, so they 401
for an unauthenticated shared-trip viewer and render as broken images.

Rewrite place image_url values in the shared payload to a public,
token-scoped proxy (/api/shared/:token/place-photo/:placeId/bytes) and
add an unguarded SharedController route that validates the token and that
the place belongs to its trip before streaming the cached bytes. Mirrors
the existing JourneyPublicController precedent. No client changes needed.

* fix(atlas): replace Natural Earth with geoBoundaries for up-to-date regions (#1119)

Atlas sourced country and sub-national boundaries from Natural Earth's GitHub
`master` at runtime. That data is stale (e.g. it still shows Norway's pre-2020
counties such as Oppland/Hordaland) and depicts some contested territory in
unwanted ways (nvkelso/natural-earth-vector#391), so Natural Earth is dropped
entirely.

- Country borders (admin0) now come from the geoBoundaries CGAZ composite;
  sub-national regions (admin1) from per-country gbOpen, which carries ISO 3166-2
  codes. A new script (server/scripts/build-atlas-geo.mjs) normalizes and quantizes
  them into committed gzipped bundles under server/assets/atlas, read server-side at
  runtime (no network at boot, no GitHub CSP allowlist entry).
- New GET /addons/atlas/countries/geo serves the country layer; the client fetches
  it from the API instead of GitHub.
- A migration reconciles manually-marked visited_regions against the new bundle
  (valid code -> keep; region name still matches -> re-code; curated merge crosswalk
  for renamed reforms; else leave intact), with UNIQUE-safe dedup. bucket_list and
  visited_countries hold only invariant alpha-2 country codes, so they are untouched.
- Attribution added (NOTICE.md + README) per geoBoundaries CC BY 4.0.

Closes #1119

* fix(packing): make templates admin-only to create, usable by members

Creating a packing-list template was gated only by trip access, so any
trip member could create one from the Lists feature, while applying a
template silently failed for non-admins because the apply dropdown was
populated from the AdminGuard-protected /api/admin/packing-templates
endpoint.

- save-as-template now returns 403 for non-admins; the Save-as-Template
  button is hidden unless the user is an admin (both the TripPlanner
  toolbar and the inline packing header).
- add member-accessible GET /api/trips/:tripId/packing/templates so the
  apply dropdown lists templates for any trip member; client fetches
  from it instead of the admin endpoint.

Closes #1120
Closes #1121

* fix(packing): show bag tracking to non-admin members

The global Bag Tracking toggle was only readable via the admin-gated
GET /api/admin/bag-tracking, so non-admin trip members got 403 and the
weight fields, bag circles, and BAGS sidebar never rendered (#1124).

Surface the flag through the already-authenticated GET /api/addons
(loaded into the client addon store on app start for every user); the
packing hook reads it from the store instead of the admin endpoint. The
admin write path stays admin-gated and unchanged.
2026-06-09 16:02:37 +02:00
Maurice 49b3af8b0d feat: optimize routes around accommodation, confirm note deletions (#1123)
Optimize day routes around the accommodation

When a day has an accommodation set, the route optimizer now treats it as
the day's home base: it optimizes a loop that leaves the hotel and returns
to it, so the stop nearest the hotel comes first. On a transfer day -
checking out of one hotel and into another - the route runs from the first
hotel to the second instead.

The optimizer also gained a 2-opt pass on top of the nearest-neighbor
ordering, which removes the crossings the greedy pass used to leave behind.
A new display setting ("optimize route from accommodation", on by default)
lets you turn the anchoring off.

Confirm before deleting notes

Deleting a plan note or a collab note now asks for confirmation first. On
phones and tablets the edit and delete icons sit close together and were
easy to mis-tap, which deleted notes with no way back.
2026-06-07 12:52:06 +02:00
Maurice 093e069ccc Backend/frontend hardening & consistency cleanups (#1113)
* refactor(auth): session token validation and password-change consistency

* refactor(journey): entry field allow-list and public share-link consistency

* refactor(mcp): align tool authorization with the REST permission checks

* chore: input validation and sanitisation touch-ups (uploads, pdf, maps, backup, csp)
2026-06-06 16:37:03 +02:00
jubnl 070ef01328 chore: update kitinerary version 2026-06-05 19:26:34 +02:00
Maurice a876fb2634 feat: Passkey (WebAuthn) login (#1111)
* feat(auth): passkey (WebAuthn) login — server endpoints, schema + admin toggle

Add @simplewebauthn/server registration and primary (discoverable) login ceremonies under /api/auth/passkey, a webauthn_credentials + single-use webauthn_challenges schema (migration), the instance-wide passkey_login toggle (default off) enforced before auth by a guard, and require_mfa satisfaction via a verified passkey. RP ID/origin come only from server config (webauthn_rp_id/origins -> APP_URL), never request headers.

* feat(auth): passkey enrolment, login button + admin settings UI

PasskeysSection in account settings (add/rename/remove with a current-password step-up), a 'Sign in with a passkey' button on the login page, the admin enable + RP-ID/origins controls, and a per-user admin reset action.

* i18n(auth): passkey strings across all locales

Add login/settings/admin passkey keys to en and all 19 translated locales.
2026-06-05 18:54:13 +02:00
Maurice 247433fb2a feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile (#1106)
* fix(journey): authorize reads of the journey share link

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

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

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

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

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

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

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

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

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

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

* feat(auth): configurable session duration via SESSION_DURATION

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

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

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

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

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

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

* chore: enforce i18n parity

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Finish the NestJS migration — drop the legacy Express app

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

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

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

Two correctness/security gaps the NestJS migration introduced:

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

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

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

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

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

* Derive client domain types from the shared schema contracts

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

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

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

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

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

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

* Reject WebSocket tokens minted before a password change

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

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

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

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

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

* Add semantic theme color tokens to Tailwind

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

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

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

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

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

* Remove the unrouted photos page and its dead photo components

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test(planner): update route hook tests for calculateRouteWithLegs
2026-05-25 22:27:49 +02:00
jubnl b25eb18ea4 wiki: small precision in dev env 2026-05-25 22:16:16 +02:00
jubnl 8410d7c4a5 wiki: update dev env 2026-05-25 22:10:44 +02:00
Julien G. c130ed41be chore: fix monorepo build pipeline and migrate shared to built package (#1056)
* chore: fix monorepo build pipeline and migrate shared to built package

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This reverts commit 67cf290cda.

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

This reverts commit f92b95e054.

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

This reverts commit 797183de08.

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

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

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

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

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

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

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

Remaining F4/F6/F8 checklist items (trip-access + permission levels + MFA policy, e2e harness/seed + 80% gate, Nest↔Express parity test, Playwright PR-comment workflow) are tracked on the first consuming module cards (L1/A1/C1).
2026-05-25 14:29:30 +02:00
github-actions[bot] e27be5c965 chore: bump version to 3.0.22 [skip ci] 2026-05-24 23:13:41 +00:00
Julien G. 86ee8044da v3.0.22 Bug Fixes & Improvements (#1041)
Bundles the v3.0.22 bug fixes and improvements. See the release notes for the full list.
2026-05-25 01:13:20 +02:00
Maurice 75772445a7 Update security contact email in SECURITY.md 2026-05-24 19:39:53 +02:00
github-actions[bot] bfe6664ac4 chore: bump version to 3.0.21 [skip ci] 2026-05-15 22:53:13 +00:00
Julien G. 117942f45e v3.0.21 Bug Fixes (#998)
* fix(journey): remove photo upload count limit and surface upload errors (#997)

Removes the arbitrary 10-file cap on journey entry photo uploads and 20-file
cap on gallery uploads. MulterErrors now return proper 4xx responses instead
of 500, and the client surfaces the server error message via toast rather than
silently trapping the user in the post editor overlay.

* fix(planner): remove correct assignment when place assigned to same day multiple times

When a place was assigned to the same day more than once, the "Remove from day"
button in PlaceInspector always deleted the first assignment (Array.find on
place.id) instead of the currently selected one. Now prefers selectedAssignmentId
when available.

Fixes #1005

* fix(map): enable 3D terrain for Mapbox outdoors style in trip planner

wantsTerrain() only matched satellite styles, so the outdoors-v12 style
was flat in the planner despite showing correct 3D terrain in the settings
preview. Added outdoors-v12 to the allowlist; marker drift is already
handled by syncMarkerAltitudes().

Fixes #1002

* fix(maps): send Referer header on Google API calls when APP_URL is set

Supports HTTP referrer restrictions on GCP API keys. Documents the
restriction types and photo troubleshooting steps in the wiki.
2026-05-16 00:53:02 +02:00
Julien G. e7211325df Add asset.download permission to Photo Providers 2026-05-15 23:16:34 +02:00
github-actions[bot] 7e49f3467c chore: bump version to 3.0.20 [skip ci] 2026-05-13 08:35:23 +00:00
jubnl 93b51a0bf5 fix(csp): allow unsafe-eval for HEIC image conversion 2026-05-13 10:34:57 +02:00
github-actions[bot] 5b710a429a chore: bump version to 3.0.19 [skip ci] 2026-05-13 08:13:30 +00:00
Julien G. da3cba2de3 v3.0.19 Bug Fixes (#992)
* fix(mcp): replace relative oauth constent redirect by absolute redirect derived from APP_URL (#987)

* feat(journey): convert HEIC/HEIF uploads to JPEG for cross-platform compatibility

HEIC is an Apple-only format not recognised as an image by many browsers
and platforms. heic-to (lazy-loaded) now converts HEIC/HEIF files to JPEG
before upload in both the gallery and entry editor photo pickers.
Embedded metadata (EXIF, GPS) may be lost during conversion — documented
in the Journey Journal wiki page.

* fix(journey): skip heic-to import for non-HEIC files to avoid test env failures

* fix(notifications): prevent double-escaping HTML in password reset emails

buildPasswordResetHtml passed a pre-built HTML block to buildEmailHtml,
which then escaped it again — rendering raw tags as plain text in the email.
2026-05-13 10:13:17 +02:00
github-actions[bot] 7f87dc1ce1 chore: bump version to 3.0.18 [skip ci] 2026-05-10 14:03:27 +00:00
Julien G. e7b419d397 security: login timing enumeration fix + dep CVE patches (v3.0.18) (#984)
* fix(security): equalise login response timing to prevent user enumeration (CWE-208)

Always run bcrypt.compareSync regardless of whether the email exists, using a
module-scope DUMMY_PASSWORD_HASH for unknown/OIDC-only accounts. Also wraps the
login handler in a 350ms minimum-latency pad (matching /forgot-password) as
defence-in-depth against CPU jitter and future code-path drift.

Fixes: CWE-203, CWE-208 — Observable Timing Discrepancy (CVSS 5.3 Medium)

* chore(deps): patch hono/picomatch/ip-address/brace-expansion CVEs, bump to node:24-alpine

Extends server/package.json overrides to pin hono >=4.12.16, picomatch >=4.0.4,
brace-expansion >=2.0.3, ip-address >=10.1.1. Adds matching overrides to client/.
Lockfiles regenerated to resolve: hono 4.12.18, ip-address 10.2.0, picomatch 4.0.4.

Also bumps base image node:22-alpine -> node:24-alpine (reduces base image CVEs)
and adds .github/workflows/security.yml to gate PRs on critical/high CVEs via
Docker Scout.

Addresses: CVE-2026-44456, CVE-2026-44455 (hono), CVE-2026-42338 (ip-address),
           CVE-2026-33671, CVE-2026-33672 (picomatch), CVE-2026-33750 (brace-expansion)

* chore: update emails in security.md

* ci(security): use docker/login-action for Scout auth instead of env vars

* chore: regenerate lock files

* chore: correct secret names

* chore: pr perms write

* fix(docker): remove package-lock.json from production image after npm ci

Docker Scout reads package-lock.json as an SBOM source and reports all
lockfile entries including devDependencies (e.g. picomatch via vitest/vite)
even when they are not physically installed. The lockfile has no runtime
purpose after npm ci completes, so delete it to ensure Scout only reports
packages actually present in node_modules.

* fix(docker): remove npm CLI from production image to eliminate bundled CVEs

picomatch@4.0.3, brace-expansion@5.0.4, and ip-address@10.1.0 were all
coming from /usr/local/lib/node_modules/npm — npm's own bundled packages
shipped with node:24-alpine. The production container only needs the node
binary to run the server; npm is unused at runtime.

Removing npm + npx after npm ci drops the package count from 500 to 365
and eliminates all npm-ecosystem CVEs (0H 0M remaining from npm packages).
Only busybox CVE-2025-60876 remains, which has no fix in Alpine 3.23.

* fix(deps): remove client overrides and brace-expansion server override; audit fix

brace-expansion ^2.0.3 in the client forced all installations to v2, breaking
minimatch in CI (test:coverage path via @vitest/coverage-v8 -> test-exclude)
which expects the named-export API of brace-expansion v5. The CVE it targeted
(>=4.0.0,<5.0.5) was only in npm's own bundled packages, already eliminated
by removing npm from the Docker image.

Also removes picomatch and ip-address client overrides for the same reason:
all three CVEs sourced from /usr/local/lib/node_modules/npm/, not app deps.
Drops brace-expansion from server overrides (server uses v2.1.0, outside the
affected range >=4.0.0).

* fix(#981): align public share itinerary order with daily planner (#985)

The public share page rendered daily items in a different order than the
authenticated planner because it used a simplified, divergent merge
algorithm. Five specific bugs:

1. shareService never loaded reservation_day_positions, so per-day
   transport positions were lost on the share page (fell back to
   day_plan_position ?? 999, pushing transports to the bottom).
2. Multi-day transports (overnight trains/flights) only appeared on their
   start day due to date-string filtering instead of day_id span logic.
3. Assignment-linked transports appeared twice (once as place, once as
   transport card) because the assignment_id exclusion was missing.
4. Time-based transport insertion was absent; missing positions used 999
   instead of a computed fractional position from the place timeline.
5. created_at tiebreaker was missing for assignments and notes with equal
   order_index/sort_order, making order non-deterministic on the share page.

Fix: extract the authoritative merge logic (parseTimeToMinutes,
getSpanPhase, getDisplayTimeForDay, getTransportForDay, getMergedItems)
from DayPlanSidebar into client/src/utils/dayMerge.ts and use it in both
the planner and SharedTripPage. Enrich the shareService payload with
day_positions from reservation_day_positions and add created_at tiebreakers
to the assignment and day_notes ORDER BY clauses.

* fix(#983): shift owner vacay entries when update_trip moves trip window

updateTrip() now calls shiftOwnerEntriesForTripWindow() which looks up
the owner's own vacay plan (not the active plan) and shifts all entries
in the old date window by the same offset as the trip start date.
2026-05-10 16:03:15 +02:00
github-actions[bot] de3152ee57 chore: bump version to 3.0.17 [skip ci] 2026-05-07 11:49:53 +00:00
Julien G. de6c0fb781 fix: prevent Invalid URL crash when APP_URL lacks a protocol (#972)
* fix: prevent Invalid URL crash when APP_URL lacks a protocol (issue #970)

- Add getMcpSafeUrl() to notifications.ts: wraps getAppUrl() and
  guarantees a result that satisfies the MCP SDK's checkIssuerUrl
  requirement (https:// or http://localhost). Non-HTTPS, non-localhost
  URLs fall back to http://localhost:{PORT} instead of propagating an
  "Issuer URL must be HTTPS" error.
- Switch app.ts, mcp/index.ts, mcp/oauthProvider.ts, and oauthService.ts
  to import getMcpSafeUrl instead of getAppUrl for all MCP resource URL
  construction, so a misconfigured APP_URL never crashes the metadata
  router initialisation.
- Restrict the SDK metadata router middleware to /.well-known/* paths
  only. Previously it was invoked on every request; in production the
  lazy getMetaRouter() init ran on GET / and threw "Invalid URL" when
  APP_URL had no scheme, returning 500 for every page load.
- Log a startup warning when APP_URL is set but not usable, and include
  the resolved App URL in the startup banner so operators can confirm
  the correct value at a glance.
- Update oauth.test.ts mock to target notifications.getMcpSafeUrl.

* fix: show getAppUrl in banner and add two separate APP_URL startup checks

- Banner now displays getAppUrl() (the resolved app URL) rather than
  getMcpSafeUrl() so operators see the actual configured value
- Two independent startup warnings after the banner when APP_URL is set:
  1. whether APP_URL is a valid URL (parseable by new URL())
  2. whether APP_URL is MCP-safe (https:// or http://localhost)
- Fix getMcpSafeUrl() fallback port to use Number(PORT) || 3001,
  consistent with how index.ts parses PORT

* fix: update oidc.ts to import getAppUrl from notifications
2026-05-07 13:49:39 +02:00
github-actions[bot] 9f1d05e886 chore: bump version to 3.0.16 [skip ci] 2026-05-06 19:38:58 +00:00
Julien G. 25f326a659 v3.0.16 — bug fixes (#964)
* fix(mcp): MCP RFC compliant for more strict clients

* fix(mcp): serve flat /.well-known/oauth-protected-resource for ChatGPT reconnect

Clients such as ChatGPT probe the flat well-known URL on every fresh discovery
cycle (i.e. after a full disconnect/reconnect where cached OAuth state is cleared).
The SDK's mcpAuthMetadataRouter only serves the path-based form
/.well-known/oauth-protected-resource/mcp, so the flat probe returned 404.

Without the resource metadata, ChatGPT fell back to the issuer URL as the
resource parameter (https://…/ instead of https://…/mcp). The authorize handler
then rejected it with invalid_target and redirected back to ChatGPT's callback
with an error — showing the user the TREK home page instead of the consent form.

Add an explicit GET handler for the flat URL that returns the same protected
resource metadata, so the resource URI is discovered correctly on the first probe.

* fix(mcp): fix OAuth popup blank page — SW denylist and COOP header

Service worker was intercepting /oauth/authorize navigate requests
(not in denylist), serving index.html, and React Router's catch-all
redirected to / instead of the SDK authorize handler.

Helmet's default COOP: same-origin isolated the /oauth/consent popup
from its cross-origin opener, making window.opener null and breaking
the popup-based OAuth completion signal for ChatGPT and similar clients.

* fix(ntfy): encode non-Latin-1 header values with RFC 2047 to prevent ByteString crash

Todo/trip names containing chars like → or € (and non-Latin-1 locale templates
for Czech, Chinese, Russian, etc.) caused the Fetch API to throw when setting
the ntfy Title header. Apply RFC 2047 base64 encoded-word encoding for any
header value containing chars above U+00FF; ntfy decodes this automatically.

* docs(mcp): document Cloudflare bot detection blocking ChatGPT MCP requests

Add Cloudflare WAF note to MCP-Setup and a full troubleshooting entry covering
root cause (IP reputation + UA heuristics), free-plan limitation (disable Bot
Fight Mode entirely, with explicit warning), and paid-plan WAF skip rule with
the full expression syntax and path table for all MCP/OAuth/.well-known routes.

* fix(pwa): detect upstream proxy auth challenges and recover gracefully

Behind Cloudflare Zero Trust or Pangolin, cross-origin auth redirects on
/api/* calls surface as CORS errors (error.response === undefined) that
the existing 401 interceptor never catches, leaving the PWA stuck with
network-error toasts instead of re-authenticating.

New connectivity module probes /api/health every 30s using fetch with
cache:no-store and inspects Content-Type to reliably detect whether the
server is reachable vs intercepted by an upstream proxy.

axios interceptor changes:
- On !error.response + navigator.onLine: run probeNow(); if the health
  probe also fails (proxy is intercepting all requests), trigger a guarded
  window.location.reload() so the edge proxy can intercept the top-level
  navigation and run its auth flow (covers CF Access and Pangolin 302 mode)
- On error.response status 401 with text/html body: same reload path,
  covering Pangolin header-auth extended compatibility mode which returns
  401+HTML instead of a 302 redirect. TREK own 401s are always JSON so
  there is no collision with the existing AUTH_REQUIRED branch.
- sessionStorage flag prevents reload loops; cleared on any successful
  response so the guard resets after re-auth.

/api/health excluded from SW NetworkFirst cache (vite.config.js regex)
and Cache-Control: no-store added server-side so probes always hit the
network and cannot be served stale from the 24h api-data cache.

LoginPage caches last-known appConfig in localStorage so the SSO button
renders in OIDC+UN/PW dual mode even when the config fetch is intercepted
by the proxy. Auto-redirect to IdP skipped when config comes from cache
to avoid redirect loops while the proxy is challenging.

Fixes discussion #836.

* fix(files): add bottom-nav padding to files tab wrapper on mobile

* fix(budget): expose toolbar on mobile so users can add budget categories

* fix(pwa): unregister SW before proxy-reauth reload so Pangolin can challenge

WorkBox's NavigationRoute served the cached SPA shell on window.location.reload(),
meaning Pangolin/CF Access never saw the navigation and the app was left stuck
showing stale offline data. Unregistering the SW first lets the navigation reach
the network so the upstream proxy can run its auth flow.

Also rebuilds server/public with corrected sw.js (health excluded from
NetworkFirst, /oauth/ and /.well-known/ added to NavigationRoute denylist).

* chore: remove committed build artifacts from server/public

Dockerfile and Proxmox community script both rebuild client/dist and copy
it into server/public at build time — committed artifacts were never used.
Replace with .gitkeep and add server/public/* to .gitignore.

* chore: add build-from-sources script
2026-05-06 21:38:40 +02:00
jubnl 418f3e0bb2 docs: add Portainer install guide and tag strategy to wiki
- Add wiki/Install-Portainer.md with stack setup, image tag strategy, update instructions, named volumes, and 7 annotated screenshots
- Add tag strategy sections (latest / major / pinned) to Install-Docker.md, Install-Docker-Compose.md, and Updating.md
- Add named volumes examples with Docker Compose volumes reference link to Install-Docker.md, Install-Docker-Compose.md, and Install-Portainer.md
- Add Portainer update section with screenshots to Updating.md
- Add Install-Portainer entry to _Sidebar.md
2026-05-06 16:54:05 +02:00
github-actions[bot] 640e5616e9 chore: bump version to 3.0.15 [skip ci] 2026-05-04 12:22:15 +00:00
Julien G. 22f3bf4bfc fix: add APP_VERSION fallback and HOST bind address env var (#952 #953) (#955)
* fix: add APP_VERSION fallback and HOST bind env var (#952 #953)

- Read package.json version when APP_VERSION env var is absent so the
  startup banner shows the correct version for source/Proxmox installs
- Add HOST env var to control the HTTP bind address; only applied when
  set so Docker deployments are unaffected (bind-all-interfaces default)
- Parse PORT as Number() so malformed values like '10.0.0.72:3001' fall
  back to 3001 instead of silently misbehaving
- Document HOST in .env.example, Environment-Variables wiki, and
  Install-Proxmox wiki with explicit warnings against using it in Docker

* fix: correct package.json path in APP_VERSION fallback

index.ts sits at server/src/ — one level up reaches server/package.json,
not two (../../ overshot to the repo root where no package.json exists).
2026-05-04 14:21:55 +02:00
Tranko 256f38d8fa docs: add budget documentation GIFs (create, add expense, final settlement) (#948) 2026-05-03 18:56:56 +02:00
jubnl 9592cc663f docs: document wiki-only PR exemption from branch enforcement 2026-05-03 18:48:39 +02:00
jubnl dba4b28380 ci: exempt wiki-only PRs from branch target enforcement 2026-05-03 18:43:21 +02:00
github-actions[bot] 51b5bd6966 chore: bump version to 3.0.14 [skip ci] 2026-05-03 15:40:00 +00:00
Julien G. 6072b969d6 Bug fixes - May 2nd 2026 (#941)
* fix: collab chat input hidden by mobile bottom nav bar

Closes #939

* chore: prepare database for nest + typeorm

* fix(ssrf): relax internal network resolution (#947)

* docs(ssrf): update Internal-Network-Access wiki to reflect relaxed guard

Loopback, link-local, and .local/.internal hostnames are now all
overridable with ALLOW_INTERNAL_NETWORK=true (commit 9a08368). Merge
the two-tier "always blocked / conditionally blocked" structure into a
single table, add a warning about cloud metadata exposure.

* fix(ssrf): let .local/.internal hostnames pass to IP-level checks

The pre-DNS hostname block was redundant: any .local/.internal host
that resolves to a private IP is already gated by isPrivateNetwork +
ALLOW_INTERNAL_NETWORK, and any that resolves to loopback/link-local
is caught by isAlwaysBlocked unconditionally.

Dropping the hostname pre-check means Docker/LAN deployments can reach
services on .local hostnames (e.g. immich.local) with
ALLOW_INTERNAL_NETWORK=true, while loopback and link-local IPs
(including 169.254.169.254) remain hard-blocked with no override.

Reverts the isAlwaysBlocked guard loosening from 9a08368.

* fix(auth): trim username and email on all write paths

Self-registration stored values verbatim, so trailing whitespace could
produce rows that lookup code (which trims input) silently misses.
Trim username and email before validation and INSERT in registerUser,
adminService.updateUser, and oidcService.findOrCreateUser. updateSettings
and adminService.createUser already trimmed correctly.

Adds a one-shot backfill migration (trimUserWhitespace) that trims
existing dirty rows; collisions are resolved by appending __migrated_<id>
to the value with a loud console.warn so operators can review affected
accounts.

18 new tests covering registration trim, duplicate detection, admin
update trim, trip-member lookup regression, and all migration branches.

* feat(notices): add v3014-whitespace-collision admin notice

Adds a dismissible banner for admins on v3.0.14+ that fires only when
the whitespace-trimming migration detected a username/email collision
(stored in app_settings as whitespace_migration_collision=true).

Notice conditions: existingUserBeforeVersion(3.0.14) + role=admin +
custom predicate reading the app_settings flag. Predicate registered in
registry.ts; migration step writes the flag when hadCollision=true.

All 15 translation files updated with title/body keys.
7 integration tests added (SN-COLLISION-1 through -7) covering all
condition branches: shown when all conditions met, hidden when flag
absent/false, hidden for non-admin, hidden for new user, hidden below
min app version, hidden after dismissal.
2026-05-03 17:39:45 +02:00
github-actions[bot] 4ae4e0c676 chore: bump version to 3.0.13 [skip ci] 2026-04-30 23:43:49 +00:00
Julien G. 51ab30f436 Bug fixes - April 30th 2026 (#936)
* fix: hotel day-range clamping in ReservationModal + stale assignment_id on accommodation clear (issues #929, #934)

* ReservationModal hotel start/end pickers now use findIndex-based
  positional clamping instead of raw ID arithmetic, matching the fix
  applied to DayDetailPanel in 8e05ba7. Prevents inverted
  start_day_id/end_day_id on trips with non-monotonic day IDs.

* Clearing accommodation_id on a hotel reservation now forces
  assignment_id to null in the save payload, removing the stale
  day-assignment link that had no UI path to clear.

* Migration: swaps inverted start_day_id/end_day_id pairs in
  day_accommodations where start.day_number > end.day_number,
  recovering existing corrupt rows from the pre-fix picker bug.

* Tests FE-PLANNER-RESMODAL-050/051/052 cover both fixes.

* fix: preserve line breaks and wrap long URLs in notes fields (#930)

Add remark-breaks to all reservation/place notes markdown renderers so
single newlines render as <br>, and add wordBreak/overflowWrap styles
so long unbroken URLs (e.g. booking.com tracking links) wrap correctly.

* fix: delete linked budget item when accommodation or reservation is deleted (#933)

Deleting an accommodation or reservation now removes any budget item
linked via reservation_id, preventing orphan entries in the Budget page.
Also fixes a pre-existing payload-shape bug where budget:deleted was
broadcast with {id} instead of {itemId}, breaking live updates for
collaborators when a reservation price was cleared.

Tests added: ACCOM-006, RESV-009b, BUDGET-004b.

* fix: restore scroll position in mobile Plan and Places sidebars on reopen (issue #932)

Both DayPlanSidebar and PlacesSidebar have their own internal scroll
containers (overflowY: auto). Scroll events don't bubble, so previous
attempts that tracked scrollTop on the outer portal div never fired.

Each sidebar now accepts initialScrollTop and onScrollTopChange props.
The internal scroll container saves its scrollTop via onScrollTopChange
on every scroll event, and restores it via useLayoutEffect on mount
(before the browser paints, so no visible flash).

TripPlannerPage holds the saved values in refs (mobilePlanScrollTopRef,
mobilePlacesScrollTopRef) and passes them through on each portal mount.

* fix(map): prevent auto zoom-out when opening/closing place inspector (issue #921)

Both Leaflet and Mapbox GL renderers now gate fitBounds strictly on fitKey
increments from the parent. Selecting or dismissing a place inspector changes
paddingOpts (via hasInspector) but no longer triggers a re-fit that zoomed
the map out to the full trip extent when no day was selected.

Also removes the zoom-12 visibility gate on Leaflet route info pills so they
render at all zoom levels when a route is active.

* fix: translate mobile bottom-nav tab labels (issue #931)

Replaced hardcoded English labels in BottomNav with t() lookups using the same translation keys as the desktop navbar (nav.myTrips, admin.addons.catalog.*.name).
2026-05-01 01:43:19 +02:00
github-actions[bot] 8b53948231 chore: bump version to 3.0.12 [skip ci] 2026-04-28 22:17:13 +00:00
Julien G. 78d6f2ba77 Bug fixes - April 28th 2026 (#915)
* fix: replace raw day-ID range checks with position-based helper (issue #889 follow-up)

Commit 8e05ba7 fixed the accommodation date-range pickers, but the
post-save state filters in DayDetailPanel and several other consumers
still compared `day.id >= start_day_id && day.id <= end_day_id`. With
non-monotonic ID layouts (day_number 1-9 → IDs 17-25, day_number 10-16
→ IDs 1-7) this made the just-saved accommodation immediately invisible
— matching the regression reported in the last comment of #889.

Introduces `isDayInAccommodationRange` in `client/src/utils/dayOrder.ts`
which compares positional order (`day_number` with `indexOf` fallback)
rather than raw IDs. Falls back to the old numeric comparison when
endpoint days are absent from the loaded array (sparse test data or
partial loads) so existing tests are unaffected.

Fixed call sites:
- DayDetailPanel.tsx (initial load, post-create, post-delete, post-edit-save)
- DayPlanSidebar.tsx (daily badge renderer)
- SharedTripPage.tsx (public share view)
- TripPDF.tsx (PDF export filter + sort)

Also declares `day_number?: number` on the client `Day` type (already
returned by the server but previously untyped).

Adds regression tests FE-PLANNER-DAYDETAIL-060/061/062 covering the
edit-save, create-save, and initial-load paths with the reporter's exact
non-monotonic ID layout.

* fix: non-transport reservations no longer appear as transports in day planner (issue #914)

getTransportForDay now uses TRANSPORT_TYPES allowlist instead of only excluding hotels,
and the click handler dispatches to onEditReservation for non-transport types instead of
always opening TransportModal, preventing silent type coercion to 'flight'.

* feat: add file attachment support to TransportModal (issue #918)

Transports (flight/train/car/cruise) now support file attachments identical to the reservation modal — upload on create/edit, link existing files, and unlink. The Files tab and Assign File modal now differentiate between bookings and transports with separate sections and type-specific icons. Translations added for all 15 locales.
2026-04-29 00:16:56 +02:00
jubnl bb89d70a94 docs: document required permissions for Immich and Synology photo providers
Co-authored-by: Ben Haas <ben@benhaas.io>
2026-04-28 05:32:39 +02:00
jubnl ad9f3887d8 docs: add wiki guide for adding places to day itinerary with GIFs
Co-authored-by: Tranko <tranko@gmail.com>
2026-04-28 05:32:35 +02:00
github-actions[bot] 7f1fb508db chore: bump version to 3.0.11 [skip ci] 2026-04-28 03:17:32 +00:00
Julien G. 1f5deeba6c Bug fixes - April 27th 2026 (#907)
* fix: clean up dangling FK references before deleting a user

Resolves FOREIGN KEY constraint failed (500) on DELETE /api/admin/users/:id
and DELETE /api/auth/me when the target user had rows in trip_members.invited_by,
share_tokens.created_by, budget_items.paid_by_user_id, journeys.user_id,
journey_entries.author_id, journey_contributors.user_id, or
journey_share_tokens.created_by — none of which had ON DELETE clauses.

Introduces deleteUserCompletely() in userCleanupService.ts which wraps all
cleanup and the final DELETE FROM users in a single transaction. Both
adminService.deleteUser and authService.deleteAccount now call it instead of
the bare DELETE. Tests ADMIN-005b and AUTH-040 cover all reference types
including notification sender/recipient and notice dismissals.

* test: extend FK deletion tests to cover journeys, files, and photos

ADMIN-005b and AUTH-040 now also seed and assert:
- owned journey with entries (cascade-deleted via journeys.user_id cleanup)
- trip_files.uploaded_by (SET NULL — file survives, attribution cleared)
- trek_photos.owner_id (SET NULL — photo record survives, owner cleared)
- trip_photos.user_id (CASCADE — photo association removed)

* test: extend user deletion tests to cover all FK relationships

ADMIN-005b and AUTH-040 now seed and assert every user FK relationship:

CASCADE (row deleted): trips, trip_members, tags, mcp_tokens, oauth_tokens,
oauth_consents, vacay_plans, vacay_plan_members, bucket_list,
visited_countries, visited_regions, packing_templates, invite_tokens,
collab_notes, settings, password_reset_tokens, notification_channel_preferences

SET NULL (row survives, column nulled): categories, todo_items.assigned_user_id,
packing_bags, audit_log

Caught and fixed: notification_preferences was dropped in migration 72;
correct table is notification_channel_preferences.

* fix: preserve URL hash and OIDC redirect target through login flow

- Include location.hash in redirect param at all three producer sites
  (ProtectedRoute, axios 401 interceptor, OAuthAuthorizePage) so
  hash fragments survive the login bounce
- Stash redirectTarget in sessionStorage before any OIDC provider
  redirect and restore it after the code exchange, since the IdP
  strips the original ?redirect= param during the roundtrip
- Clear sessionStorage on OIDC error to avoid stale state
- Add tests covering sessionStorage stash on mount, navigate to saved
  redirect after OIDC exchange, fallback to /dashboard, and cleanup
  on error

* fix: use day position instead of ID for accommodation date range clamping

Math.min/Math.max over raw day IDs breaks the start/end picker when a
trip's day IDs are non-monotonic relative to day_number (normal after
repeated generateDays extend/shrink cycles). Replaced with findIndex
lookups so clamping is always based on positional order.

Closes #889

* fix: normalize env var comparisons to be case-insensitive

All NODE_ENV, DEMO_MODE, OIDC_ONLY, FORCE_HTTPS, COOKIE_SECURE, and
ALLOW_INTERNAL_NETWORK checks now use .toLowerCase() so values like
'Production' or 'True' behave identically to their lowercase forms.
Also adds APP_VERSION to the startup banner.

* fix: delete surplus days when shortening a trip

When shrinking a trip's date range, surplus days are now deleted along
with their assignments, notes, and accommodations (cascade). Places
remain in the trip pool; reservations keep their day reference nulled
by the existing ON DELETE SET NULL constraint (issue #909).

Updates TRIP-SVC-011 to reflect the new behaviour; adds TRIP-SVC-016
as a regression test for the empty-day case.

* fix: auto-backup retention deletes itself and manual backups on Docker

Two bugs in cleanupOldBackups:
1. Filter was .endsWith('.zip') — swept manual backup-*.zip files too.
   Now restricted to auto-backup-* prefix.
2. Age was derived from stat.birthtimeMs, which is 0 on overlayfs
   (Docker default), making every backup appear epoch-old and get
   deleted immediately. Age is now parsed from the filename timestamp
   and falls back to mtimeMs (reliable on overlayfs).

Also converts inline require('./services/auditLog') calls to a static
import throughout scheduler.ts, and adds 8 unit tests covering the
fixed retention logic including the overlayfs regression case.

* test: update TRIP-024 to match delete behavior on trip shrink

* feat: add bypass-branch-check label to skip branch enforcement
2026-04-28 05:17:20 +02:00
jubnl ca832e8d88 chore: prevent new build on workflow change 2026-04-27 00:31:22 +02:00
jubnl 12fc7f7b68 docs: fix Proxmox update section to run inside LXC and add command 2026-04-27 00:28:48 +02:00
github-actions[bot] 2770a189df chore: bump version to 3.0.10 [skip ci] 2026-04-26 22:22:31 +00:00
jubnl 2b162a8cc7 chore: reset to 3.0.9 2026-04-27 00:22:09 +02:00
github-actions[bot] 009d89fecf chore: bump version to 3.0.10 [skip ci] 2026-04-26 22:15:15 +00:00
jubnl 5c3b89578d docs: add Proxmox VE LXC install guide and update CI ignore paths
- Add wiki/Install-Proxmox.md with full install/update/log instructions
- Add Proxmox VE section to wiki/Updating.md
- Add Install: Proxmox VE (LXC) to wiki/_Sidebar.md
- Add "Proxmox Community Script" option to bug report install dropdown
- Exclude GitHub meta files from triggering Docker CI workflow
2026-04-27 00:14:50 +02:00
1835 changed files with 140597 additions and 80211 deletions
+1
View File
@@ -2,6 +2,7 @@ node_modules
client/node_modules
server/node_modules
client/dist
shared/dist
data
uploads
.git
+1
View File
@@ -62,6 +62,7 @@ body:
- Docker (standalone)
- Kubernetes / Helm
- Unraid template
- Proxmox Community Script
- Sources
- Other
validations:
+1 -1
View File
@@ -15,7 +15,7 @@
## Checklist
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
- [ ] This PR targets the `dev` branch, not `main`
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
- [ ] I have tested my changes locally
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
- [ ] I have updated documentation if needed
@@ -26,9 +26,36 @@ jobs:
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const pull of pulls) {
const hasBypass = pull.labels.some(l => l.name === 'bypass-branch-check');
if (hasBypass) continue;
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
if (!hasLabel) continue;
// Wiki-only PRs are exempt — clear label and skip
const files = [];
for (let page = 1; ; page++) {
const { data } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pull.number,
per_page: 100,
page,
});
files.push(...data);
if (data.length < 100) break;
}
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
if (allWiki) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pull.number,
name: 'wrong-base-branch',
});
continue;
}
const createdAt = new Date(pull.created_at);
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
+7 -5
View File
@@ -7,7 +7,10 @@ on:
- 'docs/**'
- '**/*.md'
- 'wiki/**'
- '.github/workflows/wiki.yml'
- '.github/workflows/**'
- '.github/ISSUE_TEMPLATE/**'
- '.github/FUNDING.yml'
- '.github/PULL_REQUEST_TEMPLATE.md'
workflow_dispatch:
inputs:
bump:
@@ -99,16 +102,15 @@ jobs:
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$STABLE → $NEW_VERSION ($BUMP)"
# Update package.json files and Helm chart
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
# Update all workspace + root package.json files and the root lockfile in one shot
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
# Commit and tag
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add server/package.json server/package-lock.json client/package.json client/package-lock.json charts/trek/Chart.yaml
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag "v$NEW_VERSION"
git push origin main --follow-tags
@@ -21,6 +21,39 @@ jobs:
const labels = context.payload.pull_request.labels.map(l => l.name);
const prNumber = context.payload.pull_request.number;
// bypass-branch-check label skips all enforcement
if (labels.includes('bypass-branch-check')) {
console.log('bypass-branch-check label present, skipping enforcement.');
return;
}
// Wiki-only PRs are exempt from branch enforcement
const files = [];
for (let page = 1; ; page++) {
const { data } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
per_page: 100,
page,
});
files.push(...data);
if (data.length < 100) break;
}
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
if (allWiki) {
console.log('All changed files are under wiki/ — skipping enforcement.');
if (labels.includes('wrong-base-branch')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: 'wrong-base-branch',
});
}
return;
}
// If the base was fixed, remove the label and let it through
if (base !== 'main') {
if (labels.includes('wrong-base-branch')) {
+53
View File
@@ -0,0 +1,53 @@
name: Lint & Prettier
on:
pull_request:
branches: [main, dev]
jobs:
lint:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
- name: Install dependencies
run: npm install
- name: Run lint & format check
id: checks
continue-on-error: true
run: |
cd shared
npm run lint
npm run format:check
- name: Comment on PR if checks failed
if: steps.checks.outcome == 'failure'
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: [
'## ❌ Lint & Prettier check failed',
'',
'Please fix the issues locally by running the following commands inside the `shared` package:',
'',
'```bash',
'cd shared',
'npm run lint',
'npm run format',
'```',
'',
'Then commit and push the changes.',
].join('\n'),
});
- name: Fail the job if checks failed
if: steps.checks.outcome == 'failure'
run: exit 1
+38
View File
@@ -0,0 +1,38 @@
name: Security Scan
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
pull-requests: write
jobs:
scout:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
push: false
load: true
tags: trek:scan
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/scout-action@v1
with:
command: cves
image: trek:scan
only-severities: critical,high
only-fixed: true
exit-code: true
+77 -7
View File
@@ -8,10 +8,47 @@ on:
branches: [main, dev]
paths:
- 'server/**'
- '.github/workflows/test.yml'
- 'client/**'
- 'shared/**'
- '.github/workflows/test.yml'
jobs:
i18n-parity:
name: i18n Key Parity
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
- name: Check i18n key parity
run: node shared/scripts/i18n-parity.mjs --strict
shared-contracts:
name: Shared Contracts (Zod)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared
- name: Typecheck
run: cd shared && npm run typecheck
- name: Run tests
run: cd shared && npm test
server-tests:
name: Server Tests
runs-on: ubuntu-latest
@@ -21,12 +58,33 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: npm
cache-dependency-path: server/package-lock.json
cache-dependency-path: package-lock.json
- name: Install dependencies
run: cd server && npm ci
run: npm ci
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build shared
run: npm run build --workspace=shared
- name: Build server (tsc -> dist)
run: cd server && npm run build
- name: Typecheck
run: cd server && npm run typecheck
- name: Lint
run: cd server && npm run lint:check
- name: Run tests
run: cd server && npm run test:coverage
@@ -48,12 +106,24 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: npm
cache-dependency-path: client/package-lock.json
cache-dependency-path: package-lock.json
- name: Install dependencies
run: cd client && npm ci
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
- name: Typecheck
run: cd client && npm run typecheck
- name: Lint
run: cd client && npm run lint:check
- name: Page pattern check
run: cd client && npm run lint:pages
- name: Run tests
run: cd client && npm run test:coverage
+4
View File
@@ -3,6 +3,10 @@ node_modules/
# Build output
client/dist/
server/dist/
shared/dist/
server/public/*
!server/public/.gitkeep
# Generated PWA icons (built from SVG via prebuild)
client/public/icons/*.png
+1 -1
View File
@@ -7,7 +7,7 @@ Thanks for your interest in contributing! Please read these guidelines before op
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
3. **No breaking changes** — Backwards compatibility is non-negotiable
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
+89 -19
View File
@@ -1,28 +1,97 @@
# Stage 1: Build React client
FROM node:22-alpine AS client-builder
WORKDIR /app/client
COPY client/package*.json ./
RUN npm ci
COPY client/ ./
RUN npm run build
# ── Stage 0: gosu ────────────────────────────────────────────────────────────
# Rebuild gosu with a current Go toolchain so the runtime image ships no stale
# Go stdlib (Debian's apt gosu is built with an old Go that trips CVE scanners).
# The binary and its runtime behaviour are identical to the apt package.
FROM golang:1.25-alpine AS gosu-build
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@latest
# Stage 2: Production server
FROM node:22-alpine
# ── Stage 1: shared ──────────────────────────────────────────────────────────
FROM node:24-alpine AS shared-builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
RUN npm ci --workspace=shared
COPY shared/ ./shared/
RUN npm run build --workspace=shared
# ── Stage 2: client ──────────────────────────────────────────────────────────
FROM node:24-alpine AS client-builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY client/package.json ./client/
RUN npm ci --workspace=client
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY client/ ./client/
RUN npm run build --workspace=client
# ── Stage 3: server ──────────────────────────────────────────────────────────
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
FROM node:24-alpine AS server-builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
RUN npm ci --workspace=server --ignore-scripts
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY server/ ./server/
RUN npm run build --workspace=server
# ── Stage 4: production runtime ──────────────────────────────────────────────
FROM node:24-trixie-slim
WORKDIR /app
# Timezone support + native deps (better-sqlite3 needs build tools)
COPY server/package*.json ./
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
npm ci --production && \
apk del python3 make g++
# Workspace manifests only — source never enters this stage.
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
COPY server/ ./
COPY --from=client-builder /app/client/dist ./public
COPY --from=client-builder /app/client/public/fonts ./public/fonts
# 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 && \
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 && \
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
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
ENV XDG_CACHE_HOME=/tmp/kf6-cache
# Prevent Qt from probing for a display in headless containers.
ENV QT_QPA_PLATFORM=offscreen
# Fixed path for both amd64 (static binary) and arm64 (symlink to apt binary).
# Override with KITINERARY_EXTRACTOR_PATH if you install it elsewhere.
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
COPY --from=server-builder /app/server/dist ./server/dist
# Runtime data assets read from server/assets at runtime: airports.json (flight
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
# only emits dist, so these must be copied explicitly or the features silently
# degrade to empty in the image.
COPY --from=server-builder /app/server/assets ./server/assets
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
COPY server/tsconfig.json ./server/
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
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
ln -s /app/uploads /app/server/uploads && \
ln -s /app/data /app/server/data && \
chown -R node:node /app
ENV NODE_ENV=production
@@ -36,4 +105,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
ENTRYPOINT ["dumb-init", "--"]
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"]
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
+33
View File
@@ -0,0 +1,33 @@
# Third-party data & attributions
TREK bundles and uses third-party data that requires attribution.
## geoBoundaries — country & sub-national boundaries
The Atlas map's administrative boundaries (admin-0 countries and admin-1
provinces/counties), shipped at `server/assets/atlas/admin0.geojson.gz` and
`server/assets/atlas/admin1.geojson.gz` and generated by
`server/scripts/build-atlas-geo.mjs`, are derived from **geoBoundaries**.
> Runfola, D. et al. (2020) geoBoundaries: A global database of political
> administrative boundaries. PLoS ONE 15(4): e0231866.
> https://doi.org/10.1371/journal.pone.0231866
geoBoundaries is licensed under **CC BY 4.0**
(https://creativecommons.org/licenses/by/4.0/). Source: https://www.geoboundaries.org/
The bundled files are simplified (coordinate-quantized) and re-tagged with the
property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
(gbOpen) release.
## OpenStreetMap — geocoding
Atlas reverse-geocodes places via the **Nominatim** service. Geocoding data is
© OpenStreetMap contributors, licensed under the Open Database License (ODbL).
https://www.openstreetmap.org/copyright
## OurAirports — airport reference data
`server/assets/airports.json` is built from **OurAirports**
(https://ourairports.com/data/), released into the public domain.
+9 -2
View File
@@ -18,7 +18,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<br />
<a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
<a href="https://demo.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
&nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
&nbsp;
@@ -89,7 +89,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### 🧳 Travel management
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
- **Packing lists** — categories, templates, user assignment, progress tracking
- **Bag tracking** — optional weight tracking with iOS-style distribution
@@ -437,6 +437,13 @@ Caddy handles TLS and WebSockets automatically.
<br />
## Data sources
The Atlas map's country and sub-national (province/county) boundaries come from
[**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
for full third-party attributions.
## License
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
+1 -1
View File
@@ -14,7 +14,7 @@ Only the latest version receives security updates. Please update to the latest r
If you discover a security vulnerability, please report it responsibly:
1. **Do not** open a public issue
2. Email: **mauriceboe@icloud.com**
2. Email: **report@liketrek.com**
3. Include a description of the vulnerability and steps to reproduce
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
CLIENT_DIR="$REPO_ROOT/client"
SERVER_DIR="$REPO_ROOT/server"
PUBLIC_DIR="$REPO_ROOT/server/public"
echo "==> Installing client dependencies"
cd "$CLIENT_DIR"
npm ci
echo "==> Building client"
npm run build
echo "==> Installing server dependencies"
cd "$SERVER_DIR"
npm ci
echo "==> Populating server/public"
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
echo "==> Done — server/public is ready"
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.0.9
version: 3.0.22
description: Minimal Helm chart for TREK app
appVersion: "3.0.9"
appVersion: "3.0.22"
+5
View File
@@ -0,0 +1,5 @@
# Playwright E2E (FE7)
e2e/.tmp/
test-results/
playwright-report/
playwright/.cache/
+27
View File
@@ -0,0 +1,27 @@
{
"printWidth": 120,
"useTabs": false,
"tabWidth": 2,
"trailingComma": "es5",
"semi": true,
"singleQuote": true,
"bracketSpacing": true,
"arrowParens": "always",
"jsxSingleQuote": false,
"bracketSameLine": false,
"endOfLine": "lf",
"plugins": [
"prettier-plugin-organize-imports",
"@trivago/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss"
],
"importOrder": [
"^[a-zA-Z]",
"^@/.*"
],
"importOrderSeparation": true,
"importOrderParserPlugins": [
"typescript",
"decorators-legacy"
]
}
+42
View File
@@ -0,0 +1,42 @@
import { test as setup, expect } from '@playwright/test'
// Relative to the config dir (client/), matching `storageState` in
// playwright.config.ts. Playwright runs from the client workspace root.
const stateFile = 'e2e/.tmp/state.json'
// Credentials match e2e/server-launch.mjs (ADMIN_EMAIL/ADMIN_PASSWORD). The
// seeded admin is created with must_change_password=1, so the first login goes
// through the forced change-password step before reaching the dashboard.
const EMAIL = 'e2e@trek.local'
const SEED_PW = 'E2eTest12345!'
const NEW_PW = 'E2eChanged12345!'
setup('authenticate the seeded admin (incl. forced password change)', async ({ page }) => {
await page.goto('/login')
await page.locator('input[type="email"]').fill(EMAIL)
await page.locator('input[type="password"]').fill(SEED_PW)
await page.locator('button[type="submit"]').click()
// must_change_password=1 → the change-password step renders two password
// fields (new + confirm). Selector-agnostic of the UI language.
const pw = page.locator('input[type="password"]')
await expect(pw).toHaveCount(2)
await pw.nth(0).fill(NEW_PW)
await pw.nth(1).fill(NEW_PW)
await page.locator('button[type="submit"]').click()
await page.waitForURL('**/dashboard', { timeout: 30_000 })
// Dismiss the first-run "Welcome to TREK" system-notice modal(s). It renders
// asynchronously (after the notices fetch), so wait for it before clicking.
// Dismissal is recorded server-side against this user, so clearing it here
// keeps it cleared for every authenticated flow in the run (shared test DB).
const ok = page.getByRole('button', { name: 'OK', exact: true })
await ok.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {})
for (let i = 0; i < 8 && (await ok.isVisible().catch(() => false)); i++) {
await ok.click()
await page.waitForTimeout(400)
}
await page.context().storageState({ path: stateFile })
})
+25
View File
@@ -0,0 +1,25 @@
import { test, expect } from '@playwright/test'
// Trip lifecycle (core): from the dashboard, open the new-trip modal, name the
// trip, submit, and confirm it shows up on the dashboard. Exercises the whole
// authenticated stack — dashboard → TripFormModal → POST /api/trips → store →
// re-render — against the real backend + isolated test DB.
test('create a trip and see it on the dashboard', async ({ page }) => {
await page.goto('/dashboard')
// The "+ New Trip" card is always rendered in the default (planned) filter.
await page.locator('.add-trip-card').click()
// Scope to the shared Modal (.modal-backdrop). Its form has no in-form submit
// button (the primary action lives in the footer), so click it explicitly
// rather than pressing Enter. The Create button is the slate primary button;
// Cancel is the bordered one.
const modal = page.locator('.modal-backdrop')
await expect(modal).toBeVisible()
const title = `E2E Trip ${Date.now()}`
await modal.locator('input[type="text"]').first().fill(title)
await modal.getByRole('button', { name: 'Create New Trip' }).click()
await expect(page.getByText(title).first()).toBeVisible({ timeout: 15_000 })
})
+10
View File
@@ -0,0 +1,10 @@
import { test, expect } from '@playwright/test'
// Authenticated smoke: the stored session lands on the dashboard and the
// app chrome (navbar) renders instead of bouncing back to /login.
test('authenticated session reaches the dashboard', async ({ page }) => {
await page.goto('/dashboard')
await expect(page).toHaveURL(/\/dashboard/)
// The shared Navbar shows the TREK brand once authenticated.
await expect(page.getByRole('img', { name: 'TREK' }).first()).toBeVisible()
})
+8
View File
@@ -0,0 +1,8 @@
import { test, expect } from '@playwright/test'
// Infra smoke + first unauthenticated flow: the app boots, the backend is
// reachable through the Vite proxy, and the login screen renders its form.
test('login screen renders with a password field', async ({ page }) => {
await page.goto('/login')
await expect(page.locator('input[type="password"]')).toBeVisible()
})
+43
View File
@@ -0,0 +1,43 @@
// Boots the TREK backend for the Playwright E2E run against a fresh, isolated
// SQLite database. The DB file is deleted first so every run starts clean, then
// the server's own startup seeds a known admin from ADMIN_EMAIL/ADMIN_PASSWORD.
//
// The server is built once and launched as a SINGLE node process (not the
// watch-mode `npm run dev`, which spawns tsc -w + node --watch grandchildren
// that survive Playwright's teardown and then linger on :3001 with stale DB
// state). A single child is killed cleanly when Playwright tears the run down.
import { rmSync } from 'node:fs'
import { spawn, execSync } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const dbFile = path.join(here, '.tmp', 'e2e.db')
const serverDir = path.join(here, '..', '..', 'server')
for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) {
try { rmSync(f, { force: true }) } catch {}
}
// Build once (no watcher) — the resulting process is a single killable node.
execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' })
const env = {
...process.env,
TREK_DB_FILE: dbFile,
ADMIN_EMAIL: 'e2e@trek.local',
ADMIN_PASSWORD: 'E2eTest12345!',
PORT: '3001',
NODE_ENV: 'development',
}
const child = spawn(process.execPath, ['--require', 'tsconfig-paths/register', 'dist/index.js'], {
cwd: serverDir,
env,
stdio: 'inherit',
})
const stop = () => { try { child.kill() } catch {} }
process.on('SIGINT', stop)
process.on('SIGTERM', stop)
process.on('exit', stop)
child.on('exit', code => process.exit(code ?? 0))
+23
View File
@@ -0,0 +1,23 @@
import { test, expect } from '@playwright/test'
// Open a trip into the planner: create a trip, open it from the dashboard, and
// confirm the trip planner (TripPlannerPage — the app's largest page) actually
// mounts, proving the day-plan/map shell renders rather than crashing on load.
test('open a trip and land in the planner with a map', async ({ page }) => {
await page.goto('/dashboard')
// Create a trip to open.
await page.locator('.add-trip-card').click()
const modal = page.locator('.modal-backdrop')
await expect(modal).toBeVisible()
const title = `E2E Planner ${Date.now()}`
await modal.locator('input[type="text"]').first().fill(title)
await modal.getByRole('button', { name: 'Create New Trip' }).click()
// Open it from the dashboard.
await page.getByText(title).first().click()
await expect(page).toHaveURL(/\/trips\/\d+/)
// The planner shows a Leaflet map once mounted (past the splash screen).
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
})
+78
View File
@@ -0,0 +1,78 @@
import js from '@eslint/js';
import gitignore from 'eslint-config-flat-gitignore';
import eslintConfigPrettier from 'eslint-config-prettier';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
// Minimal stub so the existing `// eslint-disable-next-line react/no-danger`
// directive in src/i18n/TransHtml.tsx resolves without pulling in the full
// eslint-plugin-react (not a dependency here). The rule is a no-op.
const reactStub = {
rules: {
'no-danger': {
meta: { schema: [] },
create() {
return {};
},
},
},
};
export default tseslint.config(
gitignore({ strict: false }),
{
ignores: [
'node_modules',
'dist',
'coverage',
'public',
'test-results',
'playwright-report',
'e2e/**',
'scripts/**',
'**/*.config.js',
'**/*.config.ts',
'**/*.config.mjs',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
eslintConfigPrettier,
{
files: ['src/**/*.{ts,tsx}', 'tests/**/*.{ts,tsx}'],
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
react: reactStub,
},
rules: {
'react/no-danger': 'off',
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
// --- Severities tuned to keep CI green on a codebase that was never linted ---
// (each rule below has pre-existing violations; surfaced as warnings, not blockers)
// rules-of-hooks has one conditional-hook violation in PlaceInspector.tsx -> warn (not error).
'react-hooks/rules-of-hooks': 'warn',
'react-hooks/exhaustive-deps': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
],
'@typescript-eslint/no-unused-expressions': 'warn',
'@typescript-eslint/no-unsafe-function-type': 'warn',
'@typescript-eslint/no-this-alias': 'warn',
'@typescript-eslint/no-non-null-asserted-optional-chain': 'warn',
// js.recommended rules with pre-existing hits.
'no-empty': 'warn',
'no-useless-escape': 'warn',
'no-useless-assignment': 'warn',
'preserve-caught-error': 'warn',
},
},
);
+36 -10
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "3.0.9",
"name": "@trek/client",
"version": "3.0.22",
"private": true,
"type": "module",
"scripts": {
@@ -8,25 +8,38 @@
"prebuild": "node scripts/generate-icons.mjs",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:unit": "vitest run tests/unit",
"test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
"test:coverage": "vitest run --coverage",
"lint": "eslint .",
"lint:check": "eslint .",
"lint:pages": "node scripts/check-page-pattern.mjs",
"e2e": "playwright test",
"e2e:report": "playwright show-report",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
},
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"@fontsource/geist-sans": "^5.2.5",
"@fontsource/poppins": "^5.2.7",
"@react-pdf/renderer": "^4.5.1",
"@simplewebauthn/browser": "^13.1.2",
"@trek/shared": "*",
"axios": "^1.6.7",
"dexie": "^4.4.2",
"heic-to": "^1.4.2",
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
"marked": "^18.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-dropzone": "^14.4.1",
"react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0",
"react-leaflet": "^5.0.0",
"react-leaflet-cluster": "^4.1.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.2",
"react-window": "^2.2.7",
@@ -34,26 +47,39 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
"zod": "^4.3.6",
"zustand": "^4.5.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/leaflet": "^1.9.8",
"@types/react": "^18.2.61",
"@types/react-dom": "^18.2.19",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^3.2.4",
"autoprefixer": "^10.4.18",
"eslint": "^10.2.1",
"eslint-config-flat-gitignore": "^2.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"fake-indexeddb": "^6.2.5",
"jsdom": "^29.0.1",
"msw": "^2.13.0",
"postcss": "^8.4.35",
"prettier": "^3.8.3",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"sharp": "^0.33.0",
"tailwindcss": "^3.4.1",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^5.1.4",
"vite-plugin-pwa": "^0.21.0",
"vitest": "^3.2.4"
+57
View File
@@ -0,0 +1,57 @@
import { defineConfig, devices } from '@playwright/test'
/**
* E2E harness for TREK's critical user flows (FE7).
*
* Two web servers are orchestrated: the Express/Nest backend on :3001 against an
* isolated throwaway SQLite DB (e2e/server-launch.mjs sets TREK_DB_FILE + seeds a
* known admin), and the Vite dev server on :5173 which proxies /api, /uploads,
* /ws to the backend. Tests run serially against one worker so they share the
* single seeded database deterministically.
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
timeout: 45_000,
expect: { timeout: 15_000 },
reporter: [['list']],
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
// Unauthenticated flows (login, register, public share) — no stored session.
{ name: 'public', testMatch: /\.public\.spec\.ts/, use: { ...devices['Desktop Chrome'] } },
// One-time login that persists a session for the authenticated flows.
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'app',
testMatch: /\.spec\.ts/,
testIgnore: /(\.public\.spec\.ts|auth\.setup\.ts)/,
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
dependencies: ['setup'],
},
],
webServer: [
{
// Always start our own backend (never reuse) so the isolated test DB is
// reset + reseeded on every run, regardless of any stray dev server.
command: 'node e2e/server-launch.mjs',
port: 3001,
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
},
{
command: 'npm run dev',
port: 5173,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
],
})
+44
View File
@@ -0,0 +1,44 @@
// Guards the "Page = wiring container + data hook" convention (see
// src/pages/PATTERN.md). A *Page.tsx default-export component should wire a
// co-located use<Page>() hook into JSX — it must not own state/effects itself.
//
// We scan only the default-export component body (from `export default function`
// up to the next top-level `function` declaration or EOF), so presentational
// sub-components and helper hooks living in the same file are not flagged.
// Context hooks like useTranslation/useParams are fine; the smell is stateful
// logic — useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef.
import { readdirSync, readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const pagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'pages')
const BANNED = ['useState', 'useReducer', 'useEffect', 'useLayoutEffect', 'useMemo', 'useCallback', 'useRef']
const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*\\(`)
const violations = []
for (const file of readdirSync(pagesDir)) {
if (!file.endsWith('Page.tsx') || file.endsWith('.test.tsx')) continue
const src = readFileSync(join(pagesDir, file), 'utf8')
const lines = src.split('\n')
const start = lines.findIndex(l => /export default function/.test(l))
if (start === -1) continue
// The page body ends at the next top-level declaration (a `function` at
// column 0) — everything after that is a sub-component or helper.
let end = lines.length
for (let i = start + 1; i < lines.length; i++) {
if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break }
}
for (let i = start; i < end; i++) {
if (bannedRe.test(lines[i])) {
violations.push(`${file}:${i + 1} ${lines[i].trim()}`)
}
}
}
if (violations.length > 0) {
console.error('Page-pattern violations — move this state/effect logic into the page\'s use<Page>() hook:\n')
for (const v of violations) console.error(' ' + v)
console.error(`\n${violations.length} violation(s). See src/pages/PATTERN.md.`)
process.exit(1)
}
console.log('Page pattern OK — no state/effect logic in page containers.')
+3 -3
View File
@@ -58,7 +58,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
}
if (!isAuthenticated) {
const redirectParam = encodeURIComponent(location.pathname + location.search)
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
}
@@ -119,7 +119,7 @@ export default function App() {
}
}
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
if (config?.demo_mode) setDemoMode(true)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
if (config?.version) setAppVersion(config.version)
@@ -218,7 +218,7 @@ export default function App() {
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
<Route path="/oauth/consent" element={<OAuthAuthorizePage />} />
<Route
path="/dashboard"
element={
+367 -161
View File
@@ -1,30 +1,112 @@
import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod'
import {
weatherResultSchema, type WeatherResult,
inAppListResultSchema, type InAppListResult,
unreadCountResultSchema, type UnreadCountResult,
channelTestResultSchema,
mapsSearchResultSchema, mapsAutocompleteResultSchema, mapsPlaceDetailsResultSchema,
mapsPlacePhotoResultSchema, mapsReverseResultSchema, mapsResolveUrlResultSchema,
type NotificationRespondRequest,
type SettingUpsertRequest, type SettingsBulkRequest,
type JourneyCreateRequest, type JourneyAddTripRequest,
type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest,
type JourneyShareLinkRequest,
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
type ResetPasswordRequest, type ChangePasswordRequest,
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
type TripAddMemberRequest, type AssignmentReorderRequest,
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest,
type PlaceCreateRequest, type PlaceUpdateRequest,
type ReservationCreateRequest, type ReservationUpdateRequest,
type AccommodationCreateRequest, type AccommodationUpdateRequest,
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
type PackingCreateItemRequest, type PackingUpdateItemRequest,
type TodoCreateItemRequest, type TodoUpdateItemRequest,
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
type PlaceBulkDeleteRequest,
type DayNoteCreateRequest, type DayNoteUpdateRequest,
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
type PackingCategoryAssigneesRequest,
type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest,
type TodoCategoryAssigneesRequest,
type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest,
type CollabPollVoteRequest, type CollabMessageCreateRequest, type CollabReactionRequest,
type FileUpdateRequest, type FileLinkRequest,
type CreateTagRequest, type UpdateTagRequest,
type CreateCategoryRequest, type UpdateCategoryRequest,
type PlaceImportListRequest,
type BookingImportPreviewItem,
type BookingImportPreviewResponse,
type BookingImportConfirmResponse,
} from '@trek/shared'
import { getSocketId } from './websocket'
import en from '../i18n/translations/en'
import br from '../i18n/translations/br'
import de from '../i18n/translations/de'
import es from '../i18n/translations/es'
import fr from '../i18n/translations/fr'
import it from '../i18n/translations/it'
import nl from '../i18n/translations/nl'
import pl from '../i18n/translations/pl'
import cs from '../i18n/translations/cs'
import hu from '../i18n/translations/hu'
import ru from '../i18n/translations/ru'
import zh from '../i18n/translations/zh'
import zhTw from '../i18n/translations/zhTw'
import ar from '../i18n/translations/ar'
import { isReachable, probeNow } from '../sync/connectivity'
const rateLimitTranslations: Record<string, Record<string, string | unknown>> = {
en, br, de, es, fr, it, nl, pl, cs, hu, ru, zh, 'zh-TW': zhTw, ar,
/**
* Validate a response payload against its @trek/shared Zod schema — but only in
* dev, and never throwing. A drift between the server contract and the client's
* expected shape is surfaced as a console warning during development; in
* production (and on any mismatch) the data passes through untouched, so adding
* validation can never break a working call. This is the typed-request helper
* the FE adopts per domain as each backend module lands on @trek/shared.
*/
const API_DEV = Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV)
export function parseInDev<S extends z.ZodTypeAny>(schema: S, data: unknown, label: string): z.infer<S> {
if (API_DEV) {
const result = schema.safeParse(data)
if (!result.success) {
console.warn(`[api] ${label}: response did not match the @trek/shared schema`, result.error.issues)
}
}
return data as z.infer<S>
}
/**
* Same dev-only drift check as parseInDev, but passes the payload straight
* through with its original inferred type instead of the schema type. Use this
* for endpoints whose existing consumers rely on the loose `r.data` type — it
* adds the development contract-drift warning without retyping the public
* surface (so it can never break a consumer that worked before).
*/
function checkInDev<T>(schema: z.ZodTypeAny, data: T, label: string): T {
if (API_DEV) {
const result = schema.safeParse(data)
if (!result.success) {
console.warn(`[api] ${label}: response did not match the @trek/shared schema`, result.error.issues)
}
}
return data
}
const RATE_LIMIT_MESSAGES: Record<string, string> = {
en: 'Too many attempts. Please try again later.',
de: 'Zu viele Versuche. Bitte versuchen Sie es später erneut.',
es: 'Demasiados intentos. Inténtelo de nuevo más tarde.',
fr: 'Trop de tentatives. Veuillez réessayer plus tard.',
hu: 'Túl sok próbálkozás. Kérjük, próbálja újra később.',
nl: 'Te veel pogingen. Probeer het later opnieuw.',
br: 'Muitas tentativas. Tente novamente mais tarde.',
cs: 'Příliš mnoho pokusů. Zkuste to prosím znovu.',
pl: 'Zbyt wiele prób. Spróbuj ponownie później.',
ru: 'Слишком много попыток. Попробуйте позже.',
zh: '尝试次数过多,请稍后再试。',
'zh-TW': '嘗試次數過多,請稍後再試。',
it: 'Troppi tentativi. Riprova più tardi.',
tr: 'Çok fazla deneme. Lütfen daha sonra tekrar deneyin.',
ar: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
id: 'Terlalu banyak percobaan. Coba lagi nanti.',
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
uk: 'Занадто багато спроб. Спробуйте пізніше.',
}
function translateRateLimit(): string {
const fallback = 'Too many attempts. Please try again later.'
const fallback = RATE_LIMIT_MESSAGES['en']!
try {
const lang = localStorage.getItem('app_language') || 'en'
const table = rateLimitTranslations[lang] || rateLimitTranslations.en
return (table['common.tooManyAttempts'] as string) || (rateLimitTranslations.en['common.tooManyAttempts'] as string) || fallback
return RATE_LIMIT_MESSAGES[lang] ?? fallback
} catch {
return fallback
}
@@ -33,6 +115,7 @@ function translateRateLimit(): string {
export const apiClient: AxiosInstance = axios.create({
baseURL: '/api',
withCredentials: true,
timeout: 8000,
headers: {
'Content-Type': 'application/json',
},
@@ -42,24 +125,24 @@ const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete'])
// Request interceptor - add socket ID + idempotency key for mutating requests
apiClient.interceptors.request.use(
(config) => {
const sid = getSocketId()
if (sid) {
config.headers['X-Socket-Id'] = sid
}
// Attach a per-request idempotency key to all write operations so the
// server can deduplicate retried requests (e.g. network blips).
// The mutation queue sets its own pre-generated key; skip if already set.
const method = (config.method ?? '').toLowerCase()
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
const key = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2)
config.headers['X-Idempotency-Key'] = key
}
return config
},
(error) => Promise.reject(error)
(config) => {
const sid = getSocketId()
if (sid) {
config.headers['X-Socket-Id'] = sid
}
// Attach a per-request idempotency key to all write operations so the
// server can deduplicate retried requests (e.g. network blips).
// The mutation queue sets its own pre-generated key; skip if already set.
const method = (config.method ?? '').toLowerCase()
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
const key = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2)
config.headers['X-Idempotency-Key'] = key
}
return config
},
(error) => Promise.reject(error)
)
export function isAuthPublicPath(pathname: string): boolean {
@@ -68,45 +151,93 @@ export function isAuthPublicPath(pathname: string): boolean {
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
}
// Response interceptor - handle 401, 403 MFA, 429 rate limit
// Unregisters the SW before reloading so the navigation reaches the network.
// Without this, WorkBox's NavigationRoute serves the cached SPA shell and the
// upstream proxy (CF Access / Pangolin) never gets to challenge the user.
async function unregisterSWAndReload(): Promise<void> {
try {
const reg = await navigator.serviceWorker?.getRegistration()
if (reg) await reg.unregister()
} catch { /* ignore */ }
window.location.reload()
}
// Response interceptor - handle 401, 403 MFA, 429 rate limit, proxy auth challenges
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
const { pathname } = window.location
if (!isAuthPublicPath(pathname)) {
const currentPath = pathname + window.location.search
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
(response) => {
sessionStorage.removeItem('proxy_reauth_attempted')
return response
},
async (error) => {
// CF Access / Pangolin / similar: cross-origin redirect from /api/* surfaces
// as a CORS error with no response object. Probe the health endpoint to
// 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()) {
const { pathname } = window.location
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
sessionStorage.setItem('proxy_reauth_attempted', '1')
await unregisterSWAndReload()
return Promise.reject(error)
}
}
}
}
if (
error.response?.status === 403 &&
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
!window.location.pathname.startsWith('/settings')
) {
window.location.href = '/settings?mfa=required'
}
if (error.response?.status === 429) {
const translated = translateRateLimit()
const data = error.response.data as { error?: string } | undefined
if (data && typeof data === 'object') {
data.error = translated
} else {
error.response.data = { error: translated }
// Pangolin header-auth extended compatibility mode: returns 401 with an
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
// always application/json, so checking for text/html is unambiguous.
if (error.response?.status === 401) {
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
if (ct.includes('text/html')) {
const { pathname } = window.location
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
sessionStorage.setItem('proxy_reauth_attempted', '1')
await unregisterSWAndReload()
return Promise.reject(error)
}
}
}
error.message = translated
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
const { pathname } = window.location
if (!isAuthPublicPath(pathname)) {
const currentPath = pathname + window.location.search + window.location.hash
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
}
}
if (
error.response?.status === 403 &&
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
!window.location.pathname.startsWith('/settings')
) {
window.location.href = '/settings?mfa=required'
}
if (error.response?.status === 429) {
const translated = translateRateLimit()
const data = error.response.data as { error?: string } | undefined
if (data && typeof data === 'object') {
data.error = translated
} else {
error.response.data = { error: translated }
}
error.message = translated
}
return Promise.reject(error)
}
return Promise.reject(error)
}
)
export const authApi = {
register: (data: { username: string; email: string; password: string; invite_token?: string }) => apiClient.post('/auth/register', data).then(r => r.data),
register: (data: RegisterRequest) => apiClient.post('/auth/register', data).then(r => r.data),
validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data),
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
login: (data: LoginRequest) => apiClient.post('/auth/login', data).then(r => r.data),
verifyMfaLogin: (data: MfaVerifyLoginRequest) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data),
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }),
mfaEnable: (data: MfaEnableRequest) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }),
mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data),
me: () => apiClient.get('/auth/me').then(r => r.data),
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
@@ -120,16 +251,34 @@ export const authApi = {
updateAppSettings: (data: Record<string, unknown>) => apiClient.put('/auth/app-settings', data).then(r => r.data),
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
forgotPassword: (data: { email: string }) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
resetPassword: (data: { token: string; new_password: string; mfa_code?: string }) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }),
changePassword: (data: ChangePasswordRequest) => apiClient.put('/auth/me/password', data).then(r => r.data),
forgotPassword: (data: ForgotPasswordRequest) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
resetPassword: (data: ResetPasswordRequest) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }),
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
mcpTokens: {
list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data),
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name }).then(r => r.data),
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
},
passkey: {
registerOptions: (password: string) => apiClient.post('/auth/passkey/register/options', { password }).then(r => r.data),
registerVerify: (attestationResponse: unknown, name?: string) => apiClient.post('/auth/passkey/register/verify', { attestationResponse, name }).then(r => r.data),
loginOptions: () => apiClient.post('/auth/passkey/login/options', {}).then(r => r.data),
loginVerify: (assertionResponse: unknown) => apiClient.post('/auth/passkey/login/verify', { assertionResponse }).then(r => r.data as { token: string; user: Record<string, unknown> }),
list: () => apiClient.get('/auth/passkey/credentials').then(r => r.data as { credentials: PasskeyCredential[] }),
rename: (id: number, name: string) => apiClient.patch(`/auth/passkey/credentials/${id}`, { name }).then(r => r.data),
delete: (id: number, password: string) => apiClient.delete(`/auth/passkey/credentials/${id}`, { data: { password } }).then(r => r.data),
},
}
export interface PasskeyCredential {
id: number
name: string | null
device_type: string | null
backed_up: boolean
created_at: string
last_used_at: string | null
}
export const oauthApi = {
@@ -142,6 +291,7 @@ export const oauthApi = {
state?: string
code_challenge: string
code_challenge_method: string
resource?: string
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
/** Submit user consent (approve or deny) */
@@ -153,12 +303,13 @@ export const oauthApi = {
code_challenge: string
code_challenge_method: string
approved: boolean
resource?: string
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
clients: {
list: () => apiClient.get('/oauth/clients').then(r => r.data),
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
apiClient.post('/oauth/clients', data).then(r => r.data),
create: (data: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }) =>
apiClient.post('/oauth/clients', data).then(r => r.data),
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
},
@@ -171,32 +322,33 @@ export const oauthApi = {
export const tripsApi = {
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
create: (data: Record<string, unknown>) => apiClient.post('/trips', data).then(r => r.data),
create: (data: TripCreateRequest) => apiClient.post('/trips', data).then(r => r.data),
get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data),
update: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).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 }).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),
copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).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),
}
export const daysApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
}
export const placesApi = {
list: (tripId: number | string, params?: Record<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
create: (tripId: number | string, data: PlaceCreateRequest) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
@@ -214,65 +366,66 @@ export const placesApi = {
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
importGoogleList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
importNaverList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
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 }).then(r => r.data),
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
}
export const assignmentsApi = {
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
create: (tripId: number | string, dayId: number | string, data: { place_id: number | string }) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
create: (tripId: number | string, dayId: number | string, data: AssignmentCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data),
reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds } satisfies AssignmentReorderRequest).then(r => r.data),
move: (tripId: number | string, assignmentId: number, newDayId: number | string, orderIndex: number | null) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data),
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds }).then(r => r.data),
updateTime: (tripId: number | string, id: number, times: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data),
updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
}
export const packingApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
create: (tripId: number | string, data: PackingCreateItemRequest) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items } satisfies PackingImportRequest).then(r => r.data),
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 }).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
listTemplates: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/templates`).then(r => r.data),
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds }).then(r => r.data),
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data),
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
updateBag: (tripId: number | string, bagId: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
createBag: (tripId: number | string, data: PackingCreateBagRequest) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
updateBag: (tripId: number | string, bagId: number, data: PackingUpdateBagRequest) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data),
}
export const todoApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data),
create: (tripId: number | string, data: TodoCreateItemRequest) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: TodoUpdateItemRequest) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/todo/${id}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds }).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds } satisfies TodoReorderRequest).then(r => r.data),
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo/category-assignees`).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies TodoCategoryAssigneesRequest).then(r => r.data),
}
export const tagsApi = {
list: () => apiClient.get('/tags').then(r => r.data),
create: (data: Record<string, unknown>) => apiClient.post('/tags', data).then(r => r.data),
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
create: (data: CreateTagRequest) => apiClient.post('/tags', data).then(r => r.data),
update: (id: number, data: UpdateTagRequest) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
delete: (id: number) => apiClient.delete(`/tags/${id}`).then(r => r.data),
}
export const categoriesApi = {
list: () => apiClient.get('/categories').then(r => r.data),
create: (data: Record<string, unknown>) => apiClient.post('/categories', data).then(r => r.data),
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
create: (data: CreateCategoryRequest) => apiClient.post('/categories', data).then(r => r.data),
update: (id: number, data: UpdateCategoryRequest) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
delete: (id: number) => apiClient.delete(`/categories/${id}`).then(r => r.data),
}
@@ -281,6 +434,7 @@ export const adminApi = {
createUser: (data: Record<string, unknown>) => apiClient.post('/admin/users', data).then(r => r.data),
updateUser: (id: number, data: Record<string, unknown>) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data),
deleteUser: (id: number) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
resetUserPasskeys: (id: number) => apiClient.delete(`/admin/users/${id}/passkeys`).then(r => r.data),
stats: () => apiClient.get('/admin/stats').then(r => r.data),
saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data),
getOidc: () => apiClient.get('/admin/oidc').then(r => r.data),
@@ -313,7 +467,7 @@ export const adminApi = {
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
auditLog: (params?: { limit?: number; offset?: number }) =>
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
@@ -322,7 +476,7 @@ export const adminApi = {
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
sendTestNotification: (data: Record<string, unknown>) =>
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
@@ -333,9 +487,23 @@ export const addonsApi = {
enabled: () => apiClient.get('/addons').then(r => r.data),
}
export const airtrailApi = {
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean }) =>
apiClient.put('/integrations/airtrail/settings', data).then(r => r.data),
status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data),
test: (data: { url?: string; apiKey?: string; allowInsecureTls?: boolean }) =>
apiClient.post('/integrations/airtrail/test', data).then(r => r.data),
sync: (): Promise<{ changed: number }> => apiClient.post('/integrations/airtrail/sync').then(r => r.data),
// flights + import are added with the trip-planner import (P2)
flights: () => apiClient.get('/integrations/airtrail/flights').then(r => r.data),
import: (tripId: number, flightIds: string[]) =>
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, { flightIds }).then(r => r.data),
}
export const journeyApi = {
list: () => apiClient.get('/journeys').then(r => r.data),
create: (data: { title: string; subtitle?: string; trip_ids?: number[] }) => apiClient.post('/journeys', data).then(r => r.data),
create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data),
get: (id: number) => apiClient.get(`/journeys/${id}`).then(r => r.data),
update: (id: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/${id}`, data).then(r => r.data),
delete: (id: number) => apiClient.delete(`/journeys/${id}`).then(r => r.data),
@@ -344,7 +512,7 @@ export const journeyApi = {
availableTrips: () => apiClient.get('/journeys/available-trips').then(r => r.data),
// Trips (sync sources)
addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId }).then(r => r.data),
addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId } satisfies JourneyAddTripRequest).then(r => r.data),
removeTrip: (id: number, tripId: number) => apiClient.delete(`/journeys/${id}/trips/${tripId}`).then(r => r.data),
// Entries
@@ -352,12 +520,24 @@ export const journeyApi = {
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds } satisfies JourneyReorderEntriesRequest).then(r => r.data),
// Photos
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).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 } : {}) }).then(r => r.data),
uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
apiClient.post(`/journeys/entries/${entryId}/photos`, 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),
uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
apiClient.post(`/journeys/${journeyId}/gallery/photos`, 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) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) } 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),
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
@@ -379,19 +559,24 @@ export const journeyApi = {
// Share
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
createShareLink: (id: number, perms: JourneyShareLinkRequest) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
deleteShareLink: (id: number) => apiClient.delete(`/journeys/${id}/share-link`).then(r => r.data),
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
}
export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data),
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')),
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
// OSM-only POI explore: places of a category within the current map viewport bbox.
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
// timeout than the global default instead of aborting at 8s and showing nothing.
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
}
export const airportsApi = {
@@ -401,15 +586,18 @@ export const airportsApi = {
export const budgetApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
create: (tripId: number | string, data: BudgetCreateItemRequest) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: BudgetUpdateItemRequest) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds } satisfies BudgetUpdateMembersRequest).then(r => r.data),
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data),
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories }).then(r => r.data),
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
}
export const filesApi = {
@@ -417,71 +605,89 @@ export const filesApi = {
upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: FileUpdateRequest) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data),
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data),
permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data),
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
addLink: (tripId: number | string, fileId: number, data: FileLinkRequest) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data),
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
}
export const reservationsApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
upcoming: () => apiClient.get('/reservations/upcoming').then(r => r.data),
create: (tripId: number | string, data: ReservationCreateRequest) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
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> => {
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)
},
importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise<BookingImportConfirmResponse> =>
apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data),
}
export const healthApi = {
features: (): Promise<{ bookingImport: boolean }> => apiClient.get('/health/features').then(r => r.data),
}
export const weatherApi = {
get: (lat: number, lng: number, date: string) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
}
export const configApi = {
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
apiClient.get('/config').then(r => r.data),
apiClient.get('/config').then(r => r.data),
}
export const settingsApi = {
get: () => apiClient.get('/settings').then(r => r.data),
set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data),
setBulk: (settings: Record<string, unknown>) => apiClient.post('/settings/bulk', { settings }).then(r => r.data),
set: (key: string, value: unknown) => {
const body: SettingUpsertRequest = { key, value }
return apiClient.put('/settings', body).then(r => r.data)
},
setBulk: (settings: Record<string, unknown>) => {
const body: SettingsBulkRequest = { settings }
return apiClient.post('/settings/bulk', body).then(r => r.data)
},
}
export const accommodationsApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
create: (tripId: number | string, data: AccommodationCreateRequest) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: AccommodationUpdateRequest) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
}
export const dayNotesApi = {
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data),
create: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
create: (tripId: number | string, dayId: number | string, data: DayNoteCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, id: number, data: DayNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
}
export const collabApi = {
getNotes: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data),
createNote: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
updateNote: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
createNote: (tripId: number | string, data: CollabNoteCreateRequest) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
updateNote: (tripId: number | string, id: number, data: CollabNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
createPoll: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex }).then(r => r.data),
createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex } satisfies CollabPollVoteRequest).then(r => r.data),
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
sendMessage: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji }).then(r => r.data),
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji } satisfies CollabReactionRequest).then(r => r.data),
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
}
@@ -522,28 +728,28 @@ export const shareApi = {
export const notificationsApi = {
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data),
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => r.data),
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')),
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')),
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')),
}
export const inAppNotificationsApi = {
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
unreadCount: () =>
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }): Promise<InAppListResult> =>
apiClient.get('/notifications/in-app', { params }).then(r => parseInDev(inAppListResultSchema, r.data, 'notifications.list')),
unreadCount: (): Promise<UnreadCountResult> =>
apiClient.get('/notifications/in-app/unread-count').then(r => parseInDev(unreadCountResultSchema, r.data, 'notifications.unreadCount')),
markRead: (id: number) =>
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
markUnread: (id: number) =>
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
markAllRead: () =>
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
delete: (id: number) =>
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
deleteAll: () =>
apiClient.delete('/notifications/in-app/all').then(r => r.data),
respond: (id: number, response: 'positive' | 'negative') =>
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
apiClient.delete('/notifications/in-app/all').then(r => r.data),
respond: (id: number, response: NotificationRespondRequest['response']) =>
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
}
export default apiClient
export default apiClient
+6
View File
@@ -20,6 +20,12 @@ export function getSocketId(): string | null {
return mySocketId
}
/** Trip ids the app currently has open (joined). Used to re-hydrate the active
* trip's store after the network comes back via the `online` event. */
export function getActiveTrips(): string[] {
return Array.from(activeTrips)
}
export function setRefetchCallback(fn: RefetchCallback | null): void {
refetchCallback = fn
}
+39 -40
View File
@@ -4,10 +4,10 @@ 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 } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react'
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane,
}
function ImmichIcon({ size = 14 }: { size?: number }) {
@@ -158,16 +158,16 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
return (
<div className="space-y-6">
{/* Header */}
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
<div className="px-6 py-4 border-b border-edge-secondary">
<h2 className="font-semibold text-content">{t('admin.addons.title')}</h2>
<p className="text-xs mt-1 text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
</p>
</div>
{addons.length === 0 ? (
<div className="p-8 text-center text-sm" style={{ color: 'var(--text-faint)' }}>
<div className="p-8 text-center text-sm text-content-faint">
{t('admin.addons.noAddons')}
</div>
) : (
@@ -175,9 +175,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{/* Trip Addons */}
{tripAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
<Briefcase size={13} style={{ color: 'var(--text-muted)' }} />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
<div className="px-6 py-2.5 border-b flex items-center gap-2 bg-surface-secondary border-edge-secondary">
<Briefcase size={13} className="text-content-muted" />
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
{t('admin.addons.type.trip')} {t('admin.addons.tripHint')}
</span>
</div>
@@ -185,14 +185,14 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<Luggage size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div className="flex items-center gap-4 px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
<Luggage size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('admin.bagTracking.title')}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.bagTracking.subtitle')}</div>
<div className="text-sm font-medium text-content-secondary">{t('admin.bagTracking.title')}</div>
<div className="text-xs mt-0.5 text-content-faint">{t('admin.bagTracking.subtitle')}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
<span className={`hidden sm:inline text-xs font-medium ${bagTrackingEnabled ? 'text-content' : 'text-content-faint'}`}>
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button onClick={onToggleBagTracking}
@@ -205,20 +205,20 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
</div>
)}
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
<div className="space-y-2">
{COLLAB_SUB_FEATURES.map(feat => {
const enabled = collabFeatures[feat.key]
const Icon = feat.icon
return (
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<Icon size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t(feat.titleKey)}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t(feat.subtitleKey)}</div>
<div className="text-sm font-medium text-content-secondary">{t(feat.titleKey)}</div>
<div className="text-xs mt-0.5 text-content-faint">{t(feat.subtitleKey)}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
<span className={`hidden sm:inline text-xs font-medium ${enabled ? 'text-content' : 'text-content-faint'}`}>
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button onClick={() => onToggleCollabFeature(feat.key)}
@@ -242,9 +242,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{/* Global Addons */}
{globalAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
<Globe size={13} style={{ color: 'var(--text-muted)' }} />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
<Globe size={13} className="text-content-muted" />
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
{t('admin.addons.type.global')} {t('admin.addons.globalHint')}
</span>
</div>
@@ -253,19 +253,19 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{/* Memories providers as sub-items under Journey addon */}
{addon.id === 'journey' && providerOptions.length > 0 && (
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
<div className="space-y-2">
{providerOptions.map(provider => {
const ProviderIcon = PROVIDER_ICONS[provider.key]
return (
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
{ProviderIcon && <span style={{ color: 'var(--text-faint)' }}><ProviderIcon size={14} /></span>}
{ProviderIcon && <span className="text-content-faint"><ProviderIcon size={14} /></span>}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
<div className="text-sm font-medium text-content-secondary">{provider.label}</div>
<div className="text-xs mt-0.5 text-content-faint">{provider.description}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
<span className={`hidden sm:inline text-xs font-medium ${provider.enabled ? 'text-content' : 'text-content-faint'}`}>
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
@@ -291,9 +291,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{/* Integration Addons */}
{integrationAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
<Link2 size={13} style={{ color: 'var(--text-muted)' }} />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
<Link2 size={13} className="text-content-muted" />
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
{t('admin.addons.type.integration')} {t('admin.addons.integrationHint')}
</span>
</div>
@@ -336,31 +336,31 @@ function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statu
const displayDescription = descriptionOverride || label.description
const enabledState = statusOverride ?? addon.enabled
return (
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95 border-edge-secondary" style={{ opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
{/* Icon */}
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 bg-surface-secondary text-content">
<AddonIcon name={addon.icon} size={20} />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{displayName}</span>
<span className="text-sm font-semibold text-content">{displayName}</span>
{isComingSoon && (
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full text-content-faint bg-surface-tertiary">
Coming Soon
</span>
)}
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}>
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-surface-secondary text-content-muted">
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
</span>
</div>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{displayDescription}</p>
<p className="text-xs mt-0.5 text-content-muted">{displayDescription}</p>
</div>
{/* Toggle */}
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
<span className={`hidden sm:inline text-xs font-medium ${(enabledState && !isComingSoon) ? 'text-content' : 'text-content-faint'}`}>
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
{!hideToggle && (
@@ -371,9 +371,8 @@ function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statu
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
>
<span
className="inline-block h-4 w-4 transform rounded-full transition-transform"
className="inline-block h-4 w-4 transform rounded-full transition-transform bg-surface-card"
style={{
background: 'var(--bg-card)',
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
}}
/>
@@ -83,14 +83,14 @@ export default function AdminMcpTokensPanel() {
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.title')}</h2>
<h2 className="text-lg font-semibold text-content">{t('admin.mcpTokens.title')}</h2>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
</div>
{/* OAuth Sessions */}
<div>
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.sectionTitle')}</h3>
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
<h3 className="text-sm font-semibold mb-2 text-content-secondary">{t('admin.oauthSessions.sectionTitle')}</h3>
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
{sessionsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
@@ -102,8 +102,8 @@ export default function AdminMcpTokensPanel() {
</div>
) : (
<>
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 px-4 py-2.5 text-xs font-medium border-b"
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 px-4 py-2.5 text-xs font-medium border-b border-edge bg-surface-secondary"
style={{ color: 'var(--text-tertiary)' }}>
<span>{t('admin.oauthSessions.clientName')}</span>
<span>{t('admin.oauthSessions.owner')}</span>
<span className="text-right">{t('admin.oauthSessions.created')}</span>
@@ -115,34 +115,31 @@ export default function AdminMcpTokensPanel() {
const hidden = session.scopes.length - SCOPES_PREVIEW
return (
<div key={session.id}
className="grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3"
style={{ borderBottom: i < sessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
className={`grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3 ${i < sessions.length - 1 ? 'border-b border-edge' : ''}`}>
<div className="min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p>
<p className="text-sm font-medium truncate text-content">{session.client_name}</p>
<div className="flex flex-wrap gap-1 mt-1.5">
{visible.map(scope => (
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono bg-surface-secondary border border-edge"
style={{ color: 'var(--text-tertiary)' }}>
{scope}
</span>
))}
{!expanded && hidden > 0 && (
<button onClick={() => toggleScopes(session.id)}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
+{hidden} more
</button>
)}
{expanded && hidden > 0 && (
<button onClick={() => toggleScopes(session.id)}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
show less
</button>
)}
</div>
</div>
<div className="flex items-center gap-1.5 text-sm pt-0.5" style={{ color: 'var(--text-secondary)' }}>
<div className="flex items-center gap-1.5 text-sm pt-0.5 text-content-secondary">
<User className="w-3.5 h-3.5 flex-shrink-0" />
<span className="whitespace-nowrap">{session.username}</span>
</div>
@@ -164,8 +161,8 @@ export default function AdminMcpTokensPanel() {
{/* MCP Tokens */}
<div>
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.sectionTitle')}</h3>
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
<h3 className="text-sm font-semibold mb-2 text-content-secondary">{t('admin.mcpTokens.sectionTitle')}</h3>
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
{tokensLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
@@ -177,8 +174,8 @@ export default function AdminMcpTokensPanel() {
</div>
) : (
<>
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b border-edge bg-surface-secondary"
style={{ color: 'var(--text-tertiary)' }}>
<span>{t('admin.mcpTokens.tokenName')}</span>
<span>{t('admin.mcpTokens.owner')}</span>
<span className="text-right">{t('admin.mcpTokens.created')}</span>
@@ -187,13 +184,12 @@ export default function AdminMcpTokensPanel() {
</div>
{tokens.map((token, i) => (
<div key={token.id}
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
className={`grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3 ${i < tokens.length - 1 ? 'border-b border-edge' : ''}`}>
<div className="min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
<p className="text-sm font-medium truncate text-content">{token.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
</div>
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
<div className="flex items-center gap-1.5 text-sm text-content-secondary">
<User className="w-3.5 h-3.5 flex-shrink-0" />
<span className="whitespace-nowrap">{token.username}</span>
</div>
@@ -217,14 +213,14 @@ export default function AdminMcpTokensPanel() {
{/* Revoke OAuth session modal */}
{revokeConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.oauthSessions.revokeTitle')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.revokeMessage')}</p>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
<h3 className="text-base font-semibold text-content">{t('admin.oauthSessions.revokeTitle')}</h3>
<p className="text-sm text-content-secondary">{t('admin.oauthSessions.revokeMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setRevokeConfirmId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')}
</button>
<button onClick={() => handleRevoke(revokeConfirmId)}
@@ -238,14 +234,14 @@ export default function AdminMcpTokensPanel() {
{/* Delete MCP token modal */}
{deleteConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.deleteTitle')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.deleteMessage')}</p>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
<h3 className="text-base font-semibold text-content">{t('admin.mcpTokens.deleteTitle')}</h3>
<p className="text-sm text-content-secondary">{t('admin.mcpTokens.deleteMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setDeleteConfirmId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')}
</button>
<button onClick={() => handleDelete(deleteConfirmId)}
+22 -24
View File
@@ -100,54 +100,53 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-lg m-0 flex items-center gap-2" style={{ color: 'var(--text-primary)' }}>
<h2 className="font-semibold text-lg m-0 flex items-center gap-2 text-content">
<ClipboardList size={20} />
{t('admin.tabs.audit')}
</h2>
<p className="text-sm m-0 mt-1" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.subtitle')}</p>
<p className="text-sm m-0 mt-1 text-content-muted">{t('admin.audit.subtitle')}</p>
</div>
<button
type="button"
disabled={loading}
onClick={() => loadFirstPage()}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-primary)', background: 'var(--bg-card)' }}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50 border-edge text-content bg-surface-card"
>
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
{t('admin.audit.refresh')}
</button>
</div>
<p className="text-xs m-0" style={{ color: 'var(--text-faint)' }}>
<p className="text-xs m-0 text-content-faint">
{t('admin.audit.showing', { count: entries.length, total })}
</p>
{loading && entries.length === 0 ? (
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('common.loading')}</div>
<div className="py-12 text-center text-sm text-content-muted">{t('common.loading')}</div>
) : entries.length === 0 ? (
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.empty')}</div>
<div className="py-12 text-center text-sm text-content-muted">{t('admin.audit.empty')}</div>
) : (
<div className="rounded-xl border overflow-x-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
<div className="rounded-xl border overflow-x-auto border-edge bg-surface-card">
<table className="w-full text-sm border-collapse min-w-[720px]">
<thead>
<tr className="border-b text-left" style={{ borderColor: 'var(--border-secondary)' }}>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.time')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.user')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.action')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.resource')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.ip')}</th>
<th className="p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.details')}</th>
<tr className="border-b text-left border-edge-secondary">
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.time')}</th>
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.user')}</th>
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.action')}</th>
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.resource')}</th>
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.ip')}</th>
<th className="p-3 font-semibold text-content-secondary">{t('admin.audit.col.details')}</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.id} className="border-b align-top" style={{ borderColor: 'var(--border-secondary)' }}>
<td className="p-3 whitespace-nowrap font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{fmtTime(e.created_at)}</td>
<td className="p-3" style={{ color: 'var(--text-primary)' }}>{userLabel(e)}</td>
<td className="p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{e.action}</td>
<td className="p-3 font-mono text-xs break-all max-w-[140px]" style={{ color: 'var(--text-muted)' }}>{e.resource || '—'}</td>
<td className="p-3 font-mono text-xs whitespace-nowrap" style={{ color: 'var(--text-muted)' }}>{e.ip || '—'}</td>
<td className="p-3 font-mono text-xs break-all max-w-[280px]" style={{ color: 'var(--text-faint)' }}>{fmtDetails(e.details)}</td>
<tr key={e.id} className="border-b align-top border-edge-secondary">
<td className="p-3 whitespace-nowrap font-mono text-xs text-content">{fmtTime(e.created_at)}</td>
<td className="p-3 text-content">{userLabel(e)}</td>
<td className="p-3 font-mono text-xs text-content">{e.action}</td>
<td className="p-3 font-mono text-xs break-all max-w-[140px] text-content-muted">{e.resource || '—'}</td>
<td className="p-3 font-mono text-xs whitespace-nowrap text-content-muted">{e.ip || '—'}</td>
<td className="p-3 font-mono text-xs break-all max-w-[280px] text-content-faint">{fmtDetails(e.details)}</td>
</tr>
))}
</tbody>
@@ -160,8 +159,7 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
type="button"
disabled={loading}
onClick={() => loadMore()}
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50"
style={{ color: 'var(--text-secondary)' }}
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50 text-content-secondary"
>
{t('admin.audit.loadMore')}
</button>
+14 -12
View File
@@ -186,8 +186,8 @@ export default function BackupPanel() {
<div className="flex items-center gap-3">
<HardDrive className="w-5 h-5 text-gray-400" />
<div>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.subtitle')}</p>
<h2 className="font-semibold text-content">{t('backup.title')}</h2>
<p className="text-xs mt-1 text-content-muted">{t('backup.subtitle')}</p>
</div>
</div>
<div className="flex items-center gap-2">
@@ -310,8 +310,8 @@ export default function BackupPanel() {
<div className="flex items-center gap-3 mb-6">
<Clock className="w-5 h-5 text-gray-400" />
<div>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.auto.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.auto.subtitle')}</p>
<h2 className="font-semibold text-content">{t('backup.auto.title')}</h2>
<p className="text-xs mt-1 text-content-muted">{t('backup.auto.subtitle')}</p>
</div>
</div>
@@ -360,7 +360,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
<CustomSelect
value={String(autoSettings.hour)}
onChange={v => handleAutoSettingsChange('hour', parseInt(v, 10))}
onChange={v => handleAutoSettingsChange('hour', parseInt(String(v), 10))}
size="sm"
options={HOURS.map(h => {
let label: string
@@ -408,7 +408,7 @@ export default function BackupPanel() {
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
<CustomSelect
value={String(autoSettings.day_of_month)}
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(v, 10))}
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(String(v), 10))}
size="sm"
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
/>
@@ -458,7 +458,8 @@ export default function BackupPanel() {
{/* Restore Warning Modal */}
{restoreConfirm && (
<div
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
className="bg-[rgba(0,0,0,0.5)]"
style={{ position: 'fixed', inset: 0, zIndex: 9999, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={() => setRestoreConfirm(null)}
>
<div
@@ -468,14 +469,14 @@ export default function BackupPanel() {
>
{/* Red header */}
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<AlertTriangle size={20} style={{ color: 'white' }} />
<div className="bg-[rgba(255,255,255,0.2)]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<AlertTriangle size={20} className="text-white" />
</div>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>
<h3 className="text-white" style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>
{t('backup.restoreConfirmTitle')}
</h3>
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 12 }}>
{restoreConfirm.filename}
</p>
</div>
@@ -505,7 +506,8 @@ export default function BackupPanel() {
</button>
<button
onClick={executeRestore}
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: '#dc2626', color: 'white' }}
className="bg-[#dc2626] text-white"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
>
@@ -191,8 +191,8 @@ export default function CategoryManager() {
<div className="bg-white rounded-2xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('categories.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('categories.subtitle')}</p>
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
<p className="text-xs mt-1 text-content-muted">{t('categories.subtitle')}</p>
</div>
<button onClick={handleStartCreate}
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
@@ -20,11 +20,24 @@ type Defaults = {
temperature_unit?: string
dark_mode?: string | boolean
time_format?: string
route_calculation?: boolean
blur_booking_codes?: boolean
map_tile_url?: string
map_provider?: string
mapbox_access_token?: string
mapbox_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' },
]
function OptionRow({
label,
hint,
@@ -36,10 +49,10 @@ function OptionRow({
}) {
return (
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
<label className="block text-sm font-medium mb-2 text-content-secondary">
{label}
</label>
{hint && <p className="text-xs mb-2" style={{ color: 'var(--text-faint)' }}>{hint}</p>}
{hint && <p className="text-xs mb-2 text-content-faint">{hint}</p>}
<div className="flex gap-3 flex-wrap">{children}</div>
</div>
)
@@ -78,11 +91,15 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const [defaults, setDefaults] = useState<Defaults>({})
const [loaded, setLoaded] = useState(false)
const [mapTileUrl, setMapTileUrl] = useState('')
const [mapboxToken, setMapboxToken] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
useEffect(() => {
adminApi.getDefaultUserSettings().then((data: Defaults) => {
setDefaults(data)
setMapTileUrl(data.map_tile_url || '')
setMapboxToken(data.mapbox_access_token || '')
setMapboxStyle(data.mapbox_style || '')
setLoaded(true)
}).catch(() => setLoaded(true))
}, [])
@@ -102,6 +119,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
setDefaults(updated)
if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'mapbox_style') setMapboxStyle('')
toast.success(t('admin.defaultSettings.reset'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error'))
@@ -114,8 +133,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
isSet(field) ? (
<button
onClick={() => reset(field)}
className="text-xs ml-2"
style={{ color: 'var(--text-faint)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer' }}
className="text-xs ml-2 text-content-faint underline"
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
>
{t('admin.defaultSettings.resetToBuiltIn')}
</button>
@@ -131,7 +150,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
lng: 2.3522,
address: null,
category_id: null,
icon: null,
price: null,
currency: null,
image_url: null,
@@ -148,14 +166,14 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
}], [])
if (!loaded) {
return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading</p>
return <p className="text-content-faint" style={{ fontSize: 12, fontStyle: 'italic', padding: 16 }}>Loading</p>
}
const darkMode = defaults.dark_mode
return (
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
<p className="text-sm" style={{ color: 'var(--text-faint)', marginTop: -8 }}>
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>
{t('admin.defaultSettings.description')}
</p>
@@ -208,22 +226,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
))}
</OptionRow>
{/* Route Calculation */}
<OptionRow label={<>{t('settings.routeCalculation')} <ResetButton field="route_calculation" /></>}>
{([
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
] as const).map(opt => (
<OptionButton
key={String(opt.value)}
active={defaults.route_calculation === opt.value}
onClick={() => save({ route_calculation: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Blur Booking Codes */}
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
{([
@@ -242,7 +244,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{/* Map Tile URL */}
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('settings.mapTemplate')}
<ResetButton field="map_tile_url" />
</label>
@@ -262,7 +264,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{t('settings.mapDefaultHint')}</p>
<p className="text-xs mt-1 text-content-faint">{t('settings.mapDefaultHint')}</p>
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{React.createElement(MapView as any, {
@@ -285,6 +287,94 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
})}
</div>
</div>
{/* ── Map provider / instance-wide Mapbox ───────────────────────── */}
<div style={{ borderTop: '1px solid var(--border-primary)', paddingTop: 20, marginTop: 4 }}>
<OptionRow
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
hint={t('admin.defaultSettings.mapProviderHint')}
>
{([
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
] as const).map(opt => (
<OptionButton
key={opt.value}
active={(defaults.map_provider || 'leaflet') === opt.value}
onClick={() => save({ map_provider: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{defaults.map_provider === 'mapbox-gl' && (
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxToken')}
<ResetButton field="mapbox_access_token" />
</label>
<input
type="text"
value={mapboxToken}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxToken(e.target.value)}
onBlur={() => save({ mapbox_access_token: mapboxToken })}
placeholder="pk.eyJ…"
spellCheck={false}
autoComplete="off"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
</div>
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxStyle')}
<ResetButton field="mapbox_style" />
</label>
<CustomSelect
value={mapboxStyle}
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ mapbox_style: value }) } }}
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
options={MAPBOX_STYLE_PRESETS.map(p => ({ value: p.url, label: p.name }))}
size="sm"
style={{ marginBottom: 8 }}
/>
<input
type="text"
value={mapboxStyle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
onBlur={() => save({ mapbox_style: mapboxStyle })}
placeholder="mapbox://styles/mapbox/standard"
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>
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
{([
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
] as const).map(opt => (
<OptionButton key={String(opt.value)} active={(defaults.mapbox_3d_enabled ?? true) === opt.value} onClick={() => save({ mapbox_3d_enabled: opt.value })}>
{opt.label}
</OptionButton>
))}
</OptionRow>
<OptionRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
{([
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
] as const).map(opt => (
<OptionButton key={String(opt.value)} active={(defaults.mapbox_quality_mode ?? false) === opt.value} onClick={() => save({ mapbox_quality_mode: opt.value })}>
{opt.label}
</OptionButton>
))}
</OptionRow>
</div>
)}
</div>
</Section>
)
}
@@ -68,8 +68,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
<button
onClick={onClick}
disabled={sending !== null}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full border-edge bg-surface-card"
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
>
@@ -78,8 +77,8 @@ export default function DevNotificationsPanel(): React.ReactElement {
<Icon className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>{sub}</p>
<p className="text-sm font-medium text-content">{label}</p>
<p className="text-xs truncate text-content-faint">{sub}</p>
</div>
{sending === id && (
<div className="w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin flex-shrink-0" />
@@ -88,15 +87,14 @@ export default function DevNotificationsPanel(): React.ReactElement {
)
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>{children}</h3>
<h3 className="text-sm font-semibold mb-3 text-content-secondary">{children}</h3>
)
const TripSelector = () => (
<select
value={selectedTripId ?? ''}
onChange={e => setSelectedTripId(Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
className="w-full px-3 py-2 rounded-lg border text-sm mb-3 border-edge bg-surface-card text-content"
>
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
</select>
@@ -106,8 +104,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
<select
value={selectedUserId ?? ''}
onChange={e => setSelectedUserId(Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
className="w-full px-3 py-2 rounded-lg border text-sm mb-3 border-edge bg-surface-card text-content"
>
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
</select>
@@ -116,10 +113,10 @@ export default function DevNotificationsPanel(): React.ReactElement {
return (
<div className="space-y-8">
<div className="flex items-center gap-2">
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold" style={{ background: '#fbbf24', color: '#000' }}>
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold bg-[#fbbf24] text-[#000]">
DEV ONLY
</div>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
<span className="text-sm font-medium text-content">
Notification Testing
</span>
</div>
@@ -127,7 +124,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
{/* ── Type Testing ─────────────────────────────────────────────────── */}
<div>
<SectionTitle>Type Testing</SectionTitle>
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
<p className="text-xs mb-3 text-content-muted">
Test how each in-app notification type renders, sent to yourself.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
@@ -175,7 +172,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
{trips.length > 0 && (
<div>
<SectionTitle>Trip-Scoped Events</SectionTitle>
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
<p className="text-xs mb-3 text-content-muted">
Fires each trip event to all members of the selected trip (excluding yourself).
</p>
<TripSelector />
@@ -228,7 +225,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
{users.length > 0 && (
<div>
<SectionTitle>User-Scoped Events</SectionTitle>
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
<p className="text-xs mb-3 text-content-muted">
Fires each user event to the selected recipient.
</p>
<UserSelector />
@@ -266,7 +263,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
<div>
<SectionTitle>Admin-Scoped Events</SectionTitle>
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
<p className="text-xs mb-3 text-content-muted">
Fires to all admin users.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
+64 -69
View File
@@ -9,6 +9,12 @@ const PER_PAGE = 10
interface GithubRelease {
id: number
prerelease: boolean
tag_name: string
name: string | null
body: string | null
published_at: string | null
created_at: string
author: { login: string } | null
[key: string]: unknown
}
@@ -67,7 +73,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
elements.push(
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
{listItems.map((item, i) => (
<li key={i} className="flex gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
<li key={i} className="flex gap-2 text-xs text-content-muted">
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
</li>
@@ -96,14 +102,14 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
if (trimmed.startsWith('### ')) {
flushList()
elements.push(
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1 text-content">
{trimmed.slice(4)}
</h4>
)
} else if (trimmed.startsWith('## ')) {
flushList()
elements.push(
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1 text-content">
{trimmed.slice(3)}
</h3>
)
@@ -112,7 +118,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
} else {
flushList()
elements.push(
<p key={elements.length} className="text-xs my-1" style={{ color: 'var(--text-muted)' }}
<p key={elements.length} className="text-xs my-1 text-content-muted"
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
/>
)
@@ -130,55 +136,52 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://ko-fi.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ff5e5b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Coffee size={20} style={{ color: '#ff5e5b' }} />
<div className="bg-[#ff5e5b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Coffee size={20} className="text-[#ff5e5b]" />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Ko-fi</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
<div className="text-sm font-semibold text-content">Ko-fi</div>
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
<a
href="https://buymeacoffee.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ffdd0015', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Heart size={20} style={{ color: '#ffdd00' }} />
<div className="bg-[#ffdd0015]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Heart size={20} className="text-[#ffdd00]" />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Buy Me a Coffee</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
<div className="text-sm font-semibold text-content">Buy Me a Coffee</div>
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
<a
href="https://discord.gg/NhZBDSd4qW"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#5865F215', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<div className="bg-[#5865F215]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div>
<div className="text-sm font-semibold text-content">Discord</div>
<div className="text-xs text-content-faint">Join the community</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
</div>
@@ -187,85 +190,81 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ef444415', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Bug size={20} style={{ color: '#ef4444' }} />
<div className="bg-[#ef444415]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Bug size={20} className="text-[#ef4444]" />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.reportBug')}</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.reportBugHint')}</div>
<div className="text-sm font-semibold text-content">{t('settings.about.reportBug')}</div>
<div className="text-xs text-content-faint">{t('settings.about.reportBugHint')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
<a
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#f59e0b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Lightbulb size={20} style={{ color: '#f59e0b' }} />
<div className="bg-[#f59e0b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Lightbulb size={20} className="text-[#f59e0b]" />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.featureRequest')}</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.featureRequestHint')}</div>
<div className="text-sm font-semibold text-content">{t('settings.about.featureRequest')}</div>
<div className="text-xs text-content-faint">{t('settings.about.featureRequestHint')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
<a
href="https://github.com/mauriceboe/TREK/wiki"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#6366f115', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<BookOpen size={20} style={{ color: '#6366f1' }} />
<div className="bg-[#6366f115]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<BookOpen size={20} className="text-[#6366f1]" />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Wiki</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.wikiHint')}</div>
<div className="text-sm font-semibold text-content">Wiki</div>
<div className="text-xs text-content-faint">{t('settings.about.wikiHint')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
</div>
{/* Loading / Error / Releases */}
{loading ? (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
<div className="p-8 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--text-muted)' }} />
<Loader2 className="w-6 h-6 animate-spin text-content-muted" />
</div>
</div>
) : error ? (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
<div className="p-6 text-center">
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.github.error')}</p>
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{error}</p>
<p className="text-sm text-content-muted">{t('admin.github.error')}</p>
<p className="text-xs mt-1 text-content-faint">{error}</p>
</div>
</div>
) : (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-secondary)' }}>
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
<div className="px-5 py-4 border-b flex items-center justify-between border-edge-secondary">
<div>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.github.title')}</h2>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
<h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
<p className="text-xs mt-0.5 text-content-faint">{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
</div>
<a
href={`https://github.com/${REPO}/releases`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
>
<ExternalLink size={12} />
GitHub
@@ -299,36 +298,34 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
{/* Release content */}
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
<span className="text-sm font-semibold text-content">
{release.tag_name}
</span>
{isLatest && (
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
style={{ background: 'rgba(34,197,94,0.12)', color: '#16a34a' }}>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(34,197,94,0.12)] text-[#16a34a]">
{t('admin.github.latest')}
</span>
)}
{release.prerelease && (
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
style={{ background: 'rgba(245,158,11,0.12)', color: '#d97706' }}>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(245,158,11,0.12)] text-[#d97706]">
{t('admin.github.prerelease')}
</span>
)}
</div>
{release.name && release.name !== release.tag_name && (
<p className="text-xs font-medium mt-0.5" style={{ color: 'var(--text-muted)' }}>
<p className="text-xs font-medium mt-0.5 text-content-muted">
{release.name}
</p>
)}
<div className="flex items-center gap-3 mt-1">
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-faint)' }}>
<span className="flex items-center gap-1 text-[11px] text-content-faint">
<Calendar size={10} />
{formatDate(release.published_at || release.created_at)}
</span>
{release.author && (
<span className="text-[11px]" style={{ color: 'var(--text-faint)' }}>
<span className="text-[11px] text-content-faint">
{t('admin.github.by')} {release.author.login}
</span>
)}
@@ -339,15 +336,14 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
<div className="mt-2">
<button
onClick={() => toggleExpand(release.id)}
className="flex items-center gap-1 text-[11px] font-medium transition-colors"
style={{ color: 'var(--text-muted)' }}
className="flex items-center gap-1 text-[11px] font-medium transition-colors text-content-muted"
>
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
</button>
{isExpanded && (
<div className="mt-2 p-3 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
<div className="mt-2 p-3 rounded-lg bg-surface-secondary">
{renderBody(release.body)}
</div>
)}
@@ -366,8 +362,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
<button
onClick={handleLoadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
>
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
@@ -500,7 +500,8 @@ describe('PackingTemplateManager', () => {
// Find the X (cancel) button in the create row — it's the last button in the create row
const createRow = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)').closest('div')!;
const cancelBtn = Array.from(createRow.querySelectorAll('button')).at(-1) as HTMLElement;
const createRowButtons = Array.from(createRow.querySelectorAll('button'));
const cancelBtn = createRowButtons[createRowButtons.length - 1] as HTMLElement;
await user.click(cancelBtn);
await waitFor(() =>
@@ -0,0 +1,29 @@
export const CURRENCIES = [
'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
]
export const SYMBOLS: Record<string, string> = {
EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł',
SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$',
NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM',
PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$',
ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD',
HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽',
UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$',
PEN: 'S/.', ARS: 'AR$',
}
export const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
export const SPLIT_COLORS = [
{ solid: '#6366f1', gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)' },
{ solid: '#ec4899', gradient: 'linear-gradient(135deg, #ec4899, #f43f5e)' },
{ solid: '#10b981', gradient: 'linear-gradient(135deg, #10b981, #22c55e)' },
{ solid: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b, #f97316)' },
{ solid: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4, #3b82f6)' },
{ solid: '#a855f7', gradient: 'linear-gradient(135deg, #a855f7, #d946ef)' },
]
@@ -0,0 +1,73 @@
import { currencyDecimals } from '../../utils/formatters'
import { SYMBOLS, SPLIT_COLORS } from './BudgetPanel.constants'
export function widgetTheme(dark: boolean) {
if (dark) return {
bg: 'linear-gradient(180deg, #17171d 0%, #0d0d12 100%)',
border: 'rgba(255,255,255,0.07)',
text: '#ffffff',
sub: 'rgba(255,255,255,0.6)',
faint: 'rgba(255,255,255,0.4)',
track: 'rgba(255,255,255,0.04)',
divider: 'rgba(255,255,255,0.07)',
iconBg: 'rgba(255,255,255,0.08)',
iconBorder: 'rgba(255,255,255,0.12)',
iconColor: 'rgba(255,255,255,0.9)',
centerBg: '#17171d',
flowBg: 'rgba(255,255,255,0.05)',
flowBorder: 'rgba(255,255,255,0.07)',
flowHoverBg: 'rgba(255,255,255,0.08)',
flowHoverBorder: 'rgba(255,255,255,0.12)',
rowHover: 'rgba(255,255,255,0.03)',
shadow: '0 20px 50px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.04)',
donutShadow: 'drop-shadow(0 0 20px rgba(0,0,0,0.3))',
}
return {
bg: 'linear-gradient(180deg, #ffffff 0%, #f9fafb 100%)',
border: 'rgba(15,23,42,0.08)',
text: '#111827',
sub: 'rgba(17,24,39,0.6)',
faint: 'rgba(17,24,39,0.4)',
track: 'rgba(15,23,42,0.05)',
divider: 'rgba(15,23,42,0.08)',
iconBg: 'rgba(15,23,42,0.05)',
iconBorder: 'rgba(15,23,42,0.1)',
iconColor: 'rgba(17,24,39,0.75)',
centerBg: '#ffffff',
flowBg: 'rgba(15,23,42,0.03)',
flowBorder: 'rgba(15,23,42,0.08)',
flowHoverBg: 'rgba(15,23,42,0.06)',
flowHoverBorder: 'rgba(15,23,42,0.14)',
rowHover: 'rgba(15,23,42,0.04)',
shadow: '0 12px 32px rgba(15,23,42,0.08), 0 2px 6px rgba(0,0,0,0.04)',
donutShadow: 'drop-shadow(0 4px 18px rgba(15,23,42,0.12))',
}
}
export function hexLighten(hex: string, amount: number): string {
const m = hex.replace('#', '').match(/.{2}/g)
if (!m || m.length !== 3) return hex
const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount))
const [r, g, b] = m.map(x => parseInt(x, 16))
return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}`
}
export const fmtNum = (v: number | null | undefined, locale: string, cur: string) => {
if (v == null || isNaN(v)) return '-'
const d = currencyDecimals(cur)
return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur)
}
type NumOrNull = number | null | undefined
export const calcPP = (p: NumOrNull, n: NumOrNull) => (n! > 0 ? (p as number) / (n as number) : null)
export const calcPD = (p: NumOrNull, d: NumOrNull) => (d! > 0 ? (p as number) / (d as number) : null)
export const calcPPD = (p: NumOrNull, n: NumOrNull, d: NumOrNull) => (n! > 0 && d! > 0 ? (p as number) / ((n as number) * (d as number)) : null)
export function splitColorFor(userId: number, order: number) {
return SPLIT_COLORS[order % SPLIT_COLORS.length]
}
export function colorForUserId(userId: number) {
return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
}
@@ -66,7 +66,8 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Transport');
// 'Transport' appears in the category section header and the spend breakdown chart.
expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0);
});
it('FE-COMP-BUDGET-006: renders budget table headers', async () => {
@@ -76,7 +77,8 @@ describe('BudgetPanel', () => {
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Name');
await screen.findByText('Total');
// 'Total' appears both as a table header and in the chart total label.
expect((await screen.findAllByText('Total')).length).toBeGreaterThan(0);
});
it('FE-COMP-BUDGET-007: shows Budget title heading', async () => {
@@ -169,8 +171,9 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Transport');
await screen.findByText('Hotels');
// Each category appears in its section header and again in the breakdown chart.
expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0);
expect((await screen.findAllByText('Hotels')).length).toBeGreaterThan(0);
});
it('FE-COMP-BUDGET-015: currency from settings store is used for default_currency display', async () => {
@@ -200,7 +203,8 @@ describe('BudgetPanel', () => {
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('ToDelete');
// 'ToDelete' appears in the category header and the breakdown chart.
expect((await screen.findAllByText('ToDelete')).length).toBeGreaterThan(0);
expect(screen.getByTitle('Delete Category')).toBeInTheDocument();
});
@@ -390,7 +394,7 @@ describe('BudgetPanel', () => {
const item = {
...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Shared Dinner' }),
total_price: 75,
members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: false }],
members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: 0 }],
};
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
@@ -425,7 +429,7 @@ describe('BudgetPanel', () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
// Use a user with id != 1 so they're not the owner
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
@@ -439,7 +443,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
@@ -484,7 +488,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
import { useState, useRef } from 'react'
import { Plus } from 'lucide-react'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
interface AddItemRowProps {
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void
t: (key: string) => string
}
export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
const [name, setName] = useState('')
const [price, setPrice] = useState('')
const [persons, setPersons] = useState('')
const [days, setDays] = useState('')
const [note, setNote] = useState('')
const [expenseDate, setExpenseDate] = useState('')
const nameRef = useRef<HTMLInputElement>(null)
const handleAdd = () => {
if (!name.trim()) return
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
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)' }
return (
<tr className="bg-surface-secondary">
<td style={{ padding: '4px 6px' }}>
<input ref={nameRef} value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder={t('budget.newEntry')} style={inp} />
</td>
<td style={{ padding: '4px 6px' }}>
<input value={price} onChange={e => setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } setPrice(t) }}
placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} />
</td>
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
<input value={persons} onChange={e => setPersons(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 sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
<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 sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
<div style={{ maxWidth: 90, margin: '0 auto' }}>
<CustomDatePicker value={expenseDate} onChange={setExpenseDate} placeholder="-" compact />
</div>
</td>
<td className="hidden sm:table-cell" style={{ padding: '4px 6px' }}>
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
</td>
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
<button onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
style={{ background: name.trim() ? 'var(--text-primary)' : 'var(--border-primary)', border: 'none', borderRadius: 4, color: 'var(--bg-primary)',
cursor: name.trim() ? 'pointer' : 'default', padding: '4px 8px', display: 'inline-flex', alignItems: 'center' }}>
<Plus size={14} />
</button>
</td>
</tr>
)
}
@@ -0,0 +1,258 @@
import type { CSSProperties, Dispatch, SetStateAction } from 'react'
import { Trash2, Pencil, GripVertical } from 'lucide-react'
import type { BudgetItem } from '../../types'
import { currencyDecimals } from '../../utils/formatters'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import { calcPP, calcPD, calcPPD } from './BudgetPanel.helpers'
import InlineEditCell from './BudgetPanelInlineEditCell'
import AddItemRow from './BudgetPanelAddItemRow'
import BudgetMemberChips, { type TripMember } from './BudgetPanelMemberChips'
import type { EditingCat, AddItemData } from './useBudgetPanel'
interface BudgetCategoryTableProps {
cat: string
grouped: Map<string, BudgetItem[]>
categoryColor: (cat: string) => string
canEdit: boolean
editingCat: EditingCat | null
setEditingCat: Dispatch<SetStateAction<EditingCat | null>>
dragCat: string | null
setDragCat: Dispatch<SetStateAction<string | null>>
dragOverCat: string | null
setDragOverCat: Dispatch<SetStateAction<string | null>>
dragItem: number | null
setDragItem: Dispatch<SetStateAction<number | null>>
dragOverItem: number | null
setDragOverItem: Dispatch<SetStateAction<number | null>>
dragItemCat: string | null
setDragItemCat: Dispatch<SetStateAction<string | null>>
categoryNames: string[]
reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => Promise<void>
reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise<void>
handleRenameCategory: (oldName: string, newName: string) => Promise<void>
handleDeleteCategory: (cat: string) => Promise<void>
handleDeleteItem: (id: number) => Promise<void>
handleUpdateField: (id: number, field: string, value: unknown) => Promise<void>
handleAddItem: (category: string, data: AddItemData) => Promise<void>
tripId: number
currency: string
locale: string
t: (key: string) => string
fmt: (v: number | null | undefined, cur: string) => string
hasMultipleMembers: boolean
tripMembers: TripMember[]
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: unknown; item: unknown }>
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
th: CSSProperties
td: CSSProperties
}
export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEdit, editingCat, setEditingCat,
dragCat, setDragCat, dragOverCat, setDragOverCat, dragItem, setDragItem, dragOverItem, setDragOverItem,
dragItemCat, setDragItemCat, categoryNames, reorderBudgetCategories, reorderBudgetItems,
handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleUpdateField, handleAddItem,
tripId, currency, locale, t, fmt, hasMultipleMembers, tripMembers, setBudgetItemMembers, toggleBudgetMemberPaid, th, td }: BudgetCategoryTableProps) {
const items = grouped.get(cat) || []
const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
const color = categoryColor(cat)
return (
<div key={cat} data-drag-cat={cat} style={{
marginBottom: 16, opacity: dragCat === cat ? 0.4 : 1,
transition: 'opacity 0.15s',
position: 'relative',
}}
onDragOver={e => {
if (!dragCat || dragCat === cat || dragItem) return
e.preventDefault(); e.dataTransfer.dropEffect = 'move'
setDragOverCat(cat)
}}
onDragLeave={e => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverCat(null)
}}
onDrop={e => {
e.preventDefault()
if (dragCat && dragCat !== cat) {
const newOrder = [...categoryNames]
const fromIdx = newOrder.indexOf(dragCat)
const toIdx = newOrder.indexOf(cat)
newOrder.splice(fromIdx, 1)
newOrder.splice(toIdx, 0, dragCat)
reorderBudgetCategories(tripId, newOrder)
}
setDragCat(null); setDragOverCat(null)
}}
>
{dragOverCat === cat && <div style={{ position: 'absolute', top: -2, left: 0, right: 0, height: 4, background: 'var(--accent)', borderRadius: 2, zIndex: 10 }} />}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff',
borderRadius: '10px 10px 0 0', padding: '9px 14px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
{canEdit && (
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/x-budget-cat', cat); setDragCat(cat) }}
onDragEnd={() => { setDragCat(null); setDragOverCat(null) }}
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
<GripVertical size={14} />
</div>
)}
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
{canEdit && editingCat?.name === cat ? (
<input
autoFocus
value={editingCat.value}
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%' }}
/>
) : (
<>
<span style={{ fontWeight: 600, fontSize: 13 }}>{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 }}
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
<Pencil size={10} />
</button>
)}
</>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 13, 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 }}
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
<Trash2 size={13} />
</button>
)}
</div>
</div>
<div style={{ overflowX: 'auto', border: '1px solid var(--border-primary)', borderTop: 'none', borderRadius: '0 0 10px 10px' }}
onDragOver={e => { if (dragCat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ ...th, textAlign: 'left', minWidth: 120 }}>{t('budget.table.name')}</th>
<th style={{ ...th, minWidth: 75 }}>{t('budget.table.total')}</th>
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 160 }}>{t('budget.table.persons')}</th>
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 55 }}>{t('budget.table.days')}</th>
<th className="hidden md:table-cell" style={{ ...th, minWidth: 100 }}>{t('budget.table.perPerson')}</th>
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perDay')}</th>
<th className="hidden lg:table-cell" style={{ ...th, minWidth: 95 }}>{t('budget.table.perPersonDay')}</th>
<th className="hidden sm:table-cell" style={{ ...th, width: 90, maxWidth: 90 }}>{t('budget.table.date')}</th>
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 150 }}>{t('budget.table.note')}</th>
<th style={{ ...th, width: 36 }}></th>
</tr>
</thead>
<tbody>
{items.map(item => {
const pp = calcPP(item.total_price, item.persons)
const pd = calcPD(item.total_price, item.days)
const ppd = calcPPD(item.total_price, item.persons, item.days)
const hasMembers = (item.members?.length ?? 0) > 0
return (
<tr key={item.id}
style={{
transition: 'background 0.1s, opacity 0.15s',
opacity: dragItem === item.id ? 0.4 : 1,
boxShadow: dragOverItem === item.id ? 'inset 4px 0 0 0 var(--accent)' : 'none',
}}
onDragOver={e => {
if (dragCat && dragCat !== cat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; return }
if (dragItem && dragItemCat === cat && dragItem !== item.id) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverItem(item.id) }
}}
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverItem(null) }}
onDrop={e => {
if (dragItem && dragItemCat === cat && dragItem !== item.id) {
e.preventDefault(); e.stopPropagation()
const ids = items.map(i => i.id)
const fromIdx = ids.indexOf(dragItem)
const toIdx = ids.indexOf(item.id)
ids.splice(fromIdx, 1)
ids.splice(toIdx, 0, dragItem)
reorderBudgetItems(tripId, ids)
setDragItem(null); setDragOverItem(null); setDragItemCat(null)
}
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<td style={td}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{canEdit && (
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }}
onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }}
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}>
<GripVertical size={12} />
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} />
{hasMultipleMembers && (
<div className="sm:hidden" style={{ marginTop: 4 }}>
<BudgetMemberChips
members={item.members || []}
tripMembers={tripMembers}
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
compact={false}
readOnly={!canEdit}
/>
</div>
)}
</div>
</div>
</td>
<td style={{ ...td, textAlign: 'center' }}>
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
</td>
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
{hasMultipleMembers ? (
<BudgetMemberChips
members={item.members || []}
tripMembers={tripMembers}
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
readOnly={!canEdit}
/>
) : (
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
)}
</td>
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
</td>
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td>
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td>
<td className="hidden lg:table-cell" style={{ ...td, textAlign: 'center', color: ppd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{ppd != null ? fmt(ppd, currency) : '-'}</td>
<td className="hidden sm:table-cell" style={{ ...td, padding: '2px 6px', width: 90, maxWidth: 90, textAlign: 'center' }}>
{canEdit ? (
<div style={{ maxWidth: 90, margin: '0 auto' }}>
<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>
)}
</td>
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td>
<td style={{ ...td, textAlign: 'center' }}>
{canEdit && (
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
<Trash2 size={14} />
</button>
)}
</td>
</tr>
)
})}
{canEdit && <AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,71 @@
import { useState, useEffect, useRef } from 'react'
interface InlineEditCellProps {
value: string | number | null | undefined
onSave: (value: string | number | null) => void
type?: 'text' | 'number'
style?: React.CSSProperties
placeholder?: string
decimals?: number
locale: string
editTooltip?: string
readOnly?: boolean
}
export default function InlineEditCell({ value, onSave, type = 'text', style = {} as React.CSSProperties, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }: InlineEditCellProps) {
const [editing, setEditing] = useState(false)
const [editValue, setEditValue] = useState<string | number>(value ?? '')
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing])
const save = () => {
setEditing(false)
let v: string | number | null = editValue
if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p }
if (v !== value) onSave(v)
}
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
if (type !== 'number') return
e.preventDefault()
let text = e.clipboardData.getData('text').trim()
// Strip everything except digits, dots, commas, minus
text = text.replace(/[^\d.,-]/g, '')
// Remove all thousand separators (dots or commas before 3-digit groups), keep last separator as decimal
const lastComma = text.lastIndexOf(',')
const lastDot = text.lastIndexOf('.')
const decimalPos = Math.max(lastComma, lastDot)
if (decimalPos > -1) {
const intPart = text.substring(0, decimalPos).replace(/[.,]/g, '')
const decPart = text.substring(decimalPos + 1)
text = intPart + '.' + decPart
} else {
text = text.replace(/[.,]/g, '')
}
setEditValue(text)
}
if (editing) {
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 }}
placeholder={placeholder} />
}
const display = type === 'number' && value != null
? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
: (value || '')
return (
<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 }}
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
{display || placeholder || '-'}
</div>
)
}
@@ -0,0 +1,179 @@
import ReactDOM from 'react-dom'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Pencil, Users, Check } from 'lucide-react'
import type { BudgetItemMember } from '../../types'
export interface TripMember {
id: number
username: string
avatar_url?: string | null
}
// ── Chip with custom tooltip ─────────────────────────────────────────────────
interface ChipWithTooltipProps {
label: string
avatarUrl: string | null
size?: number
paid?: boolean
onClick?: () => void
}
export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) {
const [hover, setHover] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const ref = useRef<HTMLDivElement>(null)
const onEnter = () => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect()
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
}
setHover(true)
}
const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
return (
<>
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
onClick={onClick}
style={{
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
transition: 'border-color 0.15s, background 0.15s',
}}>
{avatarUrl
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: label?.[0]?.toUpperCase()
}
</div>
{hover && ReactDOM.createPortal(
<div style={{
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
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,
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,
background: 'rgba(34,197,94,0.15)', color: '#16a34a',
textTransform: 'uppercase', letterSpacing: '0.03em',
}}>Paid</span>
)}
</div>,
document.body
)}
</>
)
}
// ── Budget Member Chips (for Persons column) ────────────────────────────────
interface BudgetMemberChipsProps {
members?: BudgetItemMember[]
tripMembers?: TripMember[]
onSetMembers: (memberIds: number[]) => void
onTogglePaid?: (userId: number, paid: boolean) => void
compact?: boolean
readOnly?: boolean
}
export default function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) {
const chipSize = compact ? 20 : 30
const btnSize = compact ? 18 : 28
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
const [showDropdown, setShowDropdown] = useState(false)
const [dropPos, setDropPos] = useState({ top: 0, left: 0 })
const btnRef = useRef<HTMLButtonElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
const openDropdown = useCallback(() => {
if (btnRef.current) {
const rect = btnRef.current.getBoundingClientRect()
setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 })
}
setShowDropdown(v => !v)
}, [])
useEffect(() => {
if (!showDropdown) return
const close = (e: MouseEvent) => {
if (dropRef.current && dropRef.current.contains(e.target as Node)) return
if (btnRef.current && btnRef.current.contains(e.target as Node)) return
setShowDropdown(false)
}
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [showDropdown])
const memberIds = members.map(m => m.user_id)
const toggleMember = (userId: number) => {
const newIds = memberIds.includes(userId)
? memberIds.filter(id => id !== userId)
: [...memberIds, userId]
onSetMembers(newIds)
}
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
{members.map(m => (
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize}
paid={!!m.paid}
onClick={!readOnly && onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
/>
))}
{!readOnly && (
<button ref={btnRef} onClick={openDropdown}
style={{
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
}}>
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
</button>
)}
{showDropdown && ReactDOM.createPortal(
<div ref={dropRef} style={{
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 150,
}}>
{tripMembers.map(tm => {
const isActive = memberIds.includes(tm.id)
return (
<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',
}}
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,
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
}}>
{tm.avatar_url
? <img src={tm.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: tm.username?.[0]?.toUpperCase()
}
</div>
<span style={{ flex: 1 }}>{tm.username}</span>
{isActive && <Check size={12} color="var(--text-primary)" />}
</button>
)
})}
</div>,
document.body
)}
</div>
)
}
@@ -0,0 +1,64 @@
import { useState, useEffect } from 'react'
import { budgetApi } from '../../api/client'
import type { BudgetItem } from '../../types'
import { fmtNum, colorForUserId, widgetTheme } from './BudgetPanel.helpers'
import RingAvatar from './BudgetPanelRingAvatar'
interface PerPersonSummaryEntry {
user_id: number
username: string
avatar_url: string | null
total_assigned: number
}
interface PerPersonInlineProps {
tripId: number
budgetItems: BudgetItem[]
currency: string
locale: string
}
export default function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, theme }: PerPersonInlineProps & { grandTotal: number; theme: ReturnType<typeof widgetTheme> }) {
const [data, setData] = useState<PerPersonSummaryEntry[] | null>(null)
const fmt = (v: number) => fmtNum(v, locale, currency)
useEffect(() => {
budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
}, [tripId, budgetItems])
if (!data || data.length === 0) return null
const people = data.map(p => ({ ...p, color: colorForUserId(p.user_id) }))
return (
<>
{grandTotal > 0 && (
<div style={{ display: 'flex', height: 6, borderRadius: 999, overflow: 'hidden', marginTop: 8, marginBottom: 4, gap: 3 }}>
{people.map(p => (
<div key={p.user_id} style={{
height: '100%', borderRadius: 999,
flex: Math.max(p.total_assigned || 0, 0.01),
background: p.color.gradient,
}} />
))}
</div>
)}
<div style={{ marginTop: 14, paddingTop: 14, borderTop: `1px solid ${theme.divider}`, display: 'flex', flexDirection: 'column', gap: 2 }}>
{people.map(p => {
const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0
return (
<div key={p.user_id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '6px 0' }}>
<RingAvatar userId={p.user_id} username={p.username} avatarUrl={p.avatar_url} size={34} innerBg={theme.centerBg} textColor={theme.text} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text }}>{p.username}</div>
<div style={{ fontSize: 11, color: theme.faint, marginTop: 1 }}>{percent}%</div>
</div>
<div style={{ fontSize: 13.5, fontWeight: 600, color: theme.text, letterSpacing: '-0.01em' }}>{fmt(p.total_assigned)}</div>
</div>
)
})}
</div>
</>
)
}
@@ -0,0 +1,53 @@
import { Wallet } from 'lucide-react'
interface PieSegment {
label: string
value: number
color: string
}
// ── Pie Chart (pure CSS conic-gradient) ──────────────────────────────────────
interface PieChartProps {
segments: PieSegment[]
size?: number
totalLabel: string
}
export default function PieChart({ segments, size = 200, totalLabel }: PieChartProps) {
if (!segments.length) return null
const total = segments.reduce((s, x) => s + x.value, 0)
if (total === 0) return null
let cumDeg = 0
const stops = segments.map(seg => {
const start = cumDeg
const deg = (seg.value / total) * 360
cumDeg += deg
return `${seg.color} ${start}deg ${start + deg}deg`
}).join(', ')
return (
<div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
<div
className="trek-pie-reveal"
style={{
width: size, height: size, borderRadius: '50%',
background: `conic-gradient(${stops})`,
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
}}
/>
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: size * 0.55, height: size * 0.55,
borderRadius: '50%', background: 'var(--bg-card)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
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>
</div>
</div>
)
}
@@ -0,0 +1,22 @@
import { colorForUserId } from './BudgetPanel.helpers'
export default function RingAvatar({ userId, username, avatarUrl, size = 34, innerBg = '#17171d', textColor = '#fff' }: { userId: number; username?: string; avatarUrl?: string | null; size?: number; innerBg?: string; textColor?: string }) {
const color = colorForUserId(userId)
return (
<div style={{
width: size, height: size, borderRadius: '50%', flexShrink: 0,
padding: 2, background: color.gradient,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{
width: '100%', height: '100%', borderRadius: '50%',
background: innerBg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
fontSize: size < 28 ? 10 : 12, fontWeight: 600, color: textColor,
}}>
{avatarUrl ? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : username?.[0]?.toUpperCase()}
</div>
</div>
)
}
@@ -0,0 +1,280 @@
import type { Dispatch, SetStateAction } from 'react'
import { Wallet, Info, ChevronDown, ChevronRight, TrendingUp, TrendingDown, PieChart as PieChartIcon } from 'lucide-react'
import type { BudgetItem } from '../../types'
import { currencyDecimals } from '../../utils/formatters'
import { SYMBOLS } from './BudgetPanel.constants'
import { hexLighten, widgetTheme } from './BudgetPanel.helpers'
import RingAvatar from './BudgetPanelRingAvatar'
import PerPersonInline from './BudgetPanelPerPersonInline'
import type { SettlementData, PieSegment } from './useBudgetPanel'
interface BudgetSummaryProps {
theme: ReturnType<typeof widgetTheme>
currency: string
locale: string
grandTotal: number
hasMultipleMembers: boolean
budgetItems: BudgetItem[]
settlement: SettlementData | null
settlementOpen: boolean
setSettlementOpen: Dispatch<SetStateAction<boolean>>
pieSegments: PieSegment[]
isDark: boolean
tripId: number
t: (key: string) => string
fmt: (v: number | null | undefined, cur: string) => string
}
export default function BudgetSummary({ theme, currency, locale, grandTotal, hasMultipleMembers, budgetItems,
settlement, settlementOpen, setSettlementOpen, pieSegments, isDark, tripId, t, fmt }: BudgetSummaryProps) {
return (
<div className="w-full md:w-[320px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
<div style={{
background: theme.bg,
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
border: `1px solid ${theme.border}`,
boxShadow: theme.shadow,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
<div style={{
width: 40, height: 40, borderRadius: 12,
background: theme.iconBg,
border: `1px solid ${theme.iconBorder}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: theme.iconColor, flexShrink: 0,
}}>
<Wallet size={20} strokeWidth={2} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</div>
</div>
</div>
{(() => {
const decimals = currencyDecimals(currency)
const full = Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
const sep = (0.1).toLocaleString(locale).replace(/\d/g, '')
const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, '']
return (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}>
<span style={{ fontSize: 38, fontWeight: 700 }}>{integerPart}</span>
{decimalPart && <span style={{ fontSize: 22, fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>}
<span style={{ fontSize: 22, fontWeight: 500, color: theme.sub, marginLeft: 2 }}>{SYMBOLS[currency] || currency}</span>
</div>
)
})()}
<div style={{ color: theme.faint, fontSize: 12, marginTop: 8, fontWeight: 500, letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 6 }}>
<span>{currency}</span>
</div>
{hasMultipleMembers && (budgetItems || []).some(i => (i.members?.length ?? 0) > 0) && (
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} grandTotal={grandTotal} theme={theme} />
)}
{/* Settlement dropdown inside the total card */}
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
<div style={{ marginTop: 16, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
<button onClick={() => setSettlementOpen(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
color: theme.sub, fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
}}>
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{t('budget.settlement')}
<span style={{ position: 'relative', display: 'inline-flex', marginLeft: 2 }}>
<span style={{ display: 'flex', cursor: 'help' }}
onMouseEnter={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'block' }}
onMouseLeave={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'none' }}
onClick={e => e.stopPropagation()}
>
<Info size={11} strokeWidth={2} />
</span>
<div style={{
display: 'none', position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
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',
}}>
{t('budget.settlementInfo')}
</div>
</span>
</button>
{settlementOpen && (
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
{settlement.flows.map((flow, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '12px 14px', borderRadius: 14,
background: theme.flowBg,
border: `1px solid ${theme.flowBorder}`,
transition: 'all 0.2s',
}}
onMouseEnter={e => { e.currentTarget.style.background = theme.flowHoverBg; e.currentTarget.style.borderColor = theme.flowHoverBorder }}
onMouseLeave={e => { e.currentTarget.style.background = theme.flowBg; e.currentTarget.style.borderColor = theme.flowBorder }}
>
<RingAvatar userId={flow.from.user_id} username={flow.from.username} avatarUrl={flow.from.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}>
{fmt(flow.amount, currency)}
</span>
<div style={{ width: '100%', height: 2, borderRadius: 2, background: 'linear-gradient(90deg, rgba(239,68,68,0.1), rgba(239,68,68,0.55), rgba(239,68,68,0.3))', position: 'relative' }}>
<div style={{ position: 'absolute', right: -1, top: '50%', transform: 'translateY(-50%)', width: 0, height: 0, borderLeft: '6px solid rgba(239,68,68,0.55)', borderTop: '4px solid transparent', borderBottom: '4px solid transparent' }} />
</div>
</div>
<RingAvatar userId={flow.to.user_id} username={flow.to.username} avatarUrl={flow.to.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
</div>
))}
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
<div style={{ marginTop: 8, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}>
{t('budget.netBalances')}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => {
const positive = b.balance > 0
const Trend = positive ? TrendingUp : TrendingDown
return (
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 0' }}>
<RingAvatar userId={b.user_id} username={b.username} avatarUrl={b.avatar_url} size={26} innerBg={theme.centerBg} textColor={theme.text} />
<span style={{ flex: 1, fontSize: 13, color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{b.username}
</span>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '4px 10px', borderRadius: 8,
fontSize: 12, fontWeight: 700, letterSpacing: '-0.01em',
background: positive ? 'rgba(16,185,129,0.13)' : 'rgba(239,68,68,0.13)',
color: positive ? '#10b981' : '#ef4444',
}}>
<Trend size={11} strokeWidth={3} />
{positive ? '+' : ''}{fmt(b.balance, currency)}
</span>
</div>
)
})}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
{pieSegments.length > 0 && (() => {
const decimals = currencyDecimals(currency)
const total = pieSegments.reduce((s, x) => s + x.value, 0)
const totalFmt = Number(total).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
const decimalSep = (0.1).toLocaleString(locale).replace(/\d/g, '')
const [totalInt, totalDec] = decimals > 0 ? totalFmt.split(decimalSep) : [totalFmt, '']
const R = 80
const CIRC = 2 * Math.PI * R
let dashOffset = 0
return (
<div style={{
background: theme.bg,
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
border: `1px solid ${theme.border}`,
boxShadow: theme.shadow,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
<div style={{
width: 38, height: 38, borderRadius: 11,
background: theme.iconBg,
border: `1px solid ${theme.iconBorder}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: theme.iconColor, flexShrink: 0,
}}>
<PieChartIcon size={18} strokeWidth={2} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div>
</div>
</div>
<div style={{ position: 'relative', display: 'flex', justifyContent: 'center', margin: '4px 0 16px' }}>
<svg width={200} height={200} viewBox="0 0 200 200" style={{ transform: 'rotate(-90deg)', filter: theme.donutShadow }}>
<defs>
{pieSegments.map((seg, i) => {
const c2 = hexLighten(seg.color, 0.2)
return (
<linearGradient key={`grad-${i}`} id={`cat-grad-${i}`} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor={seg.color} />
<stop offset="100%" stopColor={c2} />
</linearGradient>
)
})}
</defs>
<circle cx={100} cy={100} r={R} fill="none" stroke={theme.track} strokeWidth={22} />
{pieSegments.map((seg, i) => {
const segLen = total > 0 ? (seg.value / total) * CIRC : 0
const circle = (
<circle key={i}
cx={100} cy={100} r={R}
fill="none" strokeLinecap="round" strokeWidth={22}
stroke={`url(#cat-grad-${i})`}
strokeDasharray={`${segLen} ${CIRC}`}
strokeDashoffset={-dashOffset}
/>
)
dashOffset += segLen
return circle
})}
</svg>
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, pointerEvents: 'none' }}>
<div style={{ fontSize: 10.5, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{t('budget.total')}</div>
<div style={{ fontSize: 22, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
<span>{totalInt}</span>
{totalDec && <span style={{ fontSize: 13, fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
</div>
<div style={{ fontSize: 10.5, color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div>
</div>
</div>
<div style={{ borderTop: `1px solid ${theme.divider}`, paddingTop: 10, display: 'flex', flexDirection: 'column', gap: 2 }}>
{pieSegments.map((seg, i) => {
const pct = total > 0 ? (seg.value / total) * 100 : 0
const pctLabel = pct.toFixed(1).replace('.', decimalSep) + '%'
const c2 = hexLighten(seg.color, 0.2)
const chipColor = isDark ? hexLighten(seg.color, 0.35) : seg.color
return (
<div key={seg.name} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 8px', borderRadius: 12,
transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = theme.rowHover}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<div style={{
width: 10, height: 10, borderRadius: 3, flexShrink: 0,
background: `linear-gradient(135deg, ${seg.color}, ${c2})`,
boxShadow: `0 0 12px ${seg.color}80`,
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{seg.name}</div>
<div style={{ fontSize: 11.5, color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div>
</div>
<span style={{
flexShrink: 0,
padding: '4px 9px', borderRadius: 7,
fontSize: 11, fontWeight: 700, letterSpacing: '-0.01em',
background: `${seg.color}26`,
border: `1px solid ${seg.color}40`,
color: chipColor,
}}>{pctLabel}</span>
</div>
)
})}
</div>
</div>
)
})()}
</div>
)
}
+814
View File
@@ -0,0 +1,814 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, Check, RotateCcw, History, Pencil, Trash2 } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { budgetApi } from '../../api/client'
import { useExchangeRates } from '../../hooks/useExchangeRates'
import { useIsMobile } from '../../hooks/useIsMobile'
import { formatMoney, currencyDecimals, currencyLocale } from '../../utils/formatters'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
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'
interface CostsPanelProps {
tripId: number
tripMembers?: TripMember[]
}
interface Settlement {
id: number
from_user_id: number
to_user_id: number
amount: number
created_at?: string
from_username?: string
to_username?: string
}
interface SettlementData {
balances: { user_id: number; username: string; avatar_url: string | null; balance: number }[]
flows: { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }[]
settlements: Settlement[]
}
const round2 = (n: number) => Math.round(n * 100) / 100
const FIELD_H = 40 // shared height for the amount / currency / day row in the modal
export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps) {
const { trip, budgetItems, deleteBudgetItem, loadBudgetItems } = useTripStore()
const me = useAuthStore(s => s.user?.id ?? -1)
const can = useCanDo()
const canEdit = can('budget_edit', trip)
const toast = useToast()
const { t, locale } = useTranslation()
const isMobile = useIsMobile()
// Display/base currency = the user's preferred currency (Settings), falling back
// to the trip's own currency. Everything in Costs is converted to and shown in it.
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
// Pre-rework rows stored currency = NULL, meaning "the trip's own currency".
const tripCurrency = (trip?.currency || base).toUpperCase()
const { convert } = useExchangeRates(base)
const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency])
const [settlement, setSettlement] = useState<SettlementData | null>(null)
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
const [search, setSearch] = useState('')
const [histOpen, setHistOpen] = useState(false)
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<BudgetItem | null>(null)
const people = tripMembers
const personById = useCallback((id: number) => people.find(p => p.id === id), [people])
const personName = useCallback((id: number) => id === me ? t('costs.you') : (personById(id)?.username || '?'), [me, personById, t])
const colorFor = useCallback((id: number) => {
const idx = people.findIndex(p => p.id === id)
return SPLIT_COLORS[(idx >= 0 ? idx : 0) % SPLIT_COLORS.length].gradient
}, [people])
const initial = useCallback((id: number) => id === me ? t('costs.youShort') : (personById(id)?.username || '?').charAt(0).toUpperCase(), [me, personById, t])
const fmt = useCallback((v: number, c = base) => formatMoney(v, c, locale), [base, locale])
const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale])
const loadSettlement = useCallback(() => {
budgetApi.settlement(tripId, base).then(setSettlement).catch(() => {})
}, [tripId, base])
useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId])
useEffect(() => { loadSettlement() }, [budgetItems.length, base])
// The bottom-nav "+" on the Costs tab opens the add-expense modal via ?create=expense.
const [searchParams, setSearchParams] = useSearchParams()
useEffect(() => {
if (searchParams.get('create') === 'expense') {
setEditing(null); setModalOpen(true)
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
}
}, [searchParams])
// ── derived expense maths (everything converted to the base currency) ────
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 totals = useMemo(() => {
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
const myPaid = budgetItems.reduce((a, e) => a + myPaidOf(e), 0)
const myShare = budgetItems.reduce((a, e) => a + myShareOf(e), 0)
const owe = (settlement?.flows || []).filter(f => f.from.user_id === me).reduce((a, f) => a + f.amount, 0)
const owed = (settlement?.flows || []).filter(f => f.to.user_id === me).reduce((a, f) => a + f.amount, 0)
return { totalSpend, myPaid, myShare, owe, owed }
}, [budgetItems, settlement, me])
// ── filtering + day grouping ────────────────────────────────────────────
const filtered = useMemo(() => {
let list = budgetItems.slice()
if (filter === 'mine') list = list.filter(e => myPaidOf(e) > 0)
if (filter === 'owed') list = list.filter(e => round2(myPaidOf(e) - myShareOf(e)) > 0)
const q = search.trim().toLowerCase()
if (q) list = list.filter(e => e.name.toLowerCase().includes(q))
return list
}, [budgetItems, filter, search, me])
const dayGroups = useMemo(() => {
const groups: { day: string; items: BudgetItem[] }[] = []
const labelOf = (e: BudgetItem) => {
if (!e.expense_date) return t('costs.noDate')
try { return new Date(e.expense_date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return e.expense_date }
}
const sorted = filtered.slice().sort((a, b) => (b.expense_date || '').localeCompare(a.expense_date || ''))
for (const e of sorted) {
const day = labelOf(e)
let g = groups.find(x => x.day === day)
if (!g) { g = { day, items: [] }; groups.push(g) }
g.items.push(e)
}
return groups
}, [filtered, locale, t])
// ── settle actions ──────────────────────────────────────────────────────
const settleFlow = async (fromId: number, toId: number, amount: number) => {
try {
await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount })
loadSettlement()
} catch { toast.error(t('common.unknownError')) }
}
const undoSettlement = async (id: number) => {
try { await budgetApi.deleteSettlement(tripId, id); loadSettlement() } catch { toast.error(t('common.unknownError')) }
}
const settleAll = async () => {
const flows = settlement?.flows || []
if (!flows.length) return
try {
for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount })
loadSettlement()
} catch { toast.error(t('common.unknownError')) }
}
const dateMeta = useMemo(() => {
if (!trip?.start_date || !trip?.end_date) return null
try {
const s = new Date(trip.start_date + 'T00:00:00Z'), e = new Date(trip.end_date + 'T00:00:00Z')
const days = Math.round((e.getTime() - s.getTime()) / 86400000) + 1
const opt = { day: 'numeric', month: 'short', timeZone: 'UTC' } as const
return { range: `${s.toLocaleDateString(locale, opt)} ${e.toLocaleDateString(locale, opt)}`, days }
} catch { return null }
}, [trip?.start_date, trip?.end_date, locale])
const handleDelete = async (id: number) => {
try { await deleteBudgetItem(tripId, id); loadSettlement() } catch { toast.error(t('common.unknownError')) }
}
// ── small presentational helpers ────────────────────────────────────────
const Avatar = ({ id, size = 24 }: { id: number; size?: number }) => {
const url = personById(id)?.avatar_url
if (url) return <img src={url} alt="" style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0, display: 'block' }} />
return <span style={{ width: size, height: size, borderRadius: '50%', background: colorFor(id), color: '#fff', display: 'grid', placeItems: 'center', fontSize: size * 0.4, fontWeight: 700, flexShrink: 0 }}>{initial(id)}</span>
}
const cardCls = 'bg-surface-card border border-edge'
const labelCls = 'text-[11px] font-semibold uppercase tracking-[0.12em] text-content-faint'
// Big money number with the design's muted symbol/decimals, locale-correct via Intl.
const bigMoney = (amount: number, smallSize: number, mutedColor: string) => {
let parts: Intl.NumberFormatPart[] | null = null
try {
const d = currencyDecimals(base)
parts = new Intl.NumberFormat(currencyLocale(base), { style: 'currency', currency: base, minimumFractionDigits: d, maximumFractionDigits: d }).formatToParts(amount || 0)
} catch { return <>{formatMoney(amount, base, locale)}</> }
const isBig = (p: Intl.NumberFormatPart) => p.type === 'integer' || p.type === 'group' || p.type === 'minusSign'
return <>{parts.map((p, i) => <span key={i} style={isBig(p) ? undefined : { fontSize: smallSize, fontWeight: 500, color: mutedColor }}>{p.value}</span>)}</>
}
return (
<div className="costs-root" style={{ minHeight: '100%', background: 'var(--c-bg)', padding: isMobile ? '6px 14px 28px' : '40px 24px 48px' }}>
{isMobile ? <MobileBody /> : (
<div style={{ maxWidth: '100%', margin: '0 auto' }}>
{/* ── Header bar ── */}
<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' }}>
{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 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>
<b className="text-content">{t('costs.travelers', { count: people.length })}</b>
</span>
</div>
{canEdit && (
<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' }}>
<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' }}>
<Plus size={16} /> {t('costs.addExpense')}
</button>
</div>
)}
</div>
{/* ── Summary cards ── */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1.15fr', gap: 16, marginBottom: 36 }} className="costs-summary">
<SummaryCard label={t('costs.youOwe')} sub={t('costs.youOweSub')} amount={totals.owe} currency={base} locale={locale}
icon={<ArrowDown size={18} />} tone="owe"
foot={totals.owe > 0.01
? <FlowPills ids={(settlement?.flows || []).filter(f => f.from.user_id === me).map(f => f.to.user_id)} lead={t('costs.to')} Avatar={Avatar} name={personName} />
: <span className="text-content-faint">{t('costs.allSettled')}</span>} />
<SummaryCard label={t('costs.youreOwed')} sub={t('costs.youreOwedSub')} amount={totals.owed} currency={base} locale={locale}
icon={<ArrowUp size={18} />} tone="owed"
foot={totals.owed > 0.01
? <FlowPills ids={(settlement?.flows || []).filter(f => f.to.user_id === me).map(f => f.from.user_id)} lead={t('costs.from')} Avatar={Avatar} name={personName} />
: <span className="text-content-faint">{t('costs.nothingOwed')}</span>} />
<SummaryCard label={t('costs.totalSpend')} sub={t('costs.totalSpendSub')} amount={totals.totalSpend} currency={base} locale={locale}
icon={<BarChart3 size={18} />} tone="total"
foot={<span style={{ display: 'flex', gap: 16 }}><span>{t('costs.yourShare')} · <b>{fmt0(totals.myShare)}</b></span><span>{t('costs.youPaid')} · <b>{fmt0(totals.myPaid)}</b></span></span>} />
</div>
{/* ── Main grid ── */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 380px', gap: 32, alignItems: 'start' }} className="costs-grid">
{/* 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 }}>
{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' }} />
</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' }}>
{t('costs.filter.' + f)}
</button>
))}
</div>
</div>
</div>
{dayGroups.length === 0 ? (
<div className="text-content-faint" style={{ textAlign: 'center', padding: '60px 20px' }}>
{search ? t('costs.noMatch') : t('costs.emptyText')}
</div>
) : dayGroups.map(g => {
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
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>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}
</div>
</div>
)
})}
</div>
{/* sidebar */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* settle up */}
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)}
className="text-content-muted bg-surface-secondary border border-edge disabled:opacity-40"
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<History size={13} /> {t('costs.history')}{(settlement?.settlements || []).length ? ` (${settlement!.settlements.length})` : ''}
</button>
</div>
<SettleFlows />
</div>
{/* balances */}
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.balances')}</div>
<BalancesList balances={settlement?.balances || []} />
</div>
{/* by category */}
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
<CategoryBreakdown />
</div>
</div>
</div>
</div>)}
{modalOpen && (
<ExpenseModal tripId={tripId} base={base} people={people} me={me} editing={editing}
onClose={() => setModalOpen(false)}
onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} />
)}
<Modal isOpen={histOpen} onClose={() => setHistOpen(false)} title={t('costs.settleHistory')} size="md">
<SettleHistory settlements={settlement?.settlements || []} fmt={fmt} Avatar={Avatar} name={personName} onUndo={undoSettlement} canEdit={canEdit} />
</Modal>
<style>{`
.costs-root {
--c-bg: #f8fafc; --c-bg2: oklch(0.965 0.01 70);
--c-surface: #ffffff; --c-surface2: oklch(0.985 0.006 78);
--c-ink: oklch(0.22 0.012 65); --c-ink2: oklch(0.42 0.012 65); --c-ink3: oklch(0.62 0.01 65);
--c-line: oklch(0.92 0.008 70);
}
html.dark .costs-root {
--c-bg: #121215; --c-bg2: #18181c;
--c-surface: #1a1a1e; --c-surface2: #202027;
--c-ink: #f4f4f5; --c-ink2: #a1a1aa; --c-ink3: #71717a;
--c-line: #2a2a31;
}
.costs-root .bg-surface-card { background: var(--c-surface) !important; }
.costs-root .bg-surface-secondary, .costs-root .bg-surface-input { background: var(--c-surface2) !important; }
.costs-root .border-edge { border-color: var(--c-line) !important; }
/* dark = neutral zinc + a touch of liquid glass, matching the dashboard */
html.dark .costs-root .bg-surface-card {
background: rgba(255,255,255,0.035) !important;
border-color: rgba(255,255,255,0.08) !important;
backdrop-filter: blur(20px) saturate(1.4);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
}
html.dark .costs-root .bg-surface-secondary,
html.dark .costs-root .bg-surface-input { background: rgba(255,255,255,0.05) !important; }
html.dark .costs-root .border-edge { border-color: rgba(255,255,255,0.08) !important; }
.costs-root .text-content { color: var(--c-ink) !important; }
.costs-root .text-content-muted { color: var(--c-ink2) !important; }
.costs-root .text-content-faint { color: var(--c-ink3) !important; }
.costs-root .exp-actions { opacity: 1; }
@media (max-width: 1100px) {
.costs-root .costs-summary { grid-template-columns: 1fr !important; }
.costs-root .costs-grid { grid-template-columns: 1fr !important; }
}
`}</style>
</div>
)
// ── shared settle-flow list ──────────────────────────────────────────────
function SettleFlows() {
const flows = settlement?.flows || []
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>
)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{flows.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }} title={`${personName(f.from.user_id)}${f.to.user_id === me ? t('costs.youLower') : personName(f.to.user_id)}`}>
<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>}
</div>
</div>
))}
</div>
)
}
// ── mobile layout (Budget1Mobile.html): single flat column, total card on top ──
function MobileBody() {
return (
<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' }}>
<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' }}>
<Plus size={17} /> {t('costs.addExpense')}
</button>
)}
</section>
{/* Owe / Owed */}
<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>
<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>
</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>
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)} className="text-content-muted bg-surface-card border border-edge disabled:opacity-40" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><History size={13} /> {t('costs.history')}</button>
</div>
<SettleFlows />
</div>
{/* 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="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' }} />
</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>
))}
</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>
: dayGroups.map(g => {
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 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 style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}</div>
</div>
)
})}
</div>
{/* Balances */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.balances')}</div>
<BalancesList balances={settlement?.balances || []} />
</div>
{/* By category */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
<CategoryBreakdown />
</div>
</div>
)
}
// ── inline subcomponents (close over helpers) ────────────────────────────
function ExpenseRow({ e }: { e: BudgetItem }) {
const c = catMeta(e.category)
const Icon = c.Icon
const cur = curOf(e)
const payers = (e.payers || []).filter(p => p.amount > 0)
const net = round2(myPaidOf(e) - myShareOf(e))
return (
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></span>
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{e.name}</div>
{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 }}>
<Avatar id={p.user_id} size={18} />
<span className="text-content" style={{ fontWeight: 700 }}>{fmt(convert(p.amount, cur))}</span>
</span>
))}
</div>
)}
{!isMobile && (
<div className="text-content-faint" style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{t(c.labelKey)}{cur !== base ? ` · ${fmt(e.total_price, cur)}${fmt(baseTotal(e))}` : ''}
</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>
{(e.members || []).length > 0 && Math.abs(net) > 0.01 && (
<div style={{ fontSize: 12, marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
</div>
)}
</div>
{canEdit && (
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
<button title={t('common.edit')} onClick={() => { setEditing(e); setModalOpen(true) }} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
<button title={t('common.delete')} onClick={() => handleDelete(e.id)} className="bg-surface-secondary border border-edge" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer', color: '#dc2626' }}><Trash2 size={13} /></button>
</div>
)}
</div>
</div>
)
}
function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{rows.map(r => {
const pct = Math.min(100, Math.abs(r.balance) / max * 100)
const pos = r.balance > 0.01, neg = r.balance < -0.01
return (
<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="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)' }}>
{pos ? '+' + fmt(r.balance) : neg ? '' + fmt(-r.balance) : fmt(0)}
</div>
</div>
)
})}
</div>
)
}
function CategoryBreakdown() {
const tot: Record<string, number> = {}
let grand = 0
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e); grand += baseTotal(e) }
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{rows.map(c => {
const v = tot[c.key]; const pct = grand ? v / grand * 100 : 0
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>
<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>
</div>
)
})}
</div>
)
}
}
// ── pure subcomponents ─────────────────────────────────────────────────────
function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }: { label: string; sub: string; amount: number; currency: string; locale: string; icon: React.ReactNode; foot: React.ReactNode; tone: 'owe' | 'owed' | 'total' }) {
const total = tone === 'total'
const accent = tone === 'owe' ? '#dc2626' : tone === 'owed' ? '#16a34a' : undefined
const muted = total ? 'rgba(255,255,255,0.55)' : 'var(--text-faint)'
// formatToParts keeps the design's "big integer + muted symbol/decimals" styling
// while letting Intl place the symbol and pick separators per locale + currency.
let parts: Intl.NumberFormatPart[] | null = null
try {
const d = currencyDecimals(currency)
parts = new Intl.NumberFormat(currencyLocale(currency), { style: 'currency', currency: (currency || 'EUR').toUpperCase(), minimumFractionDigits: d, maximumFractionDigits: d }).formatToParts(amount || 0)
} catch { parts = null }
const big = (p: Intl.NumberFormatPart) => p.type === 'integer' || p.type === 'group' || p.type === 'minusSign'
return (
<div className={total ? '' : 'bg-surface-card border border-edge'}
style={{ borderRadius: 22, padding: '26px 28px', position: 'relative', overflow: 'hidden', ...(total ? { background: 'linear-gradient(135deg,#1f2937,#111827)', color: '#fff' } : {}) }}>
<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>
</div>
<div style={{ fontSize: 46, 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>)
: <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>
)
}
function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string }) {
const uniq = Array.from(new Set(ids))
return (
<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 }}>
<Avatar id={id} size={18} />{name(id)}
</span>
))}
</span>
)
}
function SettleHistory({ settlements, fmt, Avatar, name, onUndo, canEdit }: {
settlements: Settlement[]; fmt: (v: number) => string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string; onUndo: (id: number) => void; canEdit: boolean
}) {
const { t } = useTranslation()
if (settlements.length === 0) return <div className="text-content-faint" style={{ textAlign: 'center', padding: 30, fontSize: 13 }}>{t('costs.noSettlements')}</div>
const total = settlements.reduce((a, s) => a + s.amount, 0)
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 14px', borderRadius: 12, marginBottom: 14, background: 'rgba(22,163,74,0.1)', color: '#16a34a', fontWeight: 600, fontSize: 13 }}>
<span>{t('costs.paymentsSettled', { count: settlements.length })}</span><span>{fmt(total)}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{settlements.map(s => (
<div key={s.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '12px 14px', borderRadius: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }} title={`${name(s.from_user_id)}${name(s.to_user_id)}`}>
<Avatar id={s.from_user_id} size={30} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={s.to_user_id} size={30} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span className="text-content" style={{ fontSize: 14, fontWeight: 600 }}>{fmt(s.amount)}</span>
{canEdit && <button onClick={() => onUndo(s.id)} className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><RotateCcw size={12} /> {t('costs.undo')}</button>}
</div>
</div>
))}
</div>
</div>
)
}
// ── Add / edit expense modal ───────────────────────────────────────────────
function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; onClose: () => void; onSaved: () => void
}) {
const { t, locale } = useTranslation()
const toast = useToast()
const { addBudgetItem, updateBudgetItem } = useTripStore()
const { convert } = useExchangeRates(base)
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
const [name, setName] = useState(editing?.name || '')
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : 'food')
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
const [payers, setPayers] = useState<Record<number, string>>(() => {
const m: Record<number, string> = {}
for (const p of editing?.payers || []) m[p.user_id] = String(p.amount)
return m
})
const [split, setSplit] = useState<Set<number>>(() =>
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
const [saving, setSaving] = useState(false)
const payersTotal = Object.values(payers).reduce((a, v) => a + (parseFloat(v) || 0), 0)
const each = split.size > 0 ? payersTotal / split.size : 0
const valid = name.trim().length > 0 && split.size > 0 && payersTotal > 0
const save = async () => {
if (!valid) return
setSaving(true)
const payerList = Object.entries(payers).map(([uid, v]) => ({ user_id: Number(uid), amount: parseFloat(v) || 0 })).filter(p => p.amount > 0)
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.
currency,
payers: payerList, member_ids: [...split],
expense_date: day || null,
}
try {
if (editing) await updateBudgetItem(tripId, editing.id, data)
else await addBudgetItem(tripId, data)
onSaved()
} catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
}
const inputCls = 'w-full bg-surface-input border border-edge text-content'
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
return (
<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>
</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' }} />
</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>
<span className="text-content" style={{ flex: 1, fontSize: 15, fontWeight: 600, paddingLeft: 6 }}>{payersTotal.toFixed(2)}</span>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div style={{ minWidth: 0 }}>
<label className={labelCls}>{t('costs.currency')}</label>
<CustomSelect value={currency} onChange={v => setCurrency(String(v))} searchable
options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
style={{ width: '100%' }} />
</div>
<div style={{ minWidth: 0 }}>
<label className={labelCls}>{t('costs.day')}</label>
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
</div>
</div>
{currency !== base && payersTotal > 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' }}>
<span>{formatMoney(payersTotal, currency, locale)}</span>
<span className="text-content-faint"></span>
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(payersTotal, currency), base, locale)}</span>
<span className="text-content-faint">· {t('costs.liveRate')}</span>
</div>
)}
<div>
<label className={labelCls}>{t('costs.category')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
{COST_CATEGORY_LIST.map(c => {
const Icon = c.Icon; const on = cat === c.key
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 }}>
<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>
)
})}
</div>
</div>
<div>
<label className={labelCls}>{t('costs.whoPaid')}</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{people.map(p => (
<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 }}>
<span className="text-content" style={{ fontSize: 14, fontWeight: 500 }}>{p.id === me ? t('costs.you') : p.username}</span>
<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="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={payers[p.id] || ''}
onChange={e => setPayers(prev => ({ ...prev, [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>
</div>
))}
</div>
</div>
<div>
<label className={labelCls}>{t('costs.splitBetween')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
{people.map(p => {
const on = split.has(p.id)
return (
<button key={p.id} onClick={() => setSplit(prev => { const n = new Set(prev); n.has(p.id) ? n.delete(p.id) : n.add(p.id); return n })}
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-faint border border-edge'}
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '6px 13px 6px 7px', borderRadius: 999, fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
{p.avatar_url
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', opacity: on ? 1 : 0.45 }} />
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[people.findIndex(x => x.id === p.id) % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
{p.id === me ? t('costs.you') : p.username}
</button>
)
})}
</div>
<div className="text-content-faint" style={{ marginTop: 10, fontSize: 12.5 }}>
{split.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: split.size, amount: sym(currency) + each.toFixed(2) })}
</div>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,39 @@
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { COST_CATEGORIES, type CostCategory } from '@trek/shared'
/**
* The fixed Costs categories. Users can't add their own every expense maps to
* one of these. Category colour is the one place an accent is allowed (it
* visualises the category); everything else stays black/white. The label comes
* from i18n (`costs.cat.*`).
*/
export interface CostCategoryMeta {
key: CostCategory
labelKey: string
Icon: LucideIcon
color: string
}
export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
accommodation: { key: 'accommodation', labelKey: 'costs.cat.accommodation', Icon: Hotel, color: '#16a34a' },
food: { key: 'food', labelKey: 'costs.cat.food', Icon: Utensils, color: '#ea580c' },
groceries: { key: 'groceries', labelKey: 'costs.cat.groceries', Icon: ShoppingCart, color: '#65a30d' },
transport: { key: 'transport', labelKey: 'costs.cat.transport', Icon: Bus, color: '#2563eb' },
flights: { key: 'flights', labelKey: 'costs.cat.flights', Icon: Plane, color: '#0ea5e9' },
activities: { key: 'activities', labelKey: 'costs.cat.activities', Icon: Ticket, color: '#9333ea' },
sightseeing: { key: 'sightseeing', labelKey: 'costs.cat.sightseeing', Icon: Camera, color: '#db2777' },
shopping: { key: 'shopping', labelKey: 'costs.cat.shopping', Icon: ShoppingBag, color: '#e11d48' },
fees: { key: 'fees', labelKey: 'costs.cat.fees', Icon: FileText, color: '#475569' },
health: { key: 'health', labelKey: 'costs.cat.health', Icon: HeartPulse, color: '#dc2626' },
tips: { key: 'tips', labelKey: 'costs.cat.tips', Icon: Coins, color: '#d97706' },
other: { key: 'other', labelKey: 'costs.cat.other', Icon: MoreHorizontal, color: '#6b7280' },
}
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
/** Map any stored category (incl. legacy free-text values) to a known meta. */
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
if (cat && cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
return COST_CAT_META.other
}
@@ -0,0 +1,211 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import type { CSSProperties } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { budgetApi } from '../../api/client'
import type { BudgetItem } from '../../types'
import { currencyDecimals } from '../../utils/formatters'
import { widgetTheme, fmtNum, calcPP, calcPD, calcPPD } from './BudgetPanel.helpers'
import { PIE_COLORS } from './BudgetPanel.constants'
import type { TripMember } from './BudgetPanelMemberChips'
function useIsDark(): boolean {
const [dark, setDark] = useState<boolean>(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'))
useEffect(() => {
if (typeof document === 'undefined') return
const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark')))
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => mo.disconnect()
}, [])
return dark
}
export interface EditingCat {
name: string
value: string
}
interface SettlementPerson {
user_id: number
username: string
avatar_url: string | null
}
interface SettlementFlow {
from: SettlementPerson
to: SettlementPerson
amount: number
}
interface SettlementBalance {
user_id: number
username: string
avatar_url: string | null
balance: number
}
export interface SettlementData {
balances: SettlementBalance[]
flows: SettlementFlow[]
}
export interface PieSegment {
name: string
value: number
color: string
}
export interface AddItemData {
name: string
total_price: number
persons: number | null
days: number | null
note: string | null
expense_date: string | null
}
export function useBudgetPanel(tripId: number, tripMembers: TripMember[]) {
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
const can = useCanDo()
const toast = useToast()
const { t, locale } = useTranslation()
const isDark = useIsDark()
const theme = useMemo(() => widgetTheme(isDark), [isDark])
const [newCategoryName, setNewCategoryName] = useState('')
const [editingCat, setEditingCat] = useState<EditingCat | null>(null) // { name, value }
const [settlement, setSettlement] = useState<SettlementData | null>(null)
const [settlementOpen, setSettlementOpen] = useState(false)
const currency = trip?.currency || 'EUR'
const canEdit = can('budget_edit', trip)
const fmt = (v: number | null | undefined, cur: string) => fmtNum(v, locale, cur)
const hasMultipleMembers = tripMembers.length > 1
// Drag state for categories
const [dragCat, setDragCat] = useState<string | null>(null)
const [dragOverCat, setDragOverCat] = useState<string | null>(null)
// Drag state for items within a category
const [dragItem, setDragItem] = useState<number | null>(null)
const [dragOverItem, setDragOverItem] = useState<number | null>(null)
const [dragItemCat, setDragItemCat] = useState<string | null>(null)
// Load settlement data whenever budget items change
useEffect(() => {
if (!hasMultipleMembers) return
budgetApi.settlement(tripId).then(setSettlement).catch(() => {})
}, [tripId, budgetItems, hasMultipleMembers])
const setCurrency = (cur: string) => {
if (tripId) updateTrip(tripId, { currency: cur })
}
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
const grouped = useMemo(() => {
const map = new Map<string, BudgetItem[]>()
for (const item of (budgetItems || [])) {
const cat = item.category || 'Other'
if (!map.has(cat)) map.set(cat, [])
map.get(cat)!.push(item)
}
return map
}, [budgetItems])
const categoryNames = Array.from(grouped.keys())
// Stable color mapping: assign index-based colors once, never reassign on reorder
const colorMapRef = useRef(new Map<string, string>())
const categoryColor = useCallback((cat: string) => {
const map = colorMapRef.current
if (!map.has(cat)) {
map.set(cat, PIE_COLORS[map.size % PIE_COLORS.length])
}
return map.get(cat)!
}, [])
const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0)
const pieSegments = useMemo<PieSegment[]>(() =>
categoryNames.map((cat, i) => ({
name: cat,
value: (grouped.get(cat) || []).reduce((s, x) => s + (x.total_price || 0), 0),
color: categoryColor(cat),
})).filter(s => s.value > 0)
, [grouped, categoryNames])
const handleAddItem = async (category: string, data: AddItemData) => { try { await addBudgetItem(tripId, { ...data, category }) } catch { toast.error(t('common.error')) } }
const handleUpdateField = async (id: number, field: string, value: unknown) => { try { await updateBudgetItem(tripId, id, { [field]: value } as Partial<BudgetItem>) } catch { toast.error(t('common.error')) } }
const handleDeleteItem = async (id: number) => { try { await deleteBudgetItem(tripId, id) } catch { toast.error(t('common.error')) } }
const handleDeleteCategory = async (cat: string) => {
const items = grouped.get(cat) || []
try { for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id) }
catch { toast.error(t('common.error')) }
}
const handleRenameCategory = async (oldName: string, newName: string) => {
if (!newName.trim() || newName.trim() === oldName) return
const items = grouped.get(oldName) || []
try { for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) }
catch { toast.error(t('common.error')) }
}
const handleAddCategory = () => {
if (!newCategoryName.trim()) return
Promise.resolve(addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 }))
.catch(() => toast.error(t('common.error')))
setNewCategoryName('')
}
const handleExportCsv = () => {
const sep = ';'
const esc = (v: unknown) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s }
const d = currencyDecimals(currency)
const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : ''
const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) }
const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note']
const rows = [header.join(sep)]
for (const cat of categoryNames) {
for (const item of (grouped.get(cat) || [])) {
const pp = calcPP(item.total_price, item.persons)
const pd = calcPD(item.total_price, item.days)
const ppd = calcPPD(item.total_price, item.persons, item.days)
rows.push([
esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')),
fmtPrice(item.total_price), item.persons ?? '', item.days ?? '',
fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd),
esc(item.note || ''),
].join(sep))
}
}
const bom = ''
const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9À-ɏ _-]/g, '').trim()
a.download = `budget-${safeName}.csv`
a.click()
URL.revokeObjectURL(url)
}
const th: CSSProperties = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
const td: CSSProperties = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
return {
trip, budgetItems,
setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories,
t, locale, isDark, theme,
newCategoryName, setNewCategoryName,
editingCat, setEditingCat,
settlement, settlementOpen, setSettlementOpen,
currency, canEdit, fmt, hasMultipleMembers,
dragCat, setDragCat, dragOverCat, setDragOverCat,
dragItem, setDragItem, dragOverItem, setDragOverItem, dragItemCat, setDragItemCat,
setCurrency,
grouped, categoryNames, categoryColor, grandTotal, pieSegments,
handleAddItem, handleUpdateField, handleDeleteItem, handleDeleteCategory, handleRenameCategory, handleAddCategory, handleExportCsv,
th, td,
}
}
@@ -0,0 +1,10 @@
export const EMOJI_CATEGORIES = {
'Smileys': ['😀','😂','🥹','😍','🤩','😎','🥳','😭','🤔','👀','🙈','🫠','😴','🤯','🥺','😤','💀','👻','🫡','🤝'],
'Reactions': ['❤️','🔥','👍','👎','👏','🎉','💯','✨','⭐','💪','🙏','😱','😂','💖','💕','🤞','✅','❌','⚡','🏆'],
'Travel': ['✈️','🏖️','🗺️','🧳','🏔️','🌅','🌴','🚗','🚂','🛳️','🏨','🍽️','🍕','🍹','📸','🎒','⛱️','🌍','🗼','🎌'],
}
// Reaction Quick Menu (right-click)
export const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
export const URL_REGEX = /https?:\/\/[^\s<>"']+/g
@@ -0,0 +1,42 @@
// ── Twemoji helper (Apple-style emojis via CDN) ──
export function emojiToCodepoint(emoji) {
const codepoints = []
for (const c of emoji) {
const cp = c.codePointAt(0)
if (cp !== 0xfe0f) codepoints.push(cp.toString(16)) // skip variation selector
}
return codepoints.join('-')
}
// SQLite stores UTC without 'Z' suffix — append it so JS parses as UTC
export function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) }
export function formatTime(isoString, is12h) {
const d = parseUTC(isoString)
const h = d.getHours()
const mm = String(d.getMinutes()).padStart(2, '0')
if (is12h) {
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
return `${h12}:${mm} ${period}`
}
return `${String(h).padStart(2, '0')}:${mm}`
}
export function formatDateSeparator(isoString, t) {
const d = parseUTC(isoString)
const now = new Date()
const yesterday = new Date(); yesterday.setDate(now.getDate() - 1)
if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today'
if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday'
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
}
export function shouldShowDateSeparator(msg, prevMsg) {
if (!prevMsg) return true
const d1 = parseUTC(msg.created_at).toDateString()
const d2 = parseUTC(prevMsg.created_at).toDateString()
return d1 !== d2
}
+9 -750
View File
@@ -1,350 +1,10 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import ReactDOM from 'react-dom'
import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react'
import { collabApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { ArrowUp, Reply, Smile, X } from 'lucide-react'
import type { User } from '../../types'
interface ChatReaction {
emoji: string
count: number
users: { id: number; username: string }[]
}
interface ChatMessage {
id: number
trip_id: number
user_id: number
text: string
reply_to_id: number | null
reactions: ChatReaction[]
created_at: string
user?: { username: string; avatar_url: string | null }
reply_to?: ChatMessage | null
}
// ── Twemoji helper (Apple-style emojis via CDN) ──
function emojiToCodepoint(emoji) {
const codepoints = []
for (const c of emoji) {
const cp = c.codePointAt(0)
if (cp !== 0xfe0f) codepoints.push(cp.toString(16)) // skip variation selector
}
return codepoints.join('-')
}
function TwemojiImg({ emoji, size = 20, style = {} }) {
const cp = emojiToCodepoint(emoji)
const [failed, setFailed] = useState(false)
if (failed) {
return <span style={{ fontSize: size, lineHeight: 1, display: 'inline-block', verticalAlign: 'middle', ...style }}>{emoji}</span>
}
return (
<img
src={`https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/${cp}.png`}
alt={emoji}
draggable={false}
style={{ width: size, height: size, display: 'inline-block', verticalAlign: 'middle', ...style }}
onError={() => setFailed(true)}
/>
)
}
const EMOJI_CATEGORIES = {
'Smileys': ['😀','😂','🥹','😍','🤩','😎','🥳','😭','🤔','👀','🙈','🫠','😴','🤯','🥺','😤','💀','👻','🫡','🤝'],
'Reactions': ['❤️','🔥','👍','👎','👏','🎉','💯','✨','⭐','💪','🙏','😱','😂','💖','💕','🤞','✅','❌','⚡','🏆'],
'Travel': ['✈️','🏖️','🗺️','🧳','🏔️','🌅','🌴','🚗','🚂','🛳️','🏨','🍽️','🍕','🍹','📸','🎒','⛱️','🌍','🗼','🎌'],
}
// SQLite stores UTC without 'Z' suffix — append it so JS parses as UTC
function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) }
function formatTime(isoString, is12h) {
const d = parseUTC(isoString)
const h = d.getHours()
const mm = String(d.getMinutes()).padStart(2, '0')
if (is12h) {
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
return `${h12}:${mm} ${period}`
}
return `${String(h).padStart(2, '0')}:${mm}`
}
function formatDateSeparator(isoString, t) {
const d = parseUTC(isoString)
const now = new Date()
const yesterday = new Date(); yesterday.setDate(now.getDate() - 1)
if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today'
if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday'
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
}
function shouldShowDateSeparator(msg, prevMsg) {
if (!prevMsg) return true
const d1 = parseUTC(msg.created_at).toDateString()
const d2 = parseUTC(prevMsg.created_at).toDateString()
return d1 !== d2
}
/* ── Emoji Picker ── */
interface EmojiPickerProps {
onSelect: (emoji: string) => void
onClose: () => void
anchorRef: React.RefObject<HTMLElement | null>
containerRef: React.RefObject<HTMLElement | null>
}
function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) {
const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0])
const ref = useRef(null)
const getPos = () => {
const container = containerRef?.current
const anchor = anchorRef?.current
if (container && anchor) {
const cRect = container.getBoundingClientRect()
const aRect = anchor.getBoundingClientRect()
return { bottom: window.innerHeight - aRect.top + 16, left: cRect.left + cRect.width / 2 - 140 }
}
return { bottom: 80, left: 0 }
}
const pos = getPos()
useEffect(() => {
const close = (e) => {
if (ref.current && ref.current.contains(e.target)) return
if (anchorRef?.current && anchorRef.current.contains(e.target)) return
onClose()
}
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [onClose, anchorRef])
return ReactDOM.createPortal(
<div ref={ref} style={{
position: 'fixed', bottom: pos.bottom, left: pos.left, zIndex: 10000,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
boxShadow: '0 8px 32px rgba(0,0,0,0.18)', width: 280, overflow: 'hidden',
}}>
{/* Category tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-faint)', padding: '6px 8px', gap: 2 }}>
{Object.keys(EMOJI_CATEGORIES).map(c => (
<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',
}}>
{c}
</button>
))}
</div>
{/* Emoji grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(10, 1fr)', gap: 2, padding: 8 }}>
{EMOJI_CATEGORIES[cat].map((emoji, i) => (
<button key={i} onClick={() => onSelect(emoji)} style={{
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: 'none', cursor: 'pointer', borderRadius: 6,
padding: 2, transition: 'transform 0.1s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.transform = 'scale(1.2)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.transform = 'scale(1)' }}
>
<TwemojiImg emoji={emoji} size={20} />
</button>
))}
</div>
</div>,
document.body
)
}
/* ── Reaction Quick Menu (right-click) ── */
const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
interface ReactionMenuProps {
x: number
y: number
onReact: (emoji: string) => void
onClose: () => void
}
function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) {
const ref = useRef(null)
useEffect(() => {
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() }
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [onClose])
// Clamp to viewport
const menuWidth = 156
const clampedLeft = Math.max(menuWidth / 2 + 8, Math.min(x, window.innerWidth - menuWidth / 2 - 8))
return (
<div ref={ref} style={{
position: 'fixed', top: y - 80, left: clampedLeft, transform: 'translateX(-50%)', zIndex: 10000,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '6px 8px',
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, width: menuWidth,
}}>
{QUICK_REACTIONS.map(emoji => (
<button key={emoji} onClick={() => onReact(emoji)} style={{
width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: 'none', cursor: 'pointer', borderRadius: '50%',
padding: 3, transition: 'transform 0.1s, background 0.1s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'none' }}
>
<TwemojiImg emoji={emoji} size={18} />
</button>
))}
</div>
)
}
/* ── Message Text with clickable URLs ── */
interface MessageTextProps {
text: string
}
function MessageText({ text }: MessageTextProps) {
const parts = text.split(URL_REGEX)
const urls = text.match(URL_REGEX) || []
const result = []
parts.forEach((part, i) => {
if (part) result.push(part)
if (urls[i]) result.push(
<a key={i} href={urls[i]} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85 }}>
{urls[i]}
</a>
)
})
return <>{result}</>
}
/* ── Link Preview ── */
const URL_REGEX = /https?:\/\/[^\s<>"']+/g
const previewCache = {}
interface LinkPreviewProps {
url: string
tripId: number
own: boolean
onLoad: (() => void) | undefined
}
function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
const [data, setData] = useState(previewCache[url] || null)
const [loading, setLoading] = useState(!previewCache[url])
useEffect(() => {
if (previewCache[url]) return
collabApi.linkPreview(tripId, url).then(d => {
previewCache[url] = d
setData(d)
setLoading(false)
if (d?.title || d?.description || d?.image) onLoad?.()
}).catch(() => setLoading(false))
}, [url, tripId])
if (loading || !data || (!data.title && !data.description && !data.image)) return null
const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return '' } })()
return (
<a href={url} target="_blank" rel="noopener noreferrer" style={{
display: 'block', textDecoration: 'none', marginTop: 6, borderRadius: 12, overflow: 'hidden',
border: own ? '1px solid rgba(255,255,255,0.15)' : '1px solid var(--border-faint)',
background: own ? 'rgba(255,255,255,0.1)' : 'var(--bg-secondary)',
maxWidth: 280, transition: 'opacity 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
{data.image && (
<img src={data.image} alt="" style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block' }}
onError={e => e.target.style.display = 'none'} />
)}
<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 }}>
{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' }}>
{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' }}>
{data.description}
</div>
)}
</div>
</a>
)
}
/* ── Reaction Badge with NOMAD tooltip ── */
interface ReactionBadgeProps {
reaction: ChatReaction
currentUserId: number
onReact: () => void
}
function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadgeProps) {
const [hover, setHover] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const ref = useRef(null)
const names = reaction.users.map(u => u.username).join(', ')
return (
<>
<button ref={ref} onClick={onReact}
onMouseEnter={() => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect()
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
}
setHover(true)
}}
onMouseLeave={() => setHover(false)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '1px 3px',
borderRadius: 99, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
background: 'transparent', transition: 'transform 0.1s',
}}
>
<TwemojiImg emoji={reaction.emoji} size={16} />
{reaction.count > 1 && <span style={{ fontSize: 10, 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,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
}}>
{names}
</div>,
document.body
)}
</>
)
}
import { useCollabChat } from './useCollabChat'
import { ChatMessages } from './CollabChatMessages'
import { EmojiPicker } from './CollabChatEmojiPicker'
import { ReactionMenu } from './CollabChatReactionMenu'
/* ── Main Component ── */
interface CollabChatProps {
@@ -353,173 +13,8 @@ interface CollabChatProps {
}
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('collab_edit', trip)
const [messages, setMessages] = useState([])
const [loading, setLoading] = useState(true)
const [hasMore, setHasMore] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [text, setText] = useState('')
const [replyTo, setReplyTo] = useState(null)
const [hoveredId, setHoveredId] = useState(null)
const [sending, setSending] = useState(false)
const [showEmoji, setShowEmoji] = useState(false)
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
const [deletingIds, setDeletingIds] = useState(new Set())
const deleteTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
useEffect(() => {
return () => { deleteTimersRef.current.forEach(clearTimeout) }
}, [])
const containerRef = useRef(null)
const messagesRef = useRef(messages)
messagesRef.current = messages
const scrollRef = useRef(null)
const textareaRef = useRef(null)
const emojiBtnRef = useRef(null)
const isAtBottom = useRef(true)
const scrollToBottom = useCallback((behavior = 'auto') => {
const el = scrollRef.current
if (!el) return
requestAnimationFrame(() => el.scrollTo({ top: el.scrollHeight, behavior }))
}, [])
const checkAtBottom = useCallback(() => {
const el = scrollRef.current
if (!el) return
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48
}, [])
/* ── load messages ── */
useEffect(() => {
let cancelled = false
setLoading(true)
collabApi.getMessages(tripId).then(data => {
if (cancelled) return
const msgs = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
setMessages(msgs)
setHasMore(msgs.length >= 100)
setLoading(false)
setTimeout(() => scrollToBottom(), 30)
}).catch(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [tripId, scrollToBottom])
/* ── load more ── */
const handleLoadMore = useCallback(async () => {
if (loadingMore || messages.length === 0) return
setLoadingMore(true)
const el = scrollRef.current
const prevHeight = el ? el.scrollHeight : 0
try {
const data = await collabApi.getMessages(tripId, messages[0]?.id)
const older = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
if (older.length === 0) { setHasMore(false) }
else {
setMessages(prev => [...older, ...prev])
setHasMore(older.length >= 100)
requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight })
}
} catch {} finally { setLoadingMore(false) }
}, [tripId, loadingMore, messages])
/* ── websocket ── */
useEffect(() => {
const handler = (event) => {
if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) {
setMessages(prev => prev.some(m => m.id === event.message.id) ? prev : [...prev, event.message])
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 30)
}
if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) {
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, _deleted: true } : m))
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50)
}
if (event.type === 'collab:message:reacted' && String(event.tripId) === String(tripId)) {
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, reactions: event.reactions } : m))
}
}
addListener(handler)
return () => removeListener(handler)
}, [tripId, scrollToBottom])
/* ── auto-resize textarea ── */
const handleTextChange = useCallback((e) => {
setText(e.target.value)
const ta = textareaRef.current
if (ta) {
ta.style.height = 'auto'
const h = Math.min(ta.scrollHeight, 100)
ta.style.height = h + 'px'
ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden'
}
}, [])
/* ── send ── */
const handleSend = useCallback(async () => {
const body = text.trim()
if (!body || sending) return
setSending(true)
try {
const payload = { text: body }
if (replyTo) payload.reply_to = replyTo.id
const data = await collabApi.sendMessage(tripId, payload)
if (data?.message) {
setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
}
setText(''); setReplyTo(null); setShowEmoji(false)
if (textareaRef.current) textareaRef.current.style.height = 'auto'
isAtBottom.current = true
setTimeout(() => scrollToBottom('smooth'), 50)
} catch {} finally { setSending(false) }
}, [text, sending, replyTo, tripId, scrollToBottom])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
}, [handleSend])
const handleDelete = useCallback(async (msgId) => {
const msg = messages.find(m => m.id === msgId)
requestAnimationFrame(() => {
setDeletingIds(prev => new Set(prev).add(msgId))
})
const t = setTimeout(async () => {
try {
await collabApi.deleteMessage(tripId, msgId)
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
} catch {}
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
}, 400)
deleteTimersRef.current.push(t)
}, [tripId])
const handleReact = useCallback(async (msgId, emoji) => {
setReactMenu(null)
try {
const data = await collabApi.reactMessage(tripId, msgId, emoji)
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m))
} catch {}
}, [tripId])
const handleEmojiSelect = useCallback((emoji) => {
setText(prev => prev + emoji)
textareaRef.current?.focus()
}, [])
const isOwn = (msg) => String(msg.user_id) === String(currentUser.id)
// Check if message is only emoji (1-3 emojis, no other text)
const isEmojiOnly = (text) => {
const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}[\uFE0F]?(?:\u200D\p{Extended_Pictographic}[\uFE0F]?)*){1,3}$/u
return emojiRegex.test(text.trim())
}
/* ── Loading ── */
const S = useCollabChat(tripId, currentUser)
const { t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = S
if (loading) {
return (
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
@@ -528,247 +23,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
</div>
)
}
/* ── Main ── */
return (
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
{/* Messages */}
{messages.length === 0 ? (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32 }}>
<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 }}>{t('collab.chat.emptyDesc') || ''}</span>
</div>
) : (
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
display: 'flex', flexDirection: 'column', gap: 1,
}}>
{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,
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
}}>
<ChevronUp size={13} />
{loadingMore ? '...' : t('collab.chat.loadMore')}
</button>
</div>
)}
{messages.map((msg, idx) => {
const own = isOwn(msg)
const prevMsg = messages[idx - 1]
const nextMsg = messages[idx + 1]
const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id)
const isLastInGroup = !nextMsg || String(nextMsg?.user_id) !== String(msg.user_id)
const showDate = shouldShowDateSeparator(msg, prevMsg)
const showAvatar = !own && isLastInGroup
const bigEmoji = isEmojiOnly(msg.text)
const hasReply = msg.reply_text || msg.reply_to
// Deleted message placeholder
if (msg._deleted) {
return (
<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' }}>
{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' }}>
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)}
</span>
</div>
</React.Fragment>
)
}
// Bubble border radius — iMessage style tails
const br = own
? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px`
: `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}`
return (
<React.Fragment key={msg.id}>
{/* Date separator */}
{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',
}}>
{formatDateSeparator(msg.created_at, t)}
</span>
</div>
)}
<div style={{
display: 'flex', alignItems: own ? 'flex-end' : 'flex-start',
flexDirection: own ? 'row-reverse' : 'row',
gap: 6, marginTop: isNewGroup ? 10 : 1,
paddingLeft: own ? 40 : 0, paddingRight: own ? 0 : 40,
transition: 'transform 0.3s ease, opacity 0.3s ease, max-height 0.3s ease',
...(deletingIds.has(msg.id) ? { transform: 'scale(0.3)', opacity: 0, maxHeight: 0, marginTop: 0, overflow: 'hidden' } : {}),
}}>
{/* Avatar slot for others */}
{!own && (
<div style={{ width: 28, flexShrink: 0, alignSelf: 'flex-end' }}>
{showAvatar && (
msg.user_avatar ? (
<img src={msg.user_avatar} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
) : (
<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)',
}}>
{(msg.username || '?')[0].toUpperCase()}
</div>
)
)}
</div>
)}
<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 }}>
{msg.username}
</span>
)}
{/* Bubble */}
<div
style={{ position: 'relative' }}
onMouseEnter={() => setHoveredId(msg.id)}
onMouseLeave={() => setHoveredId(null)}
onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
onTouchEnd={e => {
const now = Date.now()
const lastTap = e.currentTarget.dataset.lastTap || 0
if (now - lastTap < 300 && canEdit) {
e.preventDefault()
const touch = e.changedTouches?.[0]
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
}
e.currentTarget.dataset.lastTap = now
}}
>
{bigEmoji ? (
<div style={{ fontSize: 40, lineHeight: 1.2, padding: '2px 0' }}>
{msg.text}
</div>
) : (
<div style={{
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',
}}>
{/* 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,
}}>
<div style={{ fontWeight: 600, fontSize: 11, opacity: 0.7, marginBottom: 1 }}>
{msg.reply_username || ''}
</div>
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{(msg.reply_text || '').slice(0, 80)}
</div>
</div>
)}
{hasReply ? (
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
) : <MessageText text={msg.text} />}
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
))}
</div>
)}
{/* Hover actions */}
<div style={{
position: 'absolute', top: -14,
display: 'flex', gap: 2,
opacity: hoveredId === msg.id ? 1 : 0,
pointerEvents: hoveredId === msg.id ? 'auto' : 'none',
transition: 'opacity .1s',
...(own ? { left: -6 } : { right: -6 }),
}}>
<button onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
width: 24, height: 24, borderRadius: '50%', border: 'none',
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)' }}
>
<Reply size={11} />
</button>
{own && canEdit && (
<button onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
width: 24, height: 24, borderRadius: '50%', border: 'none',
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s, background 0.15s, color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = '#ef4444'; e.currentTarget.style.color = '#fff' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
>
<Trash2 size={11} />
</button>
)}
</div>
</div>
{/* Reactions — iMessage style floating badge */}
{msg.reactions?.length > 0 && (
<div style={{
display: 'flex', gap: 3, marginTop: -6, marginBottom: 4,
justifyContent: own ? 'flex-end' : 'flex-start',
paddingLeft: own ? 0 : 8, paddingRight: own ? 8 : 0,
position: 'relative', zIndex: 1,
}}>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '3px 6px',
borderRadius: 99, background: 'var(--bg-card)',
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
}}>
{msg.reactions.map(r => {
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
return (
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
)
})}
</div>
</div>
)}
{/* Timestamp — only on last message of group */}
{isLastInGroup && (
<span style={{ fontSize: 9, color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
{formatTime(msg.created_at, is12h)}
</span>
)}
</div>
</div>
</React.Fragment>
)
})}
</div>
)}
<ChatMessages {...S} />
{/* Composer */}
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }} className="pb-[96px] md:pb-3">
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
{/* Reply preview */}
{replyTo && (
<div style={{
@@ -0,0 +1,17 @@
export interface ChatReaction {
emoji: string
count: number
users: { id: number; username: string }[]
}
export interface ChatMessage {
id: number
trip_id: number
user_id: number
text: string
reply_to_id: number | null
reactions: ChatReaction[]
created_at: string
user?: { username: string; avatar_url: string | null }
reply_to?: ChatMessage | null
}
@@ -0,0 +1,76 @@
import React, { useState, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import { EMOJI_CATEGORIES } from './CollabChat.constants'
import { TwemojiImg } from './CollabChatTwemojiImg'
/* ── Emoji Picker ── */
interface EmojiPickerProps {
onSelect: (emoji: string) => void
onClose: () => void
anchorRef: React.RefObject<HTMLElement | null>
containerRef: React.RefObject<HTMLElement | null>
}
export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) {
const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0])
const ref = useRef(null)
const getPos = () => {
const container = containerRef?.current
const anchor = anchorRef?.current
if (container && anchor) {
const cRect = container.getBoundingClientRect()
const aRect = anchor.getBoundingClientRect()
return { bottom: window.innerHeight - aRect.top + 16, left: cRect.left + cRect.width / 2 - 140 }
}
return { bottom: 80, left: 0 }
}
const pos = getPos()
useEffect(() => {
const close = (e) => {
if (ref.current && ref.current.contains(e.target)) return
if (anchorRef?.current && anchorRef.current.contains(e.target)) return
onClose()
}
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [onClose, anchorRef])
return ReactDOM.createPortal(
<div ref={ref} style={{
position: 'fixed', bottom: pos.bottom, left: pos.left, zIndex: 10000,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
boxShadow: '0 8px 32px rgba(0,0,0,0.18)', width: 280, overflow: 'hidden',
}}>
{/* Category tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-faint)', padding: '6px 8px', gap: 2 }}>
{Object.keys(EMOJI_CATEGORIES).map(c => (
<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',
}}>
{c}
</button>
))}
</div>
{/* Emoji grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(10, 1fr)', gap: 2, padding: 8 }}>
{EMOJI_CATEGORIES[cat].map((emoji, i) => (
<button key={i} onClick={() => onSelect(emoji)} style={{
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: 'none', cursor: 'pointer', borderRadius: 6,
padding: 2, transition: 'transform 0.1s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.transform = 'scale(1.2)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.transform = 'scale(1)' }}
>
<TwemojiImg emoji={emoji} size={20} />
</button>
))}
</div>
</div>,
document.body
)
}
@@ -0,0 +1,65 @@
import { useState, useEffect } from 'react'
import { collabApi } from '../../api/client'
/* ── Link Preview ── */
const previewCache = {}
interface LinkPreviewProps {
url: string
tripId: number
own: boolean
onLoad: (() => void) | undefined
}
export function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
const [data, setData] = useState(previewCache[url] || null)
const [loading, setLoading] = useState(!previewCache[url])
useEffect(() => {
if (previewCache[url]) return
collabApi.linkPreview(tripId, url).then(d => {
previewCache[url] = d
setData(d)
setLoading(false)
if (d?.title || d?.description || d?.image) onLoad?.()
}).catch(() => setLoading(false))
}, [url, tripId])
if (loading || !data || (!data.title && !data.description && !data.image)) return null
const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return '' } })()
return (
<a href={url} target="_blank" rel="noopener noreferrer" style={{
display: 'block', textDecoration: 'none', marginTop: 6, borderRadius: 12, overflow: 'hidden',
border: own ? '1px solid rgba(255,255,255,0.15)' : '1px solid var(--border-faint)',
background: own ? 'rgba(255,255,255,0.1)' : 'var(--bg-secondary)',
maxWidth: 280, transition: 'opacity 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
{data.image && (
<img src={data.image} alt="" style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block' }}
onError={e => e.currentTarget.style.display = 'none'} />
)}
<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 }}>
{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' }}>
{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' }}>
{data.description}
</div>
)}
</div>
</a>
)
}
@@ -0,0 +1,21 @@
import { URL_REGEX } from './CollabChat.constants'
/* ── Message Text with clickable URLs ── */
interface MessageTextProps {
text: string
}
export function MessageText({ text }: MessageTextProps) {
const parts = text.split(URL_REGEX)
const urls = text.match(URL_REGEX) || []
const result = []
parts.forEach((part, i) => {
if (part) result.push(part)
if (urls[i]) result.push(
<a key={i} href={urls[i]} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85 }}>
{urls[i]}
</a>
)
})
return <>{result}</>
}
@@ -0,0 +1,250 @@
import React from 'react'
import { Trash2, Reply, ChevronUp, MessageCircle } from 'lucide-react'
import { URL_REGEX } from './CollabChat.constants'
import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers'
import { MessageText } from './CollabChatMessageText'
import { LinkPreview } from './CollabChatLinkPreview'
import { ReactionBadge } from './CollabChatReactionBadge'
export function ChatMessages(props: any) {
const { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = props
return (
<>
{/* Messages */}
{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>
</div>
) : (
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
display: 'flex', flexDirection: 'column', gap: 1,
}}>
{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,
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
}}>
<ChevronUp size={13} />
{loadingMore ? '...' : t('collab.chat.loadMore')}
</button>
</div>
)}
{messages.map((msg, idx) => {
const own = isOwn(msg)
const prevMsg = messages[idx - 1]
const nextMsg = messages[idx + 1]
const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id)
const isLastInGroup = !nextMsg || String(nextMsg?.user_id) !== String(msg.user_id)
const showDate = shouldShowDateSeparator(msg, prevMsg)
const showAvatar = !own && isLastInGroup
const bigEmoji = isEmojiOnly(msg.text)
const hasReply = msg.reply_text || msg.reply_to
// Deleted message placeholder
if (msg._deleted) {
return (
<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' }}>
{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' }}>
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)}
</span>
</div>
</React.Fragment>
)
}
// Bubble border radius — iMessage style tails
const br = own
? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px`
: `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}`
return (
<React.Fragment key={msg.id}>
{/* Date separator */}
{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',
}}>
{formatDateSeparator(msg.created_at, t)}
</span>
</div>
)}
<div style={{
display: 'flex', alignItems: own ? 'flex-end' : 'flex-start',
flexDirection: own ? 'row-reverse' : 'row',
gap: 6, marginTop: isNewGroup ? 10 : 1,
paddingLeft: own ? 40 : 0, paddingRight: own ? 0 : 40,
transition: 'transform 0.3s ease, opacity 0.3s ease, max-height 0.3s ease',
...(deletingIds.has(msg.id) ? { transform: 'scale(0.3)', opacity: 0, maxHeight: 0, marginTop: 0, overflow: 'hidden' } : {}),
}}>
{/* Avatar slot for others */}
{!own && (
<div style={{ width: 28, flexShrink: 0, alignSelf: 'flex-end' }}>
{showAvatar && (
msg.user_avatar ? (
<img src={msg.user_avatar} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
) : (
<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)',
}}>
{(msg.username || '?')[0].toUpperCase()}
</div>
)
)}
</div>
)}
<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 }}>
{msg.username}
</span>
)}
{/* Bubble */}
<div
style={{ position: 'relative' }}
onMouseEnter={() => setHoveredId(msg.id)}
onMouseLeave={() => setHoveredId(null)}
onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
onTouchEnd={e => {
const now = Date.now()
const lastTap = Number(e.currentTarget.dataset.lastTap) || 0
if (now - lastTap < 300 && canEdit) {
e.preventDefault()
const touch = e.changedTouches?.[0]
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
}
e.currentTarget.dataset.lastTap = String(now)
}}
>
{bigEmoji ? (
<div style={{ fontSize: 40, lineHeight: 1.2, padding: '2px 0' }}>
{msg.text}
</div>
) : (
<div style={{
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',
}}>
{/* 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,
}}>
<div style={{ fontWeight: 600, fontSize: 11, opacity: 0.7, marginBottom: 1 }}>
{msg.reply_username || ''}
</div>
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{(msg.reply_text || '').slice(0, 80)}
</div>
</div>
)}
{hasReply ? (
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
) : <MessageText text={msg.text} />}
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
))}
</div>
)}
{/* Hover actions */}
<div style={{
position: 'absolute', top: -14,
display: 'flex', gap: 2,
opacity: hoveredId === msg.id ? 1 : 0,
pointerEvents: hoveredId === msg.id ? 'auto' : 'none',
transition: 'opacity .1s',
...(own ? { left: -6 } : { right: -6 }),
}}>
<button onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
width: 24, height: 24, borderRadius: '50%', border: 'none',
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)' }}
>
<Reply size={11} />
</button>
{own && canEdit && (
<button onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
width: 24, height: 24, borderRadius: '50%', border: 'none',
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s, background 0.15s, color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = '#ef4444'; e.currentTarget.style.color = '#fff' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
>
<Trash2 size={11} />
</button>
)}
</div>
</div>
{/* Reactions — iMessage style floating badge */}
{msg.reactions?.length > 0 && (
<div style={{
display: 'flex', gap: 3, marginTop: -6, marginBottom: 4,
justifyContent: own ? 'flex-end' : 'flex-start',
paddingLeft: own ? 0 : 8, paddingRight: own ? 8 : 0,
position: 'relative', zIndex: 1,
}}>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '3px 6px',
borderRadius: 99, background: 'var(--bg-card)',
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
}}>
{msg.reactions.map(r => {
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
return (
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
)
})}
</div>
</div>
)}
{/* Timestamp — only on last message of group */}
{isLastInGroup && (
<span style={{ fontSize: 9, color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
{formatTime(msg.created_at, is12h)}
</span>
)}
</div>
</div>
</React.Fragment>
)
})}
</div>
)}
</>
)
}
@@ -0,0 +1,53 @@
import { useState, useRef } from 'react'
import ReactDOM from 'react-dom'
import { TwemojiImg } from './CollabChatTwemojiImg'
import type { ChatReaction } from './CollabChat.types'
/* ── Reaction Badge with NOMAD tooltip ── */
interface ReactionBadgeProps {
reaction: ChatReaction
currentUserId: number
onReact: () => void
}
export function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadgeProps) {
const [hover, setHover] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const ref = useRef(null)
const names = reaction.users.map(u => u.username).join(', ')
return (
<>
<button ref={ref} onClick={onReact}
onMouseEnter={() => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect()
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
}
setHover(true)
}}
onMouseLeave={() => setHover(false)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '1px 3px',
borderRadius: 99, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
background: 'transparent', transition: 'transform 0.1s',
}}
>
<TwemojiImg emoji={reaction.emoji} size={16} />
{reaction.count > 1 && <span style={{ fontSize: 10, 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,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
}}>
{names}
</div>,
document.body
)}
</>
)
}
@@ -0,0 +1,47 @@
import { useEffect, useRef } from 'react'
import { QUICK_REACTIONS } from './CollabChat.constants'
import { TwemojiImg } from './CollabChatTwemojiImg'
/* ── Reaction Quick Menu (right-click) ── */
interface ReactionMenuProps {
x: number
y: number
onReact: (emoji: string) => void
onClose: () => void
}
export function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) {
const ref = useRef(null)
useEffect(() => {
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() }
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [onClose])
// Clamp to viewport
const menuWidth = 156
const clampedLeft = Math.max(menuWidth / 2 + 8, Math.min(x, window.innerWidth - menuWidth / 2 - 8))
return (
<div ref={ref} style={{
position: 'fixed', top: y - 80, left: clampedLeft, transform: 'translateX(-50%)', zIndex: 10000,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '6px 8px',
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, width: menuWidth,
}}>
{QUICK_REACTIONS.map(emoji => (
<button key={emoji} onClick={() => onReact(emoji)} style={{
width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: 'none', cursor: 'pointer', borderRadius: '50%',
padding: 3, transition: 'transform 0.1s, background 0.1s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'none' }}
>
<TwemojiImg emoji={emoji} size={18} />
</button>
))}
</div>
)
}
@@ -0,0 +1,21 @@
import { useState } from 'react'
import { emojiToCodepoint } from './CollabChat.helpers'
export function TwemojiImg({ emoji, size = 20, style = {} }) {
const cp = emojiToCodepoint(emoji)
const [failed, setFailed] = useState(false)
if (failed) {
return <span style={{ fontSize: size, lineHeight: 1, display: 'inline-block', verticalAlign: 'middle', ...style }}>{emoji}</span>
}
return (
<img
src={`https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/${cp}.png`}
alt={emoji}
draggable={false}
style={{ width: size, height: size, display: 'inline-block', verticalAlign: 'middle', ...style }}
onError={() => setFailed(true)}
/>
)
}
@@ -0,0 +1,10 @@
export const FONT = "var(--font-system)"
export const NOTE_COLORS = [
{ value: '#6366f1', label: 'Indigo' },
{ value: '#ef4444', label: 'Red' },
{ value: '#f59e0b', label: 'Amber' },
{ value: '#10b981', label: 'Emerald' },
{ value: '#3b82f6', label: 'Blue' },
{ value: '#8b5cf6', label: 'Violet' },
]
@@ -0,0 +1,16 @@
// Pure formatting helper for note timestamps. Falls back to translated
// relative labels for recent timestamps and a localized short date beyond a week.
export const formatTimestamp = (ts, t, locale) => {
if (!ts) return ''
const d = new Date(ts.endsWith?.('Z') ? ts : ts + 'Z')
const now = new Date()
const diffMs = now.getTime() - d.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return t('collab.chat.justNow') || 'just now'
if (diffMins < 60) return t('collab.chat.minutesAgo', { n: diffMins }) || `${diffMins}m ago`
const diffHrs = Math.floor(diffMins / 60)
if (diffHrs < 24) return t('collab.chat.hoursAgo', { n: diffHrs }) || `${diffHrs}h ago`
const diffDays = Math.floor(diffHrs / 24)
if (diffDays < 7) return t('collab.notes.daysAgo', { n: diffDays }) || `${diffDays}d ago`
return d.toLocaleDateString(locale || undefined, { month: 'short', day: 'numeric' })
}
@@ -175,7 +175,7 @@ describe('CollabNotes', () => {
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-NOTES-013: delete note calls DELETE API and removes it from grid', async () => {
it('FE-COMP-NOTES-013: deleting a note asks for confirmation, then calls DELETE API and removes it', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/trips/1/collab/notes', () =>
@@ -193,8 +193,11 @@ describe('CollabNotes', () => {
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Remove Me');
const deleteBtn = screen.getByTitle('Delete');
await user.click(deleteBtn);
await user.click(screen.getByTitle('Delete'));
// Deleting now asks for confirmation first — the note stays until confirmed.
expect(screen.getByText('Delete note?')).toBeInTheDocument();
expect(screen.getByText('Remove Me')).toBeInTheDocument();
await user.click(document.querySelector('button.bg-red-600') as HTMLElement);
await waitFor(() => expect(screen.queryByText('Remove Me')).not.toBeInTheDocument());
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
export interface NoteFile {
id: number
filename: string
original_name: string
mime_type: string
file_size?: number | null
url?: string
}
export interface CollabNote {
id: number
trip_id: number
title: string
content: string
category: string
website: string | null
pinned: boolean
color: string | null
username: string
avatar_url: string | null
avatar: string | null
user_id: number
created_at: string
author?: { username: string; avatar: string | null }
user?: { username: string; avatar: string | null }
files?: NoteFile[]
// Wire field: collabService embeds note files as `attachments` (with url).
attachments?: NoteFile[]
}
export interface NoteAuthor {
username: string
avatar?: string | null
}
@@ -0,0 +1,10 @@
import { useState, useEffect } from 'react'
import { getAuthUrl } from '../../api/authUrl'
export function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLImageElement>; onMouseLeave?: React.MouseEventHandler<HTMLImageElement>; alt?: string }) {
const [authSrc, setAuthSrc] = useState('')
useEffect(() => {
getAuthUrl(src, 'download').then(setAuthSrc)
}, [src])
return authSrc ? <img src={authSrc} alt={alt} style={style} onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> : null
}
@@ -0,0 +1,198 @@
import { useState, useCallback } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { Trash2, Pin, PinOff, Pencil, Maximize2 } from 'lucide-react'
import { FONT } from './CollabNotes.constants'
import { AuthedImg } from './CollabNotesAuthedImg'
import { UserAvatar } from './CollabNotesUserAvatar'
import { WebsiteThumbnail } from './CollabNotesWebsiteThumbnail'
import type { CollabNote, NoteFile } from './CollabNotes.types'
import type { User } from '../../types'
// ── Note Card ───────────────────────────────────────────────────────────────
interface NoteCardProps {
note: CollabNote
currentUser: User
canEdit: boolean
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
onDelete: (noteId: number) => void
onEdit: (note: CollabNote) => void
onView: (note: CollabNote) => void
onPreviewFile: (file: NoteFile) => void
getCategoryColor: (category: string) => string
tripId: number
t: (key: string) => string
}
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 color = getCategoryColor ? getCategoryColor(note.category) : (note.color || '#6366f1')
const handleTogglePin = useCallback(() => {
onUpdate(note.id, { pinned: !note.pinned })
}, [note.id, note.pinned, onUpdate])
const handleDelete = useCallback(() => {
onDelete(note.id)
}, [note.id, onDelete])
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
position: 'relative',
borderRadius: 12,
overflow: 'hidden',
border: `1px solid ${note.pinned ? color + '40' : color + '25'}`,
background: note.pinned ? `${color}08` : 'var(--bg-card)',
display: 'flex',
flexDirection: 'column',
fontFamily: FONT,
transition: 'transform 0.12s, box-shadow 0.12s',
...(hovered ? { transform: 'translateY(-1px)', boxShadow: '0 4px 16px rgba(0,0,0,0.08)' } : {}),
}}
>
{/* Header bar — like reservation cards */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 10px',
background: `${color}0d`,
}}>
{!!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' }}>
{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' }}>
{note.category}
</span>
)}
</span>
{/* Hover actions in header */}
{(
<div style={{
display: 'flex', gap: 2,
}}>
{note.content && (
<button onClick={() => onView?.(note)} title={t('collab.notes.expand') || 'Expand'}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Maximize2 size={10} />
</button>
)}
{canEdit && <button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = color}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
</button>}
{canEdit && <button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={10} />
</button>}
{canEdit && <button onClick={handleDelete} title={t('collab.notes.delete')}
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={10} />
</button>}
<div style={{ width: 1, height: 12, background: 'var(--border-faint)', flexShrink: 0, marginLeft: 1, marginRight: 1 }} />
{/* Author avatar */}
<div style={{ position: 'relative', flexShrink: 0 }}
onMouseEnter={e => { const tip = e.currentTarget.querySelector<HTMLElement>('[data-tip]'); if (tip) tip.style.opacity = '1' }}
onMouseLeave={e => { const tip = e.currentTarget.querySelector<HTMLElement>('[data-tip]'); if (tip) tip.style.opacity = '0' }}>
<UserAvatar user={author} size={16} />
<div data-tip style={{
position: 'absolute', bottom: '100%', left: '50%', transform: 'translateX(-50%)',
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,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)',
}}>
{author.username}
</div>
</div>
</div>
)}
</div>
{/* Card body */}
<div style={{
padding: '8px 12px 10px',
display: 'flex',
flexDirection: 'column',
gap: 4,
flex: 1,
}}>
<div style={{ display: 'flex', gap: 8 }}>
<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,
maxHeight: '4.5em', overflow: 'hidden',
wordBreak: 'break-word', fontFamily: FONT,
}}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{note.content}</Markdown>
</div>
)}
</div>
{/* Right: website + attachment thumbnails */}
{(note.website || (note.attachments?.length ?? 0) > 0) && (
<div style={{ display: 'flex', gap: 6, flexShrink: 0, alignItems: 'flex-start' }}>
{/* 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>
<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>
<div style={{ display: 'flex', gap: 4 }}>
{(note.attachments || []).slice(0, note.website ? 1 : 2).map(a => {
const isImage = a.mime_type?.startsWith('image/')
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
return isImage ? (
<AuthedImg key={a.id} src={a.url} alt={a.original_name}
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
onClick={() => onPreviewFile?.(a)}
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' }} />
) : (
<div key={a.id} title={a.original_name} onClick={() => onPreviewFile?.(a)}
style={{
width: 48, height: 48, borderRadius: 8, cursor: 'pointer',
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
transition: 'transform 0.12s, box-shadow 0.12s',
}}
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>
</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>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,145 @@
import ReactDOM from 'react-dom'
import { useState } from 'react'
import { Plus, Trash2, X } from 'lucide-react'
import { FONT, NOTE_COLORS } from './CollabNotes.constants'
import { EditableCatName } from './CollabNotesEditableCatName'
// ── Category Settings Modal ──────────────────────────────────────────────────
interface CategorySettingsModalProps {
onClose: () => void
categories: string[]
categoryColors: Record<string, string>
onSave: (colors: Record<string, string>) => void
onRenameCategory: (oldName: string, newName: string) => Promise<void>
t: (key: string) => string
}
export function CategorySettingsModal({ onClose, categories, categoryColors, onSave, onRenameCategory, t }: CategorySettingsModalProps) {
const [localColors, setLocalColors] = useState({ ...categoryColors })
const [renames, setRenames] = useState<Record<string, string>>({}) // { oldName: newName }
const [newCatName, setNewCatName] = useState('')
const handleColorChange = (cat, color) => {
setLocalColors(prev => ({ ...prev, [cat]: color }))
}
const handleAddCategory = () => {
if (!newCatName.trim() || localColors[newCatName.trim()]) return
setLocalColors(prev => ({ ...prev, [newCatName.trim()]: NOTE_COLORS[Object.keys(prev).length % NOTE_COLORS.length].value }))
setNewCatName('')
}
const handleRemoveCategory = (cat) => {
setLocalColors(prev => { const n = { ...prev }; delete n[cat]; return n })
}
const handleRenameCategory = (oldName, newName) => {
if (!newName.trim() || newName.trim() === oldName || localColors[newName.trim()]) return
// Track rename for saving to DB later
const originalName = Object.entries(renames).find(([, v]) => v === oldName)?.[0] || oldName
setRenames(prev => ({ ...prev, [originalName]: newName.trim() }))
setLocalColors(prev => {
const n = {}
for (const [k, v] of Object.entries(prev)) {
n[k === oldName ? newName.trim() : k] = v
}
return n
})
}
const handleSave = async () => {
// Apply renames to notes in DB
for (const [oldName, newName] of Object.entries(renames)) {
if (oldName !== newName) await onRenameCategory(oldName, newName)
}
await onSave(localColors)
onClose()
}
// Merge existing categories from notes with saved colors
const allCats = [...new Set([...categories, ...Object.keys(localColors)])]
return ReactDOM.createPortal(
<div style={{
position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))',
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT,
}} onClick={onClose}>
<div style={{
background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 420,
maxHeight: '80vh', overflow: 'auto', border: '1px solid var(--border-faint)',
}} 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 }}>
{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' }}>
<X size={16} />
</button>
</div>
{/* 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 }}>
{t('collab.notes.noCategoriesYet') || 'No categories yet'}
</p>
)}
{allCats.map(cat => (
<div key={cat} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{/* Color swatches */}
<div style={{ display: 'flex', gap: 4 }}>
{NOTE_COLORS.map(c => (
<button key={c.value} onClick={() => handleColorChange(cat, c.value)} style={{
width: 20, height: 20, borderRadius: 6, background: c.value, border: 'none', cursor: 'pointer', padding: 0,
outline: (localColors[cat] || NOTE_COLORS[0].value) === c.value ? '2px solid var(--text-primary)' : '2px solid transparent',
outlineOffset: 1, transition: 'transform 0.1s',
transform: (localColors[cat] || NOTE_COLORS[0].value) === c.value ? 'scale(1.1)' : 'scale(1)',
}} />
))}
</div>
{/* Category name — editable */}
<EditableCatName name={cat} onRename={(newName) => handleRenameCategory(cat, newName)} />
{/* Delete */}
<button onClick={() => handleRemoveCategory(cat)} style={{
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 3, display: 'flex',
}}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={13} />
</button>
</div>
))}
{/* Add new */}
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
<input value={newCatName} onChange={e => setNewCatName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
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',
}} />
<button onClick={handleAddCategory} disabled={!newCatName.trim()} style={{
background: newCatName.trim() ? 'var(--accent)' : 'var(--border-primary)', color: 'var(--accent-text)',
border: 'none', borderRadius: 10, padding: '8px 14px', cursor: newCatName.trim() ? 'pointer' : 'default',
display: 'flex', alignItems: 'center', flexShrink: 0,
}}>
<Plus size={14} />
</button>
</div>
{/* 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,
}}>
{t('collab.notes.save')}
</button>
</div>
</div>
</div>,
document.body
)
}
@@ -0,0 +1,34 @@
import { useState, useEffect, useRef } from 'react'
interface EditableCatNameProps {
name: string
onRename: (newName: string) => void
}
export function EditableCatName({ name, onRename }: EditableCatNameProps) {
const [editing, setEditing] = useState(false)
const [value, setValue] = useState(name)
const inputRef = useRef(null)
useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing])
const save = () => {
setEditing(false)
if (value.trim() && value.trim() !== name) onRename(value.trim())
else setValue(name)
}
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' }} />
}
return (
<span onClick={() => { setValue(name); setEditing(true) }}
style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }}
title="Click to rename">
{name}
</span>
)
}
@@ -0,0 +1,73 @@
import ReactDOM from 'react-dom'
import { useState, useEffect } from 'react'
import { X, ExternalLink, Loader2 } from 'lucide-react'
import { getAuthUrl } from '../../api/authUrl'
import { openFile } from '../../utils/fileDownload'
import type { NoteFile } from './CollabNotes.types'
// ── File Preview Portal ─────────────────────────────────────────────────────
interface FilePreviewPortalProps {
file: NoteFile | null
onClose: () => void
}
export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
const [authUrl, setAuthUrl] = useState('')
const rawUrl = file?.url || ''
useEffect(() => {
setAuthUrl('')
if (!rawUrl) return
getAuthUrl(rawUrl, 'download').then(setAuthUrl)
}, [rawUrl])
if (!file) return null
const isImage = file.mime_type?.startsWith('image/')
const isPdf = file.mime_type === 'application/pdf'
const isTxt = file.mime_type?.startsWith('text/')
const openInNewTab = () => openFile(rawUrl).catch(() => {})
return ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
{isImage ? (
/* Image lightbox — floating controls */
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
{authUrl
? <img src={authUrl} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
: <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>
<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>
</div>
</div>
</div>
) : (
/* 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>
<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={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>
</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>
</div>
)}
</div>
)}
</div>,
document.body
)
}
@@ -0,0 +1,311 @@
import ReactDOM from 'react-dom'
import { useState, useRef } from 'react'
import { Plus, X } from 'lucide-react'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { FONT } from './CollabNotes.constants'
import { AuthedImg } from './CollabNotesAuthedImg'
import type { CollabNote } from './CollabNotes.types'
// ── New Note Modal (portal to body) ─────────────────────────────────────────
interface NoteFormModalProps {
onClose: () => void
onSubmit: (data: { title: string; content: string; category: string | null; website: string | null; color?: string | null; _pendingFiles?: File[]; files?: File[] }) => Promise<void>
onDeleteFile?: (noteId: number, fileId: number) => Promise<void>
existingCategories: string[]
categoryColors: Record<string, string>
getCategoryColor: (category: string) => string
note: CollabNote | null
tripId: number
t: (key: string) => string
}
export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) {
const can = useCanDo()
const tripObj = useTripStore((s) => s.trip)
const canUploadFiles = can('file_upload', tripObj)
const isEdit = !!note
const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
const [title, setTitle] = useState(note?.title || '')
const [content, setContent] = useState(note?.content || '')
const [category, setCategory] = useState(note?.category || allCategories[0] || '')
const [website, setWebsite] = useState(note?.website || '')
const [pendingFiles, setPendingFiles] = useState([])
const [existingAttachments, setExistingAttachments] = useState(note?.attachments || [])
const [submitting, setSubmitting] = useState(false)
const fileRef = useRef(null)
const finalCategory = category
const handleSubmit = async (e) => {
e.preventDefault()
if (!title.trim()) return
setSubmitting(true)
try {
await onSubmit({
title: title.trim(),
content: content.trim(),
category: finalCategory || null,
color: getCategoryColor(finalCategory),
website: website.trim() || null,
_pendingFiles: pendingFiles,
})
onClose()
} catch {
} finally {
setSubmitting(false)
}
}
const handleDeleteAttachment = async (fileId) => {
if (onDeleteFile && note) {
await onDeleteFile(note.id, fileId)
setExistingAttachments(prev => prev.filter(a => a.id !== fileId))
}
}
const canSubmit = title.trim() && !submitting
return ReactDOM.createPortal(
<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,
}}
>
<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()}
onPaste={e => {
if (!canUploadFiles) return
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
if (item.type.startsWith('image/') || item.type === 'application/pdf') {
e.preventDefault()
const file = item.getAsFile()
if (file) setPendingFiles(prev => [...prev, file])
return
}
}
}}
onSubmit={handleSubmit}
>
{/* Modal 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,
fontFamily: FONT,
}}>
{isEdit ? t('collab.notes.edit') : t('collab.notes.new')}
</h3>
<button
type="button"
onClick={onClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--text-faint)',
padding: 2,
borderRadius: 6,
display: 'flex',
}}
>
<X size={16} />
</button>
</div>
{/* Modal body */}
<div style={{
padding: '14px 16px 16px',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}>
{/* Title */}
<div>
<div style={{
fontSize: 9,
fontWeight: 600,
color: 'var(--text-faint)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: 4,
fontFamily: FONT,
}}>
{t('collab.notes.title')}
</div>
<input
autoFocus
value={title}
onChange={e => setTitle(e.target.value)}
placeholder={t('collab.notes.titlePlaceholder')}
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>
{/* Content */}
<div>
<div style={{
fontSize: 9,
fontWeight: 600,
color: 'var(--text-faint)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: 4,
fontFamily: FONT,
}}>
{t('collab.notes.contentPlaceholder')}
</div>
<textarea
value={content}
onChange={e => setContent(e.target.value)}
placeholder={t('collab.notes.contentPlaceholder')}
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',
resize: 'vertical',
minHeight: 180,
lineHeight: 1.5,
}}
/>
</div>
{/* Category pills */}
<div>
<div style={{ fontSize: 9, 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' }}>
{allCategories.map(cat => {
const c = getCategoryColor(cat)
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 }}>
{cat}
</button>
)
})}
</div>
</div>
{/* Website */}
<div>
<div style={{ fontSize: 9, 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' }} />
</div>
{/* File attachments */}
{canUploadFiles && <div>
<div style={{ fontSize: 9, 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 = '' }} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
{/* Existing attachments (edit mode) */}
{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)' }}>
{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' }}>
<X size={10} />
</button>
</div>
)
})}
{/* 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)' }}>
{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} />
</button>
</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 }}>
<Plus size={11} /> {t('files.attach') || 'Add'}
</button>
</div>
</div>}
{/* Submit */}
<button
type="submit"
disabled={!canSubmit}
style={{
width: '100%',
borderRadius: 99,
padding: '7px 14px',
background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)',
fontSize: 12,
fontWeight: 600,
fontFamily: FONT,
border: 'none',
cursor: canSubmit ? 'pointer' : 'default',
marginTop: 4,
}}
>
{submitting ? '...' : isEdit ? t('collab.notes.save') : t('collab.notes.create')}
</button>
</div>
</form>
</div>,
document.body
)
}
@@ -0,0 +1,48 @@
import { FONT } from './CollabNotes.constants'
import type { NoteAuthor } from './CollabNotes.types'
// ── Avatar ──────────────────────────────────────────────────────────────────
interface UserAvatarProps {
user: NoteAuthor | null
size?: number
}
export function UserAvatar({ user, size = 14 }: UserAvatarProps) {
if (!user) return null
if (user.avatar) {
return (
<img
src={user.avatar}
alt={user.username}
style={{
width: size,
height: size,
borderRadius: '50%',
objectFit: 'cover',
flexShrink: 0,
background: 'var(--bg-tertiary)',
}}
/>
)
}
const initials = (user.username || '?').slice(0, 1)
return (
<div style={{
width: size,
height: size,
borderRadius: '50%',
background: 'var(--bg-tertiary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: size * 0.45,
fontWeight: 600,
color: 'var(--text-faint)',
flexShrink: 0,
textTransform: 'uppercase',
fontFamily: FONT,
}}>
{initials}
</div>
)
}
@@ -0,0 +1,47 @@
import { useState, useEffect } from 'react'
import { ExternalLink } from 'lucide-react'
import { collabApi } from '../../api/client'
// ── Website Thumbnail (fetches OG image) ────────────────────────────────────
const ogCache = {}
interface WebsiteThumbnailProps {
url: string
tripId: number
color: string
}
export function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps) {
const [data, setData] = useState(ogCache[url] || null)
const [failed, setFailed] = useState(false)
useEffect(() => {
if (ogCache[url]) { setData(ogCache[url]); return }
collabApi.linkPreview(tripId, url).then(d => { ogCache[url] = d; setData(d) }).catch(() => setFailed(true))
}, [url, tripId])
const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return 'link' } })()
return (
<a href={url} target="_blank" rel="noopener noreferrer" title={data?.title || url}
style={{
width: 48, height: 48, borderRadius: 8, cursor: 'pointer', overflow: 'hidden',
background: data?.image ? 'none' : 'var(--bg-tertiary)', border: 'none',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 2,
textDecoration: 'none', transition: 'transform 0.12s, box-shadow 0.12s', flexShrink: 0,
}}
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' }}>
{data?.image && !failed ? (
<img src={data.image} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} onError={() => setFailed(true)} />
) : (
<>
<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' }}>
{domain}
</span>
</>
)}
</a>
)
}
+10 -14
View File
@@ -17,11 +17,7 @@ function useIsDesktop(breakpoint = 1024) {
return isDesktop
}
const card = {
display: 'flex', flexDirection: 'column',
background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-faint)',
overflow: 'hidden', minHeight: 0,
}
const cardClass = 'flex flex-col bg-surface-card rounded-2xl border border-edge-faint overflow-hidden min-h-0'
interface TripMember {
id: number
@@ -88,7 +84,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
// Only chat
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
<div style={{ ...card, flex: 1 }}>
<div className={cardClass} style={{ flex: 1 }}>
<CollabChat tripId={tripId} currentUser={user} />
</div>
</div>
@@ -99,19 +95,19 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
// Chat left (380px) + right panels
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
<div style={{ ...card, flex: '0 0 380px' }}>
<div className={cardClass} style={{ flex: '0 0 380px' }}>
<CollabChat tripId={tripId} currentUser={user} />
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
{rightPanels.length === 1 && (
<div style={{ ...card, flex: 1 }}>
<div className={cardClass} style={{ flex: 1 }}>
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
)}
{rightPanels.length === 2 && rightPanels.map(p => (
<div key={p} style={{ ...card, flex: 1 }}>
<div key={p} className={cardClass} style={{ flex: 1 }}>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
@@ -119,14 +115,14 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
))}
{rightPanels.length === 3 && (
<>
<div style={{ ...card, flex: 1 }}>
<div className={cardClass} style={{ flex: 1 }}>
<CollabNotes tripId={tripId} currentUser={user} />
</div>
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
<div style={{ ...card, flex: 1 }}>
<div className={cardClass} style={{ flex: 1 }}>
<CollabPolls tripId={tripId} currentUser={user} />
</div>
<div style={{ ...card, flex: 1 }}>
<div className={cardClass} style={{ flex: 1 }}>
<WhatsNextWidget tripMembers={tripMembers} />
</div>
</div>
@@ -142,7 +138,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
if (panels.length === 1) {
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
<div style={{ ...card, flex: 1 }}>
<div className={cardClass} style={{ flex: 1 }}>
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
@@ -154,7 +150,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
{panels.map(p => (
<div key={p} style={{ ...card, flex: 1 }}>
<div key={p} className={cardClass} style={{ flex: 1 }}>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
@@ -49,7 +49,7 @@ beforeEach(() => {
),
);
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 1 }) });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) });
});
describe('CollabPolls', () => {
+28 -15
View File
@@ -3,6 +3,7 @@ import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import ReactDOM from 'react-dom'
@@ -31,7 +32,7 @@ interface Poll {
created_at: string
}
const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
const FONT = "var(--font-system)"
function timeRemaining(deadline) {
if (!deadline) return null
@@ -78,7 +79,7 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
if (!canSubmit) return
setSubmitting(true)
try {
await onCreate({ question: question.trim(), options: options.filter(o => o.trim()), multiple_choice: multiChoice })
await onCreate({ question: question.trim(), options: options.filter(o => o.trim()), multi_choice: multiChoice })
onClose()
} catch {} finally { setSubmitting(false) }
}
@@ -230,7 +231,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
<Clock size={8} /> {remaining}
</span>
)}
{poll.multiple_choice && (
{poll.multi_choice && (
<span style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
{t('collab.polls.multiChoice')}
</span>
@@ -305,7 +306,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
flex: 1, fontSize: 13, fontWeight: myVote || isWinner ? 600 : 400,
color: 'var(--text-primary)', position: 'relative', zIndex: 1,
}}>
{typeof opt === 'string' ? opt : opt.label || opt}
{typeof opt === 'string' ? opt : opt.text}
</span>
{/* Voter avatars */}
@@ -342,6 +343,7 @@ interface CollabPollsProps {
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
const { t } = useTranslation()
const toast = useToast()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('collab_edit', trip)
@@ -378,33 +380,44 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
}, [])
const handleCreate = useCallback(async (data) => {
const result = await collabApi.createPoll(tripId, data)
const created = result.poll || result
setPolls(prev => prev.some(p => p.id === created.id) ? prev : [created, ...prev])
setShowForm(false)
}, [tripId])
try {
const result = await collabApi.createPoll(tripId, data)
const created = result.poll || result
setPolls(prev => prev.some(p => p.id === created.id) ? prev : [created, ...prev])
setShowForm(false)
} catch (err) {
toast.error(t('common.error'))
throw err
}
}, [tripId, toast, t])
const handleVote = useCallback(async (pollId, optionIndex) => {
try {
const result = await collabApi.votePoll(tripId, pollId, optionIndex)
const updated = result.poll || result
setPolls(prev => prev.map(p => p.id === updated.id ? updated : p))
} catch {}
}, [tripId])
} catch {
toast.error(t('common.error'))
}
}, [tripId, toast, t])
const handleClose = useCallback(async (pollId) => {
try {
await collabApi.closePoll(tripId, pollId)
setPolls(prev => prev.map(p => p.id === pollId ? { ...p, is_closed: true } : p))
} catch {}
}, [tripId])
} catch {
toast.error(t('common.error'))
}
}, [tripId, toast, t])
const handleDelete = useCallback(async (pollId) => {
try {
await collabApi.deletePoll(tripId, pollId)
setPolls(prev => prev.filter(p => p.id !== pollId))
} catch {}
}, [tripId])
} catch {
toast.error(t('common.error'))
}
}, [tripId, toast, t])
const activePolls = polls.filter(p => !p.is_closed && !isExpired(p.deadline))
const closedPolls = polls.filter(p => p.is_closed || isExpired(p.deadline))
@@ -32,22 +32,23 @@ function makeAssignment(id: number, placeOverrides: Record<string, unknown> = {}
notes: null,
place: {
id,
trip_id: 1,
name: `Place ${id}`,
description: null,
lat: 0,
lng: 0,
address: null,
category_id: null,
icon: null,
price: null,
currency: null,
image_url: null,
google_place_id: null,
osm_id: null,
route_geometry: null,
place_time: null,
end_time: null,
created_at: '2025-01-01T00:00:00.000Z',
duration_minutes: 60,
notes: null,
transport_mode: 'walking',
website: null,
phone: null,
...placeOverrides,
},
participants,
@@ -83,7 +84,7 @@ describe('WhatsNextWidget', () => {
it('FE-COMP-WHATSNEXT-002: shows empty state when all events are in the past', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: yesterday, title: 'Old Day', order: 0, assignments: [], notes_items: [], notes: null }],
days: [{ id: 1, trip_id: 1, date: yesterday, title: 'Old Day', day_number: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(10, { place_time: '08:00' })],
},
@@ -95,7 +96,7 @@ describe('WhatsNextWidget', () => {
it('FE-COMP-WHATSNEXT-003: shows a future-day event with place name', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(20, { name: 'Eiffel Tower' })],
},
@@ -106,7 +107,7 @@ describe('WhatsNextWidget', () => {
it('FE-COMP-WHATSNEXT-004: shows "Tomorrow" label for next-day group', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(21, { name: 'Museum' })],
},
@@ -118,7 +119,7 @@ describe('WhatsNextWidget', () => {
it('FE-COMP-WHATSNEXT-005: shows "Today" label for today\'s events with future time', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [{ id: 1, trip_id: 1, date: today, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(22, { name: 'Night Dinner', place_time: '23:59' })],
},
@@ -130,7 +131,7 @@ describe('WhatsNextWidget', () => {
it('FE-COMP-WHATSNEXT-006: renders event time in 24h format', () => {
seedStore(useSettingsStore, { settings: { time_format: '24h' } })
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(30, { name: 'Gallery', place_time: '14:30' })],
},
@@ -142,7 +143,7 @@ describe('WhatsNextWidget', () => {
it('FE-COMP-WHATSNEXT-007: renders event time in 12h format', () => {
seedStore(useSettingsStore, { settings: { time_format: '12h' } })
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(31, { name: 'Gallery', place_time: '14:30' })],
},
@@ -153,7 +154,7 @@ describe('WhatsNextWidget', () => {
it('FE-COMP-WHATSNEXT-008: shows "TBD" when event has no time', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(32, { name: 'Free Time', place_time: null })],
},
@@ -164,7 +165,7 @@ describe('WhatsNextWidget', () => {
it('FE-COMP-WHATSNEXT-009: renders address when provided', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(33, { name: 'Café', address: '123 Rue de Rivoli' })],
},
@@ -179,7 +180,7 @@ describe('WhatsNextWidget', () => {
trip_id: 1,
date: getFutureDate(i + 1),
title: null,
order: i,
day_number: i,
assignments: [],
notes_items: [],
notes: null,
@@ -207,7 +208,7 @@ describe('WhatsNextWidget', () => {
it('FE-COMP-WHATSNEXT-011: shows participant username chip', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(40, { name: 'Louvre' }, [{ user_id: 3, username: 'alice', avatar: null }])],
},
@@ -218,7 +219,7 @@ describe('WhatsNextWidget', () => {
it('FE-COMP-WHATSNEXT-012: falls back to tripMembers when assignment has no participants', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(41, { name: 'Park' }, [])],
},
@@ -229,7 +230,7 @@ describe('WhatsNextWidget', () => {
it('FE-COMP-WHATSNEXT-013: renders end time when provided', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(50, { name: 'Concert', place_time: '19:00', end_time: '21:30' })],
},
@@ -241,7 +242,7 @@ describe('WhatsNextWidget', () => {
it('FE-COMP-WHATSNEXT-014: multiple events on same day share one day header', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [
makeAssignment(60, { name: 'Breakfast', place_time: '08:00' }),
@@ -263,7 +264,7 @@ describe('WhatsNextWidget', () => {
if (now.getHours() > 0) {
const pastTime = '00:01' // Very early — will be past for most of the day
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [{ id: 1, trip_id: 1, date: today, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(70, { name: 'Early Bird', place_time: pastTime })],
},
@@ -30,6 +30,7 @@ function formatDayLabel(date, t, locale) {
interface TripMember {
id: number
username: string
avatar?: string | null
avatar_url?: string | null
}
@@ -0,0 +1,179 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { collabApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
export function useCollabChat(tripId: any, currentUser: any) {
const { t } = useTranslation()
const toast = useToast()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('collab_edit', trip)
const [messages, setMessages] = useState([])
const [loading, setLoading] = useState(true)
const [hasMore, setHasMore] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [text, setText] = useState('')
const [replyTo, setReplyTo] = useState(null)
const [hoveredId, setHoveredId] = useState(null)
const [sending, setSending] = useState(false)
const [showEmoji, setShowEmoji] = useState(false)
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
const [deletingIds, setDeletingIds] = useState(new Set())
const deleteTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
useEffect(() => {
return () => { deleteTimersRef.current.forEach(clearTimeout) }
}, [])
const containerRef = useRef(null)
const messagesRef = useRef(messages)
messagesRef.current = messages
const scrollRef = useRef(null)
const textareaRef = useRef(null)
const emojiBtnRef = useRef(null)
const isAtBottom = useRef(true)
const scrollToBottom = useCallback((behavior = 'auto') => {
const el = scrollRef.current
if (!el) return
requestAnimationFrame(() => el.scrollTo({ top: el.scrollHeight, behavior }))
}, [])
const checkAtBottom = useCallback(() => {
const el = scrollRef.current
if (!el) return
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48
}, [])
/* ── load messages ── */
useEffect(() => {
let cancelled = false
setLoading(true)
collabApi.getMessages(tripId).then(data => {
if (cancelled) return
const msgs = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
setMessages(msgs)
setHasMore(msgs.length >= 100)
setLoading(false)
setTimeout(() => scrollToBottom(), 30)
}).catch(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [tripId, scrollToBottom])
/* ── load more ── */
const handleLoadMore = useCallback(async () => {
if (loadingMore || messages.length === 0) return
setLoadingMore(true)
const el = scrollRef.current
const prevHeight = el ? el.scrollHeight : 0
try {
const data = await collabApi.getMessages(tripId, messages[0]?.id)
const older = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
if (older.length === 0) { setHasMore(false) }
else {
setMessages(prev => [...older, ...prev])
setHasMore(older.length >= 100)
requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight })
}
} catch {} finally { setLoadingMore(false) }
}, [tripId, loadingMore, messages])
/* ── websocket ── */
useEffect(() => {
const handler = (event) => {
if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) {
setMessages(prev => prev.some(m => m.id === event.message.id) ? prev : [...prev, event.message])
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 30)
}
if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) {
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, _deleted: true } : m))
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50)
}
if (event.type === 'collab:message:reacted' && String(event.tripId) === String(tripId)) {
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, reactions: event.reactions } : m))
}
}
addListener(handler)
return () => removeListener(handler)
}, [tripId, scrollToBottom])
/* ── auto-resize textarea ── */
const handleTextChange = useCallback((e) => {
setText(e.target.value)
const ta = textareaRef.current
if (ta) {
ta.style.height = 'auto'
const h = Math.min(ta.scrollHeight, 100)
ta.style.height = h + 'px'
ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden'
}
}, [])
/* ── send ── */
const handleSend = useCallback(async () => {
const body = text.trim()
if (!body || sending) return
setSending(true)
try {
const payload: { text: string; reply_to?: number } = { text: body }
if (replyTo) payload.reply_to = replyTo.id
const data = await collabApi.sendMessage(tripId, payload)
if (data?.message) {
setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
}
setText(''); setReplyTo(null); setShowEmoji(false)
if (textareaRef.current) textareaRef.current.style.height = 'auto'
isAtBottom.current = true
setTimeout(() => scrollToBottom('smooth'), 50)
} catch { toast.error(t('common.error')) } finally { setSending(false) }
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
}, [handleSend])
const handleDelete = useCallback(async (msgId) => {
const msg = messages.find(m => m.id === msgId)
requestAnimationFrame(() => {
setDeletingIds(prev => new Set(prev).add(msgId))
})
const timer = setTimeout(async () => {
try {
await collabApi.deleteMessage(tripId, msgId)
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
} catch { toast.error(t('common.error')) }
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
}, 400)
deleteTimersRef.current.push(timer)
}, [tripId, toast, t])
const handleReact = useCallback(async (msgId, emoji) => {
setReactMenu(null)
try {
const data = await collabApi.reactMessage(tripId, msgId, emoji)
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m))
} catch { toast.error(t('common.error')) }
}, [tripId, toast, t])
const handleEmojiSelect = useCallback((emoji) => {
setText(prev => prev + emoji)
textareaRef.current?.focus()
}, [])
const isOwn = (msg) => String(msg.user_id) === String(currentUser.id)
// Check if message is only emoji (1-3 emojis, no other text)
const isEmojiOnly = (text) => {
const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}[]?(?:\p{Extended_Pictographic}[]?)*){1,3}$/u
return emojiRegex.test(text.trim())
}
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
}
@@ -1,95 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { ArrowRightLeft, RefreshCw } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
const CURRENCIES = [
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD',
'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLF', 'CLP',
'CNH', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR',
'FJD', 'FKP', 'FOK', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK',
'HTG', 'HUF', 'IDR', 'ILS', 'IMP', 'INR', 'IQD', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR',
'KID', 'KMF', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA',
'MKD', 'MMK', 'MNT', 'MOP', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK',
'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF',
'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLE', 'SOS', 'SRD', 'SSP', 'STN', 'SYP', 'SZL', 'THB',
'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TVD', 'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES',
'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XDR', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMW', 'ZWL'
]
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
export default function CurrencyWidget() {
const { t, locale } = useTranslation()
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
const [amount, setAmount] = useState('100')
const [rate, setRate] = useState(null)
const [loading, setLoading] = useState(false)
const fetchRate = useCallback(async () => {
if (from === to) { setRate(1); return }
setLoading(true)
try {
const resp = await fetch(`https://api.exchangerate-api.com/v4/latest/${from}`)
const data = await resp.json()
setRate(data.rates?.[to] || null)
} catch { setRate(null) }
finally { setLoading(false) }
}, [from, to])
useEffect(() => { fetchRate() }, [fetchRate])
useEffect(() => { localStorage.setItem('currency_from', from) }, [from])
useEffect(() => { localStorage.setItem('currency_to', to) }, [to])
const swap = () => { setFrom(to); setTo(from) }
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
const formatNumber = (num) => {
if (!num || num === '—') return '—'
return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const result = rawResult
return (
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.currency')}</span>
<button onClick={fetchRate} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* Amount */}
<div className="rounded-xl px-4 py-3 mb-3" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
<input
type="number"
value={amount}
onChange={e => setAmount(e.target.value)}
className="w-full text-2xl font-black tabular-nums outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
style={{ color: 'var(--text-primary)', background: 'transparent', border: 'none' }}
/>
</div>
{/* From / Swap / To */}
<div className="flex items-center gap-2 mb-3">
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
<CustomSelect value={from} onChange={setFrom} options={CURRENCY_OPTIONS} searchable size="sm" />
</div>
<button onClick={swap} className="p-1.5 rounded-lg shrink-0 transition-colors" style={{ color: 'var(--text-muted)' }}>
<ArrowRightLeft size={13} />
</button>
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
<CustomSelect value={to} onChange={setTo} options={CURRENCY_OPTIONS} searchable size="sm" />
</div>
</div>
{/* Result */}
<div className="rounded-xl p-3" style={{ background: 'var(--bg-secondary)' }}>
<p className="text-xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>
{formatNumber(result)} <span className="text-sm font-semibold" style={{ color: 'var(--text-muted)' }}>{to}</span>
</p>
{rate && <p className="text-[10px] mt-0.5" style={{ color: 'var(--text-faint)' }}>1 {from} = {rate.toFixed(4)} {to}</p>}
</div>
</div>
)
}
@@ -1,149 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import TimezoneWidget from './TimezoneWidget'
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
localStorage.clear()
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
})
describe('TimezoneWidget', () => {
it('FE-COMP-TIMEZONE-001: renders without crashing with default zones', () => {
render(<TimezoneWidget />)
expect(document.body).toBeInTheDocument()
expect(screen.getByText('New York')).toBeInTheDocument()
expect(screen.getByText('Tokyo')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-002: shows local time text', () => {
render(<TimezoneWidget />)
const timeElements = screen.getAllByText(/\d{1,2}:\d{2}/)
expect(timeElements.length).toBeGreaterThan(0)
})
it('FE-COMP-TIMEZONE-003: shows timezone section label', () => {
render(<TimezoneWidget />)
expect(screen.getByText(/timezones/i)).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-004: default zones render on first load (no localStorage)', () => {
localStorage.clear()
render(<TimezoneWidget />)
expect(screen.getByText('New York')).toBeInTheDocument()
expect(screen.getByText('Tokyo')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-005: zones saved in localStorage are restored', () => {
localStorage.setItem('dashboard_timezones', JSON.stringify([{ label: 'Berlin', tz: 'Europe/Berlin' }]))
render(<TimezoneWidget />)
expect(screen.getByText('Berlin')).toBeInTheDocument()
expect(screen.queryByText('New York')).toBeNull()
})
it('FE-COMP-TIMEZONE-006: clicking the Plus button opens the add-zone panel', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
expect(await screen.findByText('Custom Timezone')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-007: adding a popular zone from the dropdown adds it to the list', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
// Open add panel
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
// Find and click Berlin in the popular zones list
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
await user.click(berlinButton)
expect(screen.getByText('Berlin')).toBeInTheDocument()
// Panel should be closed
expect(screen.queryByText('Custom Timezone')).toBeNull()
})
it('FE-COMP-TIMEZONE-008: adding a custom valid timezone with label shows in the list', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
// Open add panel
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
// Type label and timezone
const labelInput = screen.getByPlaceholderText('Label (optional)')
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(labelInput, 'My City')
await user.type(tzInput, 'Europe/Paris')
// Click Add
const addButton = screen.getByRole('button', { name: 'Add' })
await user.click(addButton)
expect(await screen.findByText('My City')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-009: adding a custom invalid timezone shows an error', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(tzInput, 'Invalid/Timezone')
const addButton = screen.getByRole('button', { name: 'Add' })
await user.click(addButton)
expect(await screen.findByText(/invalid timezone/i)).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-010: adding a duplicate timezone shows a duplicate error', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
// Default zones include New York (America/New_York)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(tzInput, 'America/New_York')
const addButton = screen.getByRole('button', { name: 'Add' })
await user.click(addButton)
expect(await screen.findByText(/already added/i)).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-011: remove button removes a zone from the list', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
expect(screen.getByText('New York')).toBeInTheDocument()
// The remove buttons are always in the DOM (opacity-0 in CSS, not hidden from DOM)
// There are 2 zone rows (New York, Tokyo), plus the Plus button = 3 buttons total
// Remove buttons for New York and Tokyo come after the Plus button
const allButtons = screen.getAllByRole('button')
// allButtons[0] = Plus, allButtons[1] = remove New York, allButtons[2] = remove Tokyo
await user.click(allButtons[1])
expect(screen.queryByText('New York')).toBeNull()
expect(screen.getByText('Tokyo')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-012: adding a zone persists to localStorage', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
await user.click(berlinButton)
const saved = JSON.parse(localStorage.getItem('dashboard_timezones') || '[]')
expect(saved.some((z: { tz: string }) => z.tz === 'Europe/Berlin')).toBe(true)
})
it('FE-COMP-TIMEZONE-013: Enter key in custom tz input triggers addCustomZone', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const labelInput = screen.getByPlaceholderText('Label (optional)')
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(labelInput, 'Singapore')
await user.type(tzInput, 'Asia/Singapore')
await user.keyboard('{Enter}')
expect(await screen.findByText('Singapore')).toBeInTheDocument()
})
})
@@ -1,167 +0,0 @@
import { useState, useEffect } from 'react'
import { Clock, Plus, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
const POPULAR_ZONES = [
{ label: 'New York', tz: 'America/New_York' },
{ label: 'London', tz: 'Europe/London' },
{ label: 'Berlin', tz: 'Europe/Berlin' },
{ label: 'Paris', tz: 'Europe/Paris' },
{ label: 'Dubai', tz: 'Asia/Dubai' },
{ label: 'Mumbai', tz: 'Asia/Kolkata' },
{ label: 'Bangkok', tz: 'Asia/Bangkok' },
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
{ label: 'Sydney', tz: 'Australia/Sydney' },
{ label: 'Los Angeles', tz: 'America/Los_Angeles' },
{ label: 'Chicago', tz: 'America/Chicago' },
{ label: 'São Paulo', tz: 'America/Sao_Paulo' },
{ label: 'Istanbul', tz: 'Europe/Istanbul' },
{ label: 'Singapore', tz: 'Asia/Singapore' },
{ label: 'Hong Kong', tz: 'Asia/Hong_Kong' },
{ label: 'Seoul', tz: 'Asia/Seoul' },
{ label: 'Moscow', tz: 'Europe/Moscow' },
{ label: 'Cairo', tz: 'Africa/Cairo' },
]
function getTime(tz, locale, is12h) {
try {
return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h })
} catch { return '—' }
}
function getOffset(tz) {
try {
const now = new Date()
const local = new Date(now.toLocaleString('en-US', { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }))
const remote = new Date(now.toLocaleString('en-US', { timeZone: tz }))
const diff = (remote - local) / 3600000
const sign = diff >= 0 ? '+' : ''
return `${sign}${diff}h`
} catch { return '' }
}
export default function TimezoneWidget() {
const { t, locale } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const [zones, setZones] = useState(() => {
const saved = localStorage.getItem('dashboard_timezones')
return saved ? JSON.parse(saved) : [
{ label: 'New York', tz: 'America/New_York' },
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
]
})
const [now, setNow] = useState(Date.now())
const [showAdd, setShowAdd] = useState(false)
const [customLabel, setCustomLabel] = useState('')
const [customTz, setCustomTz] = useState('')
const [customError, setCustomError] = useState('')
useEffect(() => {
const i = setInterval(() => setNow(Date.now()), 10000)
return () => clearInterval(i)
}, [])
useEffect(() => {
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
}, [zones])
const isValidTz = (tz: string) => {
try { Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date()); return true } catch { return false }
}
const addCustomZone = () => {
const tz = customTz.trim()
if (!tz) { setCustomError(t('dashboard.timezoneCustomErrorEmpty')); return }
if (!isValidTz(tz)) { setCustomError(t('dashboard.timezoneCustomErrorInvalid')); return }
if (zones.find(z => z.tz === tz)) { setCustomError(t('dashboard.timezoneCustomErrorDuplicate')); return }
const label = customLabel.trim() || tz.split('/').pop()?.replace(/_/g, ' ') || tz
setZones([...zones, { label, tz }])
setCustomLabel(''); setCustomTz(''); setCustomError(''); setShowAdd(false)
}
const addZone = (zone) => {
if (!zones.find(z => z.tz === zone.tz)) {
setZones([...zones, zone])
}
setShowAdd(false)
}
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
// Show abbreviated timezone name (e.g. CET, CEST, EST)
const tzAbbr = new Date().toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop()
return (
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezone')}</span>
<button onClick={() => setShowAdd(!showAdd)} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
<Plus size={12} />
</button>
</div>
{/* Local time */}
<div className="mb-3 pb-3" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<p className="text-2xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>{localTime}</p>
<p className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{localZone} ({tzAbbr}) · {t('dashboard.localTime')}</p>
</div>
{/* Zone list */}
<div className="space-y-2">
{zones.map(z => (
<div key={z.tz} className="flex items-center justify-between group">
<div>
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale, is12h)}</p>
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
</div>
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
<X size={11} />
</button>
</div>
))}
</div>
{/* Add zone dropdown */}
{showAdd && (
<div className="mt-2 rounded-xl p-2 max-h-[280px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
{/* Custom timezone */}
<div className="px-2 py-2 mb-2 rounded-lg" style={{ background: 'var(--bg-card)' }}>
<p className="text-[10px] font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezoneCustomTitle')}</p>
<div className="space-y-1.5">
<input value={customLabel} onChange={e => setCustomLabel(e.target.value)}
placeholder={t('dashboard.timezoneCustomLabelPlaceholder')}
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-secondary)' }} />
<input value={customTz} onChange={e => { setCustomTz(e.target.value); setCustomError('') }}
placeholder={t('dashboard.timezoneCustomTzPlaceholder')}
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: `1px solid ${customError ? '#ef4444' : 'var(--border-secondary)'}` }}
onKeyDown={e => { if (e.key === 'Enter') addCustomZone() }} />
{customError && <p className="text-[10px]" style={{ color: '#ef4444' }}>{customError}</p>}
<button onClick={addCustomZone}
className="w-full py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
{t('dashboard.timezoneCustomAdd')}
</button>
</div>
</div>
{/* Popular zones */}
{POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
<button key={z.tz} onClick={() => addZone(z)}
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<span className="font-medium">{z.label}</span>
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale, is12h)}</span>
</button>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1 @@
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
@@ -0,0 +1,44 @@
import { FileText, FileImage, File, Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { downloadFile } from '../../utils/fileDownload'
export function isImage(mimeType?: string | null) {
if (!mimeType) return false
return mimeType.startsWith('image/')
}
export function getFileIcon(mimeType?: string | null) {
if (!mimeType) return File
if (mimeType === 'application/pdf') return FileText
if (isImage(mimeType)) return FileImage
return File
}
export function formatSize(bytes?: number | null) {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
export function triggerDownload(url: string, filename: string) {
downloadFile(url, filename).catch(() => {})
}
export function formatDateWithLocale(dateStr?: string | null, locale?: string) {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
} catch { return '' }
}
export function transportIcon(type: string) {
if (type === 'train') return Train
if (type === 'bus') return Bus
if (type === 'car') return Car
if (type === 'taxi') return CarTaxiFront
if (type === 'bicycle') return Bike
if (type === 'cruise') return Ship
if (type === 'ferry') return Sailboat
if (type === 'transport_other') return Route
return Plane
}
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import type { TripFile } from '../../types';
import FileManager from './FileManager';
// Mock getAuthUrl
@@ -36,20 +37,21 @@ vi.mock('../../api/client', async (importOriginal) => {
import { filesApi } from '../../api/client';
const buildFile = (overrides = {}) => ({
const buildFile = (overrides: Partial<TripFile> = {}): TripFile => ({
id: 1,
trip_id: 1,
filename: 'report.pdf',
original_name: 'report.pdf',
mime_type: 'application/pdf',
file_size: 51200,
created_at: '2025-01-10T08:00:00Z',
url: '/uploads/trips/1/report.pdf',
starred: false,
starred: 0,
deleted_at: null,
place_id: null,
reservation_id: null,
day_id: null,
uploaded_by: 1,
uploader_name: 'Alice',
uploaded_by_name: 'Alice',
...overrides,
});
@@ -320,8 +322,8 @@ describe('FileManager', () => {
it('FE-COMP-FILEMANAGER-018: starred filter shows only starred files', async () => {
const files = [
buildFile({ id: 1, original_name: 'starred.pdf', starred: true }),
buildFile({ id: 2, original_name: 'normal.pdf', starred: false }),
buildFile({ id: 1, original_name: 'starred.pdf', starred: 1 }),
buildFile({ id: 2, original_name: 'normal.pdf', starred: 0 }),
];
render(<FileManager {...defaultProps} files={files} />);
const user = userEvent.setup();
@@ -388,7 +390,7 @@ describe('FileManager', () => {
it('FE-COMP-FILEMANAGER-023: assign modal shows reservations list', async () => {
const { buildReservation } = await import('../../../tests/helpers/factories');
const reservation = buildReservation({ id: 20, name: 'Hotel Paris' });
const reservation = buildReservation({ id: 20, title: 'Hotel Paris' });
render(<FileManager {...defaultProps} files={[buildFile()]} reservations={[reservation]} />);
const user = userEvent.setup();
@@ -418,7 +420,7 @@ describe('FileManager', () => {
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls filesApi.update', async () => {
const { buildReservation } = await import('../../../tests/helpers/factories');
const reservation = buildReservation({ id: 20, name: 'Train Ticket' });
const reservation = buildReservation({ id: 20, title: 'Train Ticket' });
const file = buildFile({ id: 1 });
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
const user = userEvent.setup();
@@ -436,7 +438,7 @@ describe('FileManager', () => {
it('FE-COMP-FILEMANAGER-026: assign modal with both places and reservations shows both sections', async () => {
const { buildPlace, buildReservation } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Notre Dame' });
const reservation = buildReservation({ id: 20, name: 'Airbnb' });
const reservation = buildReservation({ id: 20, title: 'Airbnb' });
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} reservations={[reservation]} />);
const user = userEvent.setup();
@@ -527,7 +529,7 @@ describe('FileManager', () => {
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls filesApi.update', async () => {
const { buildReservation } = await import('../../../tests/helpers/factories');
const reservation = buildReservation({ id: 20, name: 'Museum Pass' });
const reservation = buildReservation({ id: 20, title: 'Museum Pass' });
// File already has reservation_id set to 20
const file = buildFile({ id: 1, reservation_id: 20 });
+15 -954
View File
@@ -1,968 +1,29 @@
import ReactDOM from 'react-dom'
import { useState, useCallback, useRef, useEffect } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react'
import { useToast } from '../shared/Toast'
import { useTranslation } 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 { downloadFile, openFile as openFileUrl } from '../../utils/fileDownload'
function isImage(mimeType) {
if (!mimeType) return false
return mimeType.startsWith('image/')
}
function getFileIcon(mimeType) {
if (!mimeType) return File
if (mimeType === 'application/pdf') return FileText
if (isImage(mimeType)) return FileImage
return File
}
function formatSize(bytes) {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function triggerDownload(url: string, filename: string) {
downloadFile(url, filename).catch(() => {})
}
function formatDateWithLocale(dateStr, locale) {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
} catch { return '' }
}
// Image lightbox with gallery navigation
interface ImageLightboxProps {
files: (TripFile & { url: string })[]
initialIndex: number
onClose: () => void
}
function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
const { t } = useTranslation()
const [index, setIndex] = useState(initialIndex)
const [imgSrc, setImgSrc] = useState('')
const [touchStart, setTouchStart] = useState<number | null>(null)
const file = files[index]
useEffect(() => {
setImgSrc('')
if (file) getAuthUrl(file.url, 'download').then(setImgSrc)
}, [file?.url])
const goPrev = () => setIndex(i => Math.max(0, i - 1))
const goNext = () => setIndex(i => Math.min(files.length - 1, i + 1))
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowLeft') goPrev()
if (e.key === 'ArrowRight') goNext()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
if (!file) return null
const hasPrev = index > 0
const hasNext = index < files.length - 1
const navBtn = (side: 'left' | 'right', onClick: () => void, show: boolean): React.ReactNode => show ? (
<button onClick={e => { e.stopPropagation(); onClick() }}
style={{
position: 'absolute', top: '50%', [side]: 12, transform: 'translateY(-50%)', zIndex: 10,
background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: 40, height: 40,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
color: 'rgba(255,255,255,0.8)', transition: 'background 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.75)')}
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.5)')}>
{side === 'left' ? <ChevronLeft size={22} /> : <ChevronRight size={22} />}
</button>
) : null
import { useFileManager, type FileManagerProps } from './useFileManager'
import { ImageLightbox } from './FileManagerImageLightbox'
import { AssignModal } from './FileManagerAssignModal'
import { PdfPreviewModal } from './FileManagerPdfPreviewModal'
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
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column', paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
onTouchEnd={e => {
if (touchStart === null) return
const diff = e.changedTouches[0].clientX - touchStart
if (diff > 60) goPrev()
else if (diff < -60) goNext()
setTouchStart(null)
}}
>
{/* 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 }}>
{file.original_name}
<span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span>
</span>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button
onClick={() => openFileUrl(file.url, file.original_name).catch(() => {})}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
title={t('files.openTab')}>
<ExternalLink size={16} />
</button>
<button
onClick={() => triggerDownload(file.url, file.original_name)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
title={t('files.download') || 'Download'}>
<Download size={16} />
</button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}>
<X size={18} />
</button>
</div>
</div>
{/* Main image + nav */}
<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()} />}
{navBtn('right', goNext, hasNext)}
</div>
{/* Thumbnail strip */}
{files.length > 1 && (
<div style={{ display: 'flex', gap: 4, justifyContent: 'center', padding: '10px 16px', flexShrink: 0, overflowX: 'auto' }} onClick={e => e.stopPropagation()}>
{files.map((f, i) => (
<ThumbImg key={f.id} file={f} active={i === index} onClick={() => setIndex(i)} />
))}
</div>
)}
</div>
)
}
function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) {
const [src, setSrc] = useState('')
useEffect(() => { getAuthUrl(file.url, 'download').then(setSrc) }, [file.url])
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',
}}>
{src && <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />}
</button>
)
}
// Authenticated image — fetches a short-lived download token and renders the image
function AuthedImg({ src, style }: { src: string; style?: React.CSSProperties }) {
const [authSrc, setAuthSrc] = useState('')
useEffect(() => {
getAuthUrl(src, 'download').then(setAuthSrc)
}, [src])
return authSrc ? <img src={authSrc} alt="" style={style} /> : null
}
// Source badge
interface SourceBadgeProps {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
label: string
}
function SourceBadge({ icon: Icon, label }: SourceBadgeProps) {
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 10.5, color: '#4b5563',
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
borderRadius: 6, padding: '2px 7px',
fontWeight: 500, maxWidth: '100%', overflow: 'hidden',
}}>
<Icon size={10} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</span>
)
}
function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avatarUrl?: string | null; size?: number }) {
const [hover, setHover] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const ref = useRef<HTMLDivElement>(null)
const onEnter = () => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect()
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
}
setHover(true)
}
return (
<>
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
style={{
width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)',
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
cursor: 'default',
}}>
{avatarUrl
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: name?.[0]?.toUpperCase()
}
</div>
{hover && ReactDOM.createPortal(
<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,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', whiteSpace: 'nowrap', zIndex: 9999,
pointerEvents: 'none',
}}>
{name}
</div>,
document.body
)}
</>
)
}
interface FileManagerProps {
files?: TripFile[]
onUpload: (fd: FormData) => Promise<any>
onDelete: (fileId: number) => Promise<void>
onUpdate: (fileId: number, data: Partial<TripFile>) => Promise<void>
places: Place[]
days?: Day[]
assignments?: AssignmentsMap
reservations?: Reservation[]
tripId: number
allowedFileTypes: Record<string, string[]>
}
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
const [uploading, setUploading] = useState(false)
const [filterType, setFilterType] = useState('all')
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
const [showTrash, setShowTrash] = useState(false)
const [trashFiles, setTrashFiles] = useState<TripFile[]>([])
const [loadingTrash, setLoadingTrash] = useState(false)
const toast = useToast()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const { t, locale } = useTranslation()
const loadTrash = useCallback(async () => {
setLoadingTrash(true)
try {
const data = await filesApi.list(tripId, true)
setTrashFiles(data.files || [])
} catch { /* */ }
setLoadingTrash(false)
}, [tripId])
const toggleTrash = useCallback(() => {
if (!showTrash) loadTrash()
setShowTrash(v => !v)
}, [showTrash, loadTrash])
const refreshFiles = useCallback(async () => {
if (onUpdate) onUpdate(0, {} as any)
}, [onUpdate])
const handleStar = async (fileId: number) => {
try {
await filesApi.toggleStar(tripId, fileId)
refreshFiles()
} catch { /* */ }
}
const handleRestore = async (fileId: number) => {
try {
await filesApi.restore(tripId, fileId)
setTrashFiles(prev => prev.filter(f => f.id !== fileId))
refreshFiles()
toast.success(t('files.toast.restored'))
} catch {
toast.error(t('files.toast.restoreError'))
}
}
const handlePermanentDelete = async (fileId: number) => {
if (!confirm(t('files.confirm.permanentDelete'))) return
try {
await filesApi.permanentDelete(tripId, fileId)
setTrashFiles(prev => prev.filter(f => f.id !== fileId))
toast.success(t('files.toast.deleted'))
} catch {
toast.error(t('files.toast.deleteError'))
}
}
const handleEmptyTrash = async () => {
if (!confirm(t('files.confirm.emptyTrash'))) return
try {
await filesApi.emptyTrash(tripId)
setTrashFiles([])
toast.success(t('files.toast.trashEmptied') || 'Trash emptied')
} catch {
toast.error(t('files.toast.deleteError'))
}
}
const [lastUploadedIds, setLastUploadedIds] = useState<number[]>([])
const onDrop = useCallback(async (acceptedFiles) => {
if (acceptedFiles.length === 0) return
setUploading(true)
const uploadedIds: number[] = []
try {
for (const file of acceptedFiles) {
const formData = new FormData()
formData.append('file', file)
const result = await onUpload(formData)
const fileObj = result?.file || result
if (fileObj?.id) uploadedIds.push(fileObj.id)
}
toast.success(t('files.uploaded', { count: acceptedFiles.length }))
// Open assign modal for the last uploaded file
const lastId = uploadedIds[uploadedIds.length - 1]
if (lastId && (places.length > 0 || reservations.length > 0)) {
setAssignFileId(lastId)
}
} catch {
toast.error(t('files.uploadError'))
} finally {
setUploading(false)
}
}, [onUpload, toast, t, places, reservations])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxSize: 50 * 1024 * 1024,
noClick: false,
})
const handlePaste = useCallback((e) => {
if (!can('file_upload', trip)) return
const items = e.clipboardData?.items
if (!items) return
const pastedFiles = []
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) pastedFiles.push(file)
}
}
if (pastedFiles.length > 0) {
e.preventDefault()
onDrop(pastedFiles)
}
}, [onDrop])
const filteredFiles = files.filter(f => {
if (filterType === 'starred') return !!f.starred
if (filterType === 'pdf') return f.mime_type === 'application/pdf'
if (filterType === 'image') return isImage(f.mime_type)
if (filterType === 'doc') return (f.mime_type || '').includes('word') || (f.mime_type || '').includes('excel') || (f.mime_type || '').includes('text')
if (filterType === 'collab') return !!f.note_id
return true
})
const handleDelete = async (id) => {
try {
await onDelete(id)
toast.success(t('files.toast.trashed') || 'Moved to trash')
} catch {
toast.error(t('files.toast.deleteError'))
}
}
const [previewFile, setPreviewFile] = useState(null)
const [previewFileUrl, setPreviewFileUrl] = useState('')
useEffect(() => {
if (previewFile) {
getAuthUrl(previewFile.url, 'download').then(setPreviewFileUrl)
} else {
setPreviewFileUrl('')
}
}, [previewFile?.url])
const [assignFileId, setAssignFileId] = useState<number | null>(null)
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
try {
await filesApi.update(tripId, fileId, data)
refreshFiles()
} catch {
toast.error(t('files.toast.assignError'))
}
}
const imageFiles = filteredFiles.filter(f => isImage(f.mime_type))
const openFile = (file) => {
if (isImage(file.mime_type)) {
const idx = imageFiles.findIndex(f => f.id === file.id)
setLightboxIndex(idx >= 0 ? idx : 0)
} else {
setPreviewFile(file)
}
}
const renderFileRow = (file: TripFile, isTrash = false) => {
const FileIcon = getFileIcon(file.mime_type)
const allLinkedPlaceIds = new Set<number>()
if (file.place_id) allLinkedPlaceIds.add(file.place_id)
for (const pid of (file.linked_place_ids || [])) allLinkedPlaceIds.add(pid)
const linkedPlaces = [...allLinkedPlaceIds].map(pid => places?.find(p => p.id === pid)).filter(Boolean)
// All linked reservations (primary + file_links)
const allLinkedResIds = new Set<number>()
if (file.reservation_id) allLinkedResIds.add(file.reservation_id)
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid)
const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean)
return (
<div key={file.id} style={{
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 10,
transition: 'border-color 0.12s',
opacity: isTrash ? 0.7 : 1,
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
className="group"
>
{/* Icon or thumbnail */}
<div
onClick={() => !isTrash && openFile(file)}
style={{
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: isTrash ? 'default' : 'pointer', overflow: 'hidden',
}}
>
{isImage(file.mime_type)
? <AuthedImg src={file.url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: (() => {
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
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>
</div>
)
})()
}
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
{file.uploaded_by_name && (
<AvatarChip name={file.uploaded_by_name} avatarUrl={file.uploaded_by_avatar} size={20} />
)}
{!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' }}
>
{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>
)}
<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>
{linkedPlaces.map(p => (
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
))}
{linkedReservations.map(r => (
<SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
))}
{file.note_id && (
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
)}
</div>
</div>
{/* Actions — always visible on mobile, hover on desktop */}
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{isTrash ? (
<>
{can('file_delete', trip) && <button onClick={() => handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#22c55e'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<RotateCcw size={14} />
</button>}
{can('file_delete', trip) && <button onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={14} />
</button>}
</>
) : (
<>
<button onClick={() => handleStar(file.id)} title={file.starred ? t('files.unstar') || 'Unstar' : t('files.star') || 'Star'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: file.starred ? '#facc15' : 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => { if (!file.starred) e.currentTarget.style.color = '#facc15' }} onMouseLeave={e => { if (!file.starred) e.currentTarget.style.color = 'var(--text-faint)' }}>
<Star size={14} fill={file.starred ? '#facc15' : 'none'} />
</button>
{can('file_edit', trip) && <button onClick={() => setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={14} />
</button>}
<button onClick={() => openFile(file)} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ExternalLink size={14} />
</button>
<button onClick={() => triggerDownload(file.url, file.original_name)} title={t('files.download') || 'Download'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Download size={14} />
</button>
{can('file_delete', trip) && <button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={14} />
</button>}
</>
)}
</div>
</div>
)
}
return (
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} onPaste={handlePaste} tabIndex={-1}>
<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)} />}
{/* Assign modal */}
{assignFileId && ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 5000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setAssignFileId(null)}>
<div style={{
background: 'var(--bg-card)', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
width: 'min(600px, calc(100vw - 32px))', maxHeight: '70vh', overflow: 'hidden', display: 'flex', flexDirection: 'column',
}} 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' }}>
{files.find(f => f.id === assignFileId)?.original_name || ''}
</div>
</div>
<button onClick={() => setAssignFileId(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4, display: 'flex', flexShrink: 0 }}>
<X size={18} />
</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 }}>
{t('files.noteLabel') || 'Note'}
</div>
<input
type="text"
placeholder={t('files.notePlaceholder')}
defaultValue={files.find(f => f.id === assignFileId)?.description || ''}
onBlur={e => {
const val = e.target.value.trim()
const file = files.find(f => f.id === assignFileId)
if (file && val !== (file.description || '')) {
handleAssign(file.id, { description: val } as any)
}
}}
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
style={{
width: '100%', padding: '7px 10px', fontSize: 13, borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
}}
/>
</div>
<div style={{ overflowY: 'auto', padding: 8 }}>
{(() => {
const file = files.find(f => f.id === assignFileId)
if (!file) return null
const assignedPlaceIds = new Set<number>()
const dayGroups: { day: Day; dayPlaces: Place[] }[] = []
for (const day of days) {
const da = assignments[String(day.id)] || []
const dayPlaces = da.map(a => places.find(p => p.id === a.place?.id || p.id === a.place_id)).filter(Boolean) as Place[]
if (dayPlaces.length > 0) {
dayGroups.push({ day, dayPlaces })
dayPlaces.forEach(p => assignedPlaceIds.add(p.id))
}
}
const unassigned = places.filter(p => !assignedPlaceIds.has(p.id))
const placeBtn = (p: Place) => {
const isLinked = file.place_id === p.id || (file.linked_place_ids || []).includes(p.id)
return (
<button key={p.id} onClick={async () => {
if (isLinked) {
if (file.place_id === p.id) {
await handleAssign(file.id, { place_id: null })
} else {
try {
const linksRes = await filesApi.getLinks(tripId, file.id)
const link = (linksRes.links || []).find((l: any) => l.place_id === p.id)
if (link) await filesApi.removeLink(tripId, file.id, link.id)
refreshFiles()
} catch {}
}
} else {
if (!file.place_id) {
await handleAssign(file.id, { place_id: p.id })
} else {
try {
await filesApi.addLink(tripId, file.id, { place_id: p.id })
refreshFiles()
} catch {}
}
}
}} 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)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
}
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 }}>
{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' }}>
<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)',
background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 999,
}}>{badge}</span>
) : null
})()}
</div>
{dayPlaces.map(placeBtn)}
</div>
))}
{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>}
{unassigned.map(placeBtn)}
</div>
)}
</div>
)
const bookingsSection = reservations.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 }}>
{t('files.assignBooking')}
</div>
{reservations.map(r => {
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
return (
<button key={r.id} onClick={async () => {
if (isLinked) {
// Unlink: if primary reservation_id, clear it; if via file_links, remove link
if (file.reservation_id === r.id) {
await handleAssign(file.id, { reservation_id: null })
} else {
try {
const linksRes = await filesApi.getLinks(tripId, file.id)
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
if (link) await filesApi.removeLink(tripId, file.id, link.id)
refreshFiles()
} catch {}
}
} else {
// Link: if no primary, set it; otherwise use file_links
if (!file.reservation_id) {
await handleAssign(file.id, { reservation_id: r.id })
} else {
try {
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
refreshFiles()
} catch {}
}
}
}} 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)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
})}
</div>
)
const hasBoth = placesSection && bookingsSection
return (
<div className={hasBoth ? 'md:flex' : ''}>
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingRight: hasBoth ? 6 : 0 }}>{placesSection}</div>
{hasBoth && <div className="hidden md:block" style={{ width: 1, background: 'var(--border-primary)', flexShrink: 0 }} />}
{hasBoth && <div className="block md:hidden" style={{ height: 1, background: 'var(--border-primary)', margin: '8px 0' }} />}
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingLeft: hasBoth ? 6 : 0 }}>{bookingsSection}</div>
</div>
)
})()}
</div>
</div>
</div>,
document.body
)}
{assignFileId && <AssignModal {...S} />}
{/* PDF preview modal */}
{previewFile && 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: 950, 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: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<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' }}
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' }}
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'}
</button>
<button onClick={() => setPreviewFile(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<X size={18} />
</button>
</div>
</div>
<object
data={previewFileUrl ? `${previewFileUrl}#view=FitH` : undefined}
type="application/pdf"
style={{ flex: 1, width: '100%', border: 'none' }}
title={previewFile.original_name}
>
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<button onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
</p>
</object>
</div>
</div>,
document.body
)}
{previewFile && <PdfPreviewModal {...S} />}
{/* Toolbar */}
<div style={{ padding: '24px 28px 0', flexShrink: 0 }} className="max-md:!px-4 max-md:!pt-4">
<div style={{
background: 'var(--bg-tertiary)', borderRadius: 18,
padding: '14px 16px 14px 22px',
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}
</h2>
<FileManagerToolbar {...S} />
{!showTrash && (
<>
<div className="hidden md:block" style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
<div className="hidden md:inline-flex" style={{ gap: 4, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
{[
{ id: 'all', label: t('files.filterAll') },
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star } as const] : []),
{ id: 'pdf', label: t('files.filterPdf') },
{ id: 'image', label: t('files.filterImages') },
{ id: 'doc', label: t('files.filterDocs') },
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
].map(tab => {
const active = filterType === tab.id
const TabIcon = 'icon' in tab ? tab.icon : null
const count = tab.id === 'all' ? files.length
: tab.id === 'starred' ? files.filter(f => f.starred).length
: tab.id === 'pdf' ? files.filter(f => (f.mime_type || '').includes('pdf') || /\.pdf$/i.test(f.original_name)).length
: tab.id === 'image' ? files.filter(f => (f.mime_type || '').startsWith('image/')).length
: tab.id === 'doc' ? files.filter(f => /\.(docx?|xlsx?|txt|csv)$/i.test(f.original_name)).length
: tab.id === 'collab' ? files.filter(f => f.note_id).length
: 0
return (
<button key={tab.id} onClick={() => setFilterType(tab.id)}
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
background: active ? 'var(--bg-card)' : 'transparent',
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
fontWeight: active ? 500 : 400,
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
transition: 'all 0.15s ease',
}}
>
{TabIcon ? <TabIcon size={13} fill={active ? '#facc15' : 'none'} color={active ? '#facc15' : 'currentColor'} /> : null}
{'label' in tab && tab.label}
<span style={{
fontSize: 10, fontWeight: 600,
background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
color: 'var(--text-faint)',
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
}}>{count}</span>
</button>
)
})}
</div>
</>
)}
<button onClick={toggleTrash} style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)',
flexShrink: 0, marginLeft: 'auto',
opacity: showTrash ? 1 : 0.88,
transition: 'opacity 0.15s ease',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '1'}
onMouseLeave={e => e.currentTarget.style.opacity = showTrash ? '1' : '0.88'}
>
<Trash2 size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">{t('files.trash') || 'Trash'}</span>
</button>
</div>
</div>
{showTrash ? (
/* Trash view */
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
{trashFiles.length > 0 && can('file_delete', trip) && (
<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,
cursor: 'pointer', fontFamily: 'inherit',
}}>
{t('files.emptyTrash') || 'Empty Trash'}
</button>
</div>
)}
{loadingTrash ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text-faint)' }}>
<div style={{ width: 20, height: 20, border: '2px solid var(--text-faint)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto' }} />
</div>
) : 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>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{trashFiles.map(file => renderFileRow(file, true))}
</div>
)}
</div>
) : (
<>
{/* Upload zone */}
{can('file_upload', trip) && <div
{...getRootProps()}
style={{
margin: '16px 28px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
}}
>
<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={{ 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 }}>
{(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
</p>
</>
)}
</div>}
{/* Filter tabs */}
<div className="md:!hidden" style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
{[
{ id: 'all', label: t('files.filterAll') },
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []),
{ id: 'pdf', label: t('files.filterPdf') },
{ id: 'image', label: t('files.filterImages') },
{ id: 'doc', label: t('files.filterDocs') },
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
].map(tab => (
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
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' }}>
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
</span>
</div>
{/* File list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 28px 16px' }} className="max-md:!px-4">
{filteredFiles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
<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>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredFiles.map(file => renderFileRow(file))}
</div>
)}
</div>
</>
)}
{showTrash ? <TrashView {...S} /> : <FilesView {...S} />}
<style>{`
@media (max-width: 767px) {
@@ -0,0 +1,218 @@
import ReactDOM from 'react-dom'
import { X, MapPin, Ticket, Check } from 'lucide-react'
import { filesApi } from '../../api/client'
import type { Place, Reservation, Day } from '../../types'
import type { FileManagerState } from './useFileManager'
import { TRANSPORT_TYPES } from './FileManager.constants'
import { transportIcon } from './FileManager.helpers'
export function AssignModal(S: FileManagerState) {
const { files, assignFileId, setAssignFileId, t, days, assignments, places, reservations, tripId, handleAssign, refreshFiles } = S
return ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 5000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setAssignFileId(null)}>
<div style={{
background: 'var(--bg-card)', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
width: 'min(600px, calc(100vw - 32px))', maxHeight: '70vh', overflow: 'hidden', display: 'flex', flexDirection: 'column',
}} 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' }}>
{files.find(f => f.id === assignFileId)?.original_name || ''}
</div>
</div>
<button onClick={() => setAssignFileId(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4, display: 'flex', flexShrink: 0 }}>
<X size={18} />
</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 }}>
{t('files.noteLabel') || 'Note'}
</div>
<input
type="text"
placeholder={t('files.notePlaceholder')}
defaultValue={files.find(f => f.id === assignFileId)?.description || ''}
onBlur={e => {
const val = e.target.value.trim()
const file = files.find(f => f.id === assignFileId)
if (file && val !== (file.description || '')) {
handleAssign(file.id, { description: val } as any)
}
}}
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
style={{
width: '100%', padding: '7px 10px', fontSize: 13, borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
}}
/>
</div>
<div style={{ overflowY: 'auto', padding: 8 }}>
{(() => {
const file = files.find(f => f.id === assignFileId)
if (!file) return null
const assignedPlaceIds = new Set<number>()
const dayGroups: { day: Day; dayPlaces: Place[] }[] = []
for (const day of days) {
const da = assignments[String(day.id)] || []
const dayPlaces = da.map(a => places.find(p => p.id === a.place?.id || p.id === a.place_id)).filter(Boolean) as Place[]
if (dayPlaces.length > 0) {
dayGroups.push({ day, dayPlaces })
dayPlaces.forEach(p => assignedPlaceIds.add(p.id))
}
}
const unassigned = places.filter(p => !assignedPlaceIds.has(p.id))
const placeBtn = (p: Place, idx: number) => {
const isLinked = file.place_id === p.id || (file.linked_place_ids || []).includes(p.id)
return (
<button key={`${p.id}-${idx}`} onClick={async () => {
if (isLinked) {
if (file.place_id === p.id) {
await handleAssign(file.id, { place_id: null })
} else {
try {
const linksRes = await filesApi.getLinks(tripId, file.id)
const link = (linksRes.links || []).find((l: any) => l.place_id === p.id)
if (link) await filesApi.removeLink(tripId, file.id, link.id)
refreshFiles()
} catch {}
}
} else {
if (!file.place_id) {
await handleAssign(file.id, { place_id: p.id })
} else {
try {
await filesApi.addLink(tripId, file.id, { place_id: p.id })
refreshFiles()
} catch {}
}
}
}} 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)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
}
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 }}>
{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' }}>
<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)',
background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 999,
}}>{badge}</span>
) : null
})()}
</div>
{dayPlaces.map(placeBtn)}
</div>
))}
{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>}
{unassigned.map(placeBtn)}
</div>
)}
</div>
)
const bookingReservations = reservations.filter(r => !TRANSPORT_TYPES.has(r.type))
const transportReservations = reservations.filter(r => TRANSPORT_TYPES.has(r.type))
const reservationBtn = (r: Reservation) => {
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
const Icon = TRANSPORT_TYPES.has(r.type) ? transportIcon(r.type) : Ticket
return (
<button key={r.id} onClick={async () => {
if (isLinked) {
if (file.reservation_id === r.id) {
await handleAssign(file.id, { reservation_id: null })
} else {
try {
const linksRes = await filesApi.getLinks(tripId, file.id)
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
if (link) await filesApi.removeLink(tripId, file.id, link.id)
refreshFiles()
} catch {}
}
} else {
if (!file.reservation_id) {
await handleAssign(file.id, { reservation_id: r.id })
} else {
try {
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
refreshFiles()
} catch {}
}
}
}} 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)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
<Icon size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
}
const bookingsSection = reservations.length > 0 && (
<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 }}>
{t('files.assignBooking')}
</div>
{bookingReservations.map(reservationBtn)}
</>
)}
{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 }}>
{t('files.assignTransport')}
</div>
{transportReservations.map(reservationBtn)}
</>
)}
</div>
)
const hasBoth = placesSection && bookingsSection
return (
<div className={hasBoth ? 'md:flex' : ''}>
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingRight: hasBoth ? 6 : 0 }}>{placesSection}</div>
{hasBoth && <div className="hidden md:block" style={{ width: 1, background: 'var(--border-primary)', flexShrink: 0 }} />}
{hasBoth && <div className="block md:hidden" style={{ height: 1, background: 'var(--border-primary)', margin: '8px 0' }} />}
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingLeft: hasBoth ? 6 : 0 }}>{bookingsSection}</div>
</div>
)
})()}
</div>
</div>
</div>,
document.body
)
}
@@ -0,0 +1,11 @@
import { useState, useEffect } from 'react'
import { getAuthUrl } from '../../api/authUrl'
// Authenticated image — fetches a short-lived download token and renders the image
export function AuthedImg({ src, style }: { src: string; style?: React.CSSProperties }) {
const [authSrc, setAuthSrc] = useState('')
useEffect(() => {
getAuthUrl(src, 'download').then(setAuthSrc)
}, [src])
return authSrc ? <img src={authSrc} alt="" style={style} /> : null
}
@@ -0,0 +1,45 @@
import ReactDOM from 'react-dom'
import { useState, useRef } from 'react'
export function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avatarUrl?: string | null; size?: number }) {
const [hover, setHover] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const ref = useRef<HTMLDivElement>(null)
const onEnter = () => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect()
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
}
setHover(true)
}
return (
<>
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
style={{
width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)',
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
cursor: 'default',
}}>
{avatarUrl
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: name?.[0]?.toUpperCase()
}
</div>
{hover && ReactDOM.createPortal(
<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,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', whiteSpace: 'nowrap', zIndex: 9999,
pointerEvents: 'none',
}}>
{name}
</div>,
document.body
)}
</>
)
}
@@ -0,0 +1,79 @@
import { Upload, FileText, Star } from 'lucide-react'
import type { FileManagerState } from './useFileManager'
import { FileRow } from './FileManagerRow'
export function FilesView(S: FileManagerState) {
const {
can, trip, getRootProps, getInputProps, isDragActive, uploading, t, allowedFileTypes,
files, filterType, setFilterType, filteredFiles,
} = S
return (
<>
{/* Upload zone */}
{can('file_upload', trip) && <div
{...getRootProps()}
style={{
margin: '16px 28px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
}}
>
<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={{ 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 }}>
{(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
</p>
</>
)}
</div>}
{/* Filter tabs */}
<div className="md:!hidden" style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
{[
{ id: 'all', label: t('files.filterAll') },
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []),
{ id: 'pdf', label: t('files.filterPdf') },
{ id: 'image', label: t('files.filterImages') },
{ id: 'doc', label: t('files.filterDocs') },
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
].map(tab => (
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
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' }}>
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
</span>
</div>
{/* File list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 28px 16px' }} className="max-md:!px-4">
{filteredFiles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
<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>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredFiles.map(file => <FileRow key={file.id} {...S} file={file} />)}
</div>
)}
</div>
</>
)
}
@@ -0,0 +1,128 @@
import { useState, useEffect } from 'react'
import { ExternalLink, Download, X, ChevronLeft, ChevronRight } 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'
// Image lightbox with gallery navigation
interface ImageLightboxProps {
files: (TripFile & { url: string })[]
initialIndex: number
onClose: () => void
}
export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
const { t } = useTranslation()
const [index, setIndex] = useState(initialIndex)
const [imgSrc, setImgSrc] = useState('')
const [touchStart, setTouchStart] = useState<number | null>(null)
const file = files[index]
useEffect(() => {
setImgSrc('')
if (file) getAuthUrl(file.url, 'download').then(setImgSrc)
}, [file?.url])
const goPrev = () => setIndex(i => Math.max(0, i - 1))
const goNext = () => setIndex(i => Math.min(files.length - 1, i + 1))
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowLeft') goPrev()
if (e.key === 'ArrowRight') goNext()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
if (!file) return null
const hasPrev = index > 0
const hasNext = index < files.length - 1
const navBtn = (side: 'left' | 'right', onClick: () => void, show: boolean): React.ReactNode => show ? (
<button onClick={e => { e.stopPropagation(); onClick() }}
style={{
position: 'absolute', top: '50%', [side]: 12, transform: 'translateY(-50%)', zIndex: 10,
background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: 40, height: 40,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
color: 'rgba(255,255,255,0.8)', transition: 'background 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.75)')}
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.5)')}>
{side === 'left' ? <ChevronLeft size={22} /> : <ChevronRight size={22} />}
</button>
) : null
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column', paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
onTouchEnd={e => {
if (touchStart === null) return
const diff = e.changedTouches[0].clientX - touchStart
if (diff > 60) goPrev()
else if (diff < -60) goNext()
setTouchStart(null)
}}
>
{/* 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 }}>
{file.original_name}
<span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span>
</span>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button
onClick={() => openFileUrl(file.url, file.original_name).catch(() => {})}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
title={t('files.openTab')}>
<ExternalLink size={16} />
</button>
<button
onClick={() => triggerDownload(file.url, file.original_name)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
title={t('files.download') || 'Download'}>
<Download size={16} />
</button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}>
<X size={18} />
</button>
</div>
</div>
{/* Main image + nav */}
<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()} />}
{navBtn('right', goNext, hasNext)}
</div>
{/* Thumbnail strip */}
{files.length > 1 && (
<div style={{ display: 'flex', gap: 4, justifyContent: 'center', padding: '10px 16px', flexShrink: 0, overflowX: 'auto' }} onClick={e => e.stopPropagation()}>
{files.map((f, i) => (
<ThumbImg key={f.id} file={f} active={i === index} onClick={() => setIndex(i)} />
))}
</div>
)}
</div>
)
}
function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) {
const [src, setSrc] = useState('')
useEffect(() => { getAuthUrl(file.url, 'download').then(setSrc) }, [file.url])
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',
}}>
{src && <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />}
</button>
)
}
@@ -0,0 +1,57 @@
import ReactDOM from 'react-dom'
import { ExternalLink, Download, X } from 'lucide-react'
import { openFile as openFileUrl } from '../../utils/fileDownload'
import type { FileManagerState } from './useFileManager'
import { triggerDownload } from './FileManager.helpers'
export function PdfPreviewModal(S: FileManagerState) {
const { previewFile, setPreviewFile, previewFileUrl, toast, t } = S
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: 950, 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: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<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' }}
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' }}
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'}
</button>
<button onClick={() => setPreviewFile(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<X size={18} />
</button>
</div>
</div>
<object
data={previewFileUrl ? `${previewFileUrl}#view=FitH` : undefined}
type="application/pdf"
style={{ flex: 1, width: '100%', border: 'none' }}
title={previewFile.original_name}
>
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<button onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
</p>
</object>
</div>
</div>,
document.body
)
}
@@ -0,0 +1,136 @@
import { Trash2, ExternalLink, Download, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil } from 'lucide-react'
import type { TripFile } from '../../types'
import type { FileManagerState } from './useFileManager'
import { TRANSPORT_TYPES } from './FileManager.constants'
import { getFileIcon, isImage, formatSize, formatDateWithLocale, transportIcon, triggerDownload } from './FileManager.helpers'
import { AuthedImg } from './FileManagerAuthedImg'
import { AvatarChip } from './FileManagerAvatarChip'
import { SourceBadge } from './FileManagerSourceBadge'
export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolean }) {
const {
file, isTrash = false, places, reservations, t, locale, can, trip,
handleStar, handleRestore, handlePermanentDelete, handleDelete, openFile, setAssignFileId,
} = p
const FileIcon = getFileIcon(file.mime_type)
const allLinkedPlaceIds = new Set<number>()
if (file.place_id) allLinkedPlaceIds.add(file.place_id)
for (const pid of (file.linked_place_ids || [])) allLinkedPlaceIds.add(pid)
const linkedPlaces = [...allLinkedPlaceIds].map(pid => places?.find(p => p.id === pid)).filter(Boolean)
// All linked reservations (primary + file_links)
const allLinkedResIds = new Set<number>()
if (file.reservation_id) allLinkedResIds.add(file.reservation_id)
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid)
const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean)
return (
<div key={file.id} style={{
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 10,
transition: 'border-color 0.12s',
opacity: isTrash ? 0.7 : 1,
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
className="group"
>
{/* Icon or thumbnail */}
<div
onClick={() => !isTrash && openFile(file)}
style={{
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: isTrash ? 'default' : 'pointer', overflow: 'hidden',
}}
>
{isImage(file.mime_type)
? <AuthedImg src={file.url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: (() => {
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
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>
</div>
)
})()
}
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
{file.uploaded_by_name && (
<AvatarChip name={file.uploaded_by_name} avatarUrl={file.uploaded_by_avatar} size={20} />
)}
{!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' }}
>
{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>
)}
<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>
{linkedPlaces.map(p => (
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
))}
{linkedReservations.map(r => (
TRANSPORT_TYPES.has(r.type)
? <SourceBadge key={r.id} icon={transportIcon(r.type)} label={`${t('files.sourceTransport')} · ${r.title || t('files.sourceTransport')}`} />
: <SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
))}
{file.note_id && (
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
)}
</div>
</div>
{/* Actions — always visible on mobile, hover on desktop */}
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{isTrash ? (
<>
{can('file_delete', trip) && <button onClick={() => handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#22c55e'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<RotateCcw size={14} />
</button>}
{can('file_delete', trip) && <button onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={14} />
</button>}
</>
) : (
<>
<button onClick={() => handleStar(file.id)} title={file.starred ? t('files.unstar') || 'Unstar' : t('files.star') || 'Star'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: file.starred ? '#facc15' : 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => { if (!file.starred) e.currentTarget.style.color = '#facc15' }} onMouseLeave={e => { if (!file.starred) e.currentTarget.style.color = 'var(--text-faint)' }}>
<Star size={14} fill={file.starred ? '#facc15' : 'none'} />
</button>
{can('file_edit', trip) && <button onClick={() => setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={14} />
</button>}
<button onClick={() => openFile(file)} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ExternalLink size={14} />
</button>
<button onClick={() => triggerDownload(file.url, file.original_name)} title={t('files.download') || 'Download'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Download size={14} />
</button>
{can('file_delete', trip) && <button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={14} />
</button>}
</>
)}
</div>
</div>
)
}

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